Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
22
packages/plugins/embeds/src/astro/Bluesky.astro
Normal file
22
packages/plugins/embeds/src/astro/Bluesky.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
/**
|
||||
* Bluesky post embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's BlueskyPost component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*
|
||||
* Accepts either `id` or `url` field for compatibility with different content sources.
|
||||
*/
|
||||
import { BlueskyPost } from "astro-embed";
|
||||
import type { BlueskyBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: BlueskyBlock & { url?: string };
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
// Support both 'id' (schema) and 'url' (admin editor) field names
|
||||
const postId = node.id || node.url;
|
||||
---
|
||||
|
||||
{postId && <BlueskyPost id={postId} />}
|
||||
19
packages/plugins/embeds/src/astro/Gist.astro
Normal file
19
packages/plugins/embeds/src/astro/Gist.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
/**
|
||||
* GitHub Gist embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's Gist component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { Gist as AstroGist } from "astro-embed";
|
||||
import type { GistBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: GistBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id, file } = node;
|
||||
---
|
||||
|
||||
<AstroGist id={id} file={file} />
|
||||
19
packages/plugins/embeds/src/astro/LinkPreview.astro
Normal file
19
packages/plugins/embeds/src/astro/LinkPreview.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
/**
|
||||
* Link preview (Open Graph) embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's LinkPreview component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { LinkPreview as AstroLinkPreview } from "astro-embed";
|
||||
import type { LinkPreviewBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: LinkPreviewBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id, hideMedia } = node;
|
||||
---
|
||||
|
||||
<AstroLinkPreview id={id} hideMedia={hideMedia} />
|
||||
19
packages/plugins/embeds/src/astro/Mastodon.astro
Normal file
19
packages/plugins/embeds/src/astro/Mastodon.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
/**
|
||||
* Mastodon post embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's MastodonPost component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { MastodonPost } from "astro-embed";
|
||||
import type { MastodonBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: MastodonBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id } = node;
|
||||
---
|
||||
|
||||
<MastodonPost id={id} />
|
||||
19
packages/plugins/embeds/src/astro/Tweet.astro
Normal file
19
packages/plugins/embeds/src/astro/Tweet.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
/**
|
||||
* Tweet embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's Tweet component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { Tweet as AstroTweet } from "astro-embed";
|
||||
import type { TweetBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: TweetBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id, theme } = node;
|
||||
---
|
||||
|
||||
<AstroTweet id={id} theme={theme} />
|
||||
25
packages/plugins/embeds/src/astro/Vimeo.astro
Normal file
25
packages/plugins/embeds/src/astro/Vimeo.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
/**
|
||||
* Vimeo embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's Vimeo component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { Vimeo as AstroVimeo } from "astro-embed";
|
||||
import type { VimeoBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: VimeoBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id, poster, posterQuality, params, playlabel } = node;
|
||||
---
|
||||
|
||||
<AstroVimeo
|
||||
id={id}
|
||||
poster={poster}
|
||||
posterQuality={posterQuality}
|
||||
params={params}
|
||||
playlabel={playlabel}
|
||||
/>
|
||||
26
packages/plugins/embeds/src/astro/YouTube.astro
Normal file
26
packages/plugins/embeds/src/astro/YouTube.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
/**
|
||||
* YouTube embed component for Portable Text
|
||||
*
|
||||
* Wraps astro-embed's YouTube component, extracting props from the PT block node.
|
||||
* astro-portabletext passes `node` (not `value`) for custom type components.
|
||||
*/
|
||||
import { YouTube as AstroYouTube } from "astro-embed";
|
||||
import type { YouTubeBlock } from "../schemas.js";
|
||||
|
||||
interface Props {
|
||||
node: YouTubeBlock;
|
||||
}
|
||||
|
||||
const { node } = Astro.props;
|
||||
const { id, poster, posterQuality, params, playlabel, title } = node;
|
||||
---
|
||||
|
||||
<AstroYouTube
|
||||
id={id}
|
||||
poster={poster}
|
||||
posterQuality={posterQuality}
|
||||
params={params}
|
||||
playlabel={playlabel}
|
||||
title={title}
|
||||
/>
|
||||
66
packages/plugins/embeds/src/astro/index.ts
Normal file
66
packages/plugins/embeds/src/astro/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Astro components for rendering embed blocks in Portable Text
|
||||
*
|
||||
* These components are automatically registered with PortableText when
|
||||
* the embeds plugin is enabled. Manual wiring is no longer needed!
|
||||
*
|
||||
* The components are exported with lowercase names matching their block types
|
||||
* for auto-registration, plus PascalCase aliases for direct usage.
|
||||
*
|
||||
* @example Direct usage (if you need to customize)
|
||||
* ```astro
|
||||
* ---
|
||||
* import { YouTube } from "@emdash-cms/plugin-embeds/astro";
|
||||
* ---
|
||||
* <YouTube value={{ id: "dQw4w9WgXcQ", _type: "youtube", _key: "1" }} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import BlueskyComponent from "./Bluesky.astro";
|
||||
import GistComponent from "./Gist.astro";
|
||||
import LinkPreviewComponent from "./LinkPreview.astro";
|
||||
import MastodonComponent from "./Mastodon.astro";
|
||||
import TweetComponent from "./Tweet.astro";
|
||||
import VimeoComponent from "./Vimeo.astro";
|
||||
// Import all components
|
||||
import YouTubeComponent from "./YouTube.astro";
|
||||
|
||||
// Export with lowercase names (for auto-registration via virtual module)
|
||||
// These names MUST match the block type names in EMBED_BLOCK_TYPES
|
||||
export {
|
||||
YouTubeComponent as youtube,
|
||||
VimeoComponent as vimeo,
|
||||
TweetComponent as tweet,
|
||||
BlueskyComponent as bluesky,
|
||||
MastodonComponent as mastodon,
|
||||
LinkPreviewComponent as linkPreview,
|
||||
GistComponent as gist,
|
||||
};
|
||||
|
||||
// Also export with PascalCase for direct usage
|
||||
export {
|
||||
YouTubeComponent as YouTube,
|
||||
VimeoComponent as Vimeo,
|
||||
TweetComponent as Tweet,
|
||||
BlueskyComponent as Bluesky,
|
||||
MastodonComponent as Mastodon,
|
||||
LinkPreviewComponent as LinkPreview,
|
||||
GistComponent as Gist,
|
||||
};
|
||||
|
||||
/**
|
||||
* All embed components keyed by their Portable Text block type.
|
||||
* Exported as `blockComponents` for auto-registration via the virtual module,
|
||||
* and as `embedComponents` for direct usage.
|
||||
*/
|
||||
export const blockComponents = {
|
||||
youtube: YouTubeComponent,
|
||||
vimeo: VimeoComponent,
|
||||
tweet: TweetComponent,
|
||||
bluesky: BlueskyComponent,
|
||||
mastodon: MastodonComponent,
|
||||
linkPreview: LinkPreviewComponent,
|
||||
gist: GistComponent,
|
||||
} as const;
|
||||
|
||||
export { blockComponents as embedComponents };
|
||||
183
packages/plugins/embeds/src/index.ts
Normal file
183
packages/plugins/embeds/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Embeds Plugin for EmDash CMS
|
||||
*
|
||||
* Provides Portable Text block types for embedding external content:
|
||||
* - YouTube videos
|
||||
* - Vimeo videos
|
||||
* - Twitter/X tweets
|
||||
* - Bluesky posts
|
||||
* - Mastodon posts
|
||||
* - Link previews (Open Graph)
|
||||
* - GitHub Gists
|
||||
*
|
||||
* Uses astro-embed components for high-performance, privacy-respecting embeds.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // live.config.ts
|
||||
* import { embedsPlugin } from "@emdash-cms/plugin-embeds";
|
||||
*
|
||||
* export default defineConfig({
|
||||
* plugins: [embedsPlugin()],
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Embed components are automatically registered with PortableText when
|
||||
* the plugin is enabled. No manual component wiring needed!
|
||||
*
|
||||
* If you need to customize rendering, you can still override specific types:
|
||||
*
|
||||
* @example
|
||||
* ```astro
|
||||
* <PortableText
|
||||
* value={content}
|
||||
* components={{
|
||||
* types: {
|
||||
* youtube: MyCustomYouTube, // Override just this one
|
||||
* },
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Element } from "@emdash-cms/blocks";
|
||||
import type { PluginDescriptor, ResolvedPlugin } from "emdash";
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
import { EMBED_BLOCK_TYPES } from "./schemas.js";
|
||||
|
||||
/** Rich metadata for each embed block type */
|
||||
const EMBED_BLOCK_META: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
fields?: Element[];
|
||||
}
|
||||
> = {
|
||||
youtube: {
|
||||
label: "YouTube Video",
|
||||
icon: "video",
|
||||
placeholder: "Paste YouTube URL...",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "id",
|
||||
label: "YouTube URL",
|
||||
placeholder: "https://youtube.com/watch?v=...",
|
||||
},
|
||||
{ type: "text_input", action_id: "title", label: "Title" },
|
||||
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "params",
|
||||
label: "Player Parameters",
|
||||
placeholder: "start=57&end=75",
|
||||
},
|
||||
],
|
||||
},
|
||||
vimeo: {
|
||||
label: "Vimeo Video",
|
||||
icon: "video",
|
||||
placeholder: "Paste Vimeo URL...",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "id",
|
||||
label: "Vimeo URL",
|
||||
placeholder: "https://vimeo.com/...",
|
||||
},
|
||||
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
|
||||
{ type: "text_input", action_id: "params", label: "Player Parameters" },
|
||||
],
|
||||
},
|
||||
tweet: { label: "Tweet (X)", icon: "link", placeholder: "Paste tweet URL..." },
|
||||
bluesky: { label: "Bluesky Post", icon: "link", placeholder: "Paste Bluesky post URL..." },
|
||||
mastodon: { label: "Mastodon Post", icon: "link", placeholder: "Paste Mastodon post URL..." },
|
||||
linkPreview: {
|
||||
label: "Link Preview",
|
||||
icon: "link-external",
|
||||
placeholder: "Paste any URL...",
|
||||
},
|
||||
gist: {
|
||||
label: "GitHub Gist",
|
||||
icon: "code",
|
||||
placeholder: "Paste Gist URL...",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "id",
|
||||
label: "Gist URL",
|
||||
placeholder: "https://gist.github.com/.../...",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "file",
|
||||
label: "Specific File",
|
||||
placeholder: "Optional: filename to show",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export interface EmbedsPluginOptions {
|
||||
/**
|
||||
* Which embed types to enable.
|
||||
* Defaults to all types.
|
||||
*/
|
||||
types?: Array<(typeof EMBED_BLOCK_TYPES)[number]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the embeds plugin descriptor
|
||||
*/
|
||||
export function embedsPlugin(
|
||||
options: EmbedsPluginOptions = {},
|
||||
): PluginDescriptor<EmbedsPluginOptions> {
|
||||
return {
|
||||
id: "embeds",
|
||||
version: "0.0.1",
|
||||
entrypoint: "@emdash-cms/plugin-embeds",
|
||||
componentsEntry: "@emdash-cms/plugin-embeds/astro",
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the embeds plugin
|
||||
*/
|
||||
export function createPlugin(options: EmbedsPluginOptions = {}): ResolvedPlugin {
|
||||
const _enabledTypes = options.types ?? [...EMBED_BLOCK_TYPES];
|
||||
|
||||
return definePlugin({
|
||||
id: "embeds",
|
||||
version: "0.0.1",
|
||||
|
||||
// This plugin only provides block types - no server-side capabilities needed
|
||||
capabilities: [],
|
||||
|
||||
admin: {
|
||||
portableTextBlocks: _enabledTypes.map((type) => {
|
||||
const meta = EMBED_BLOCK_META[type];
|
||||
return {
|
||||
type,
|
||||
label: meta?.label ?? type,
|
||||
icon: meta?.icon,
|
||||
description: meta?.description,
|
||||
placeholder: meta?.placeholder,
|
||||
fields: meta?.fields,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export schemas for consumers who need them
|
||||
export * from "./schemas.js";
|
||||
|
||||
export default createPlugin;
|
||||
|
||||
// Re-export the enabled types for the plugin to use
|
||||
export { EMBED_BLOCK_TYPES };
|
||||
162
packages/plugins/embeds/src/schemas.ts
Normal file
162
packages/plugins/embeds/src/schemas.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Block schemas for embed types
|
||||
*
|
||||
* These define the Portable Text block structure for each embed type.
|
||||
* The schemas match the props expected by astro-embed components.
|
||||
*/
|
||||
|
||||
import { z } from "astro/zod";
|
||||
|
||||
/** Matches http(s) scheme at start of URL */
|
||||
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
||||
|
||||
/** Validates that a URL string uses http or https scheme. Rejects javascript:/data: URI XSS vectors. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => HTTP_SCHEME_RE.test(url), "URL must use http or https");
|
||||
|
||||
/**
|
||||
* YouTube embed block
|
||||
* @see https://astro-embed.netlify.app/components/youtube/
|
||||
*/
|
||||
export const youtubeBlockSchema = z.object({
|
||||
_type: z.literal("youtube"),
|
||||
_key: z.string(),
|
||||
/** YouTube video ID or URL */
|
||||
id: z.string(),
|
||||
/** Custom poster image URL */
|
||||
poster: httpUrl.optional(),
|
||||
/** Poster quality when using default YouTube thumbnail */
|
||||
posterQuality: z.enum(["max", "high", "default", "low"]).optional(),
|
||||
/** YouTube player parameters (e.g., "start=57&end=75") */
|
||||
params: z.string().optional(),
|
||||
/** Accessible label for the play button */
|
||||
playlabel: z.string().optional(),
|
||||
/** Visible title overlay */
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export type YouTubeBlock = z.infer<typeof youtubeBlockSchema>;
|
||||
|
||||
/**
|
||||
* Vimeo embed block
|
||||
* @see https://astro-embed.netlify.app/components/vimeo/
|
||||
*/
|
||||
export const vimeoBlockSchema = z.object({
|
||||
_type: z.literal("vimeo"),
|
||||
_key: z.string(),
|
||||
/** Vimeo video ID or URL */
|
||||
id: z.string(),
|
||||
/** Custom poster image URL */
|
||||
poster: httpUrl.optional(),
|
||||
/** Poster quality */
|
||||
posterQuality: z.enum(["max", "high", "default", "low"]).optional(),
|
||||
/** Vimeo player parameters */
|
||||
params: z.string().optional(),
|
||||
/** Accessible label for the play button */
|
||||
playlabel: z.string().optional(),
|
||||
});
|
||||
|
||||
export type VimeoBlock = z.infer<typeof vimeoBlockSchema>;
|
||||
|
||||
/**
|
||||
* Twitter/X tweet embed block
|
||||
* @see https://astro-embed.netlify.app/components/twitter/
|
||||
*/
|
||||
export const tweetBlockSchema = z.object({
|
||||
_type: z.literal("tweet"),
|
||||
_key: z.string(),
|
||||
/** Tweet URL or ID */
|
||||
id: z.string(),
|
||||
/** Color theme */
|
||||
theme: z.enum(["light", "dark"]).optional(),
|
||||
});
|
||||
|
||||
export type TweetBlock = z.infer<typeof tweetBlockSchema>;
|
||||
|
||||
/**
|
||||
* Bluesky post embed block
|
||||
* @see https://astro-embed.netlify.app/components/bluesky/
|
||||
*/
|
||||
export const blueskyBlockSchema = z.object({
|
||||
_type: z.literal("bluesky"),
|
||||
_key: z.string(),
|
||||
/** Bluesky post URL or AT URI */
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type BlueskyBlock = z.infer<typeof blueskyBlockSchema>;
|
||||
|
||||
/**
|
||||
* Mastodon post embed block
|
||||
* @see https://astro-embed.netlify.app/components/mastodon/
|
||||
*/
|
||||
export const mastodonBlockSchema = z.object({
|
||||
_type: z.literal("mastodon"),
|
||||
_key: z.string(),
|
||||
/** Mastodon post URL */
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type MastodonBlock = z.infer<typeof mastodonBlockSchema>;
|
||||
|
||||
/**
|
||||
* Link preview / Open Graph embed block
|
||||
* @see https://astro-embed.netlify.app/components/link-preview/
|
||||
*/
|
||||
export const linkPreviewBlockSchema = z.object({
|
||||
_type: z.literal("linkPreview"),
|
||||
_key: z.string(),
|
||||
/** URL to fetch Open Graph data from */
|
||||
id: httpUrl,
|
||||
/** Hide media (image/video) even if present in OG data */
|
||||
hideMedia: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type LinkPreviewBlock = z.infer<typeof linkPreviewBlockSchema>;
|
||||
|
||||
/**
|
||||
* GitHub Gist embed block
|
||||
* @see https://astro-embed.netlify.app/components/gist/
|
||||
*/
|
||||
export const gistBlockSchema = z.object({
|
||||
_type: z.literal("gist"),
|
||||
_key: z.string(),
|
||||
/** Gist URL */
|
||||
id: httpUrl,
|
||||
/** Specific file to show (case-sensitive) */
|
||||
file: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GistBlock = z.infer<typeof gistBlockSchema>;
|
||||
|
||||
/**
|
||||
* Union of all embed block types
|
||||
*/
|
||||
export const embedBlockSchema = z.discriminatedUnion("_type", [
|
||||
youtubeBlockSchema,
|
||||
vimeoBlockSchema,
|
||||
tweetBlockSchema,
|
||||
blueskyBlockSchema,
|
||||
mastodonBlockSchema,
|
||||
linkPreviewBlockSchema,
|
||||
gistBlockSchema,
|
||||
]);
|
||||
|
||||
export type EmbedBlock = z.infer<typeof embedBlockSchema>;
|
||||
|
||||
/**
|
||||
* Block type names for use in plugin registration
|
||||
*/
|
||||
export const EMBED_BLOCK_TYPES = [
|
||||
"youtube",
|
||||
"vimeo",
|
||||
"tweet",
|
||||
"bluesky",
|
||||
"mastodon",
|
||||
"linkPreview",
|
||||
"gist",
|
||||
] as const;
|
||||
|
||||
export type EmbedBlockType = (typeof EMBED_BLOCK_TYPES)[number];
|
||||
Reference in New Issue
Block a user