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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View 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} />}

View 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} />

View 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} />

View 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} />

View 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} />

View 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}
/>

View 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}
/>

View 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 };

View 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 };

View 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];