From aa02f6c541b17a1f155afbf42c279bf75a209ba8 Mon Sep 17 00:00:00 2001 From: Rahul Anand <41159815+rahulnsanand@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:13 +0530 Subject: [PATCH 1/2] feat: sized media grammar, unified MediaFrame, byte-stable embeds --- packages/headless/src/core/markdown.test.ts | 6 +- .../headless/src/core/source-capability.ts | 3 + .../embeds/EmbedResolverContext.tsx | 66 ++- .../src/extensions/embeds/FileEmbedNode.tsx | 446 +++++++++++++----- .../headless/src/extensions/embeds/index.ts | 6 + packages/headless/src/extensions/index.ts | 22 + .../extensions/media/IframeEmbedExtension.tsx | 35 +- .../src/extensions/media/ImageExtension.tsx | 33 +- .../src/extensions/media/MediaFrame.test.tsx | 159 +++++++ .../src/extensions/media/MediaFrame.tsx | 285 +++++++++++ .../media/YouTubeEmbedExtension.tsx | 35 +- .../headless/src/extensions/media/index.ts | 2 + .../src/extensions/media/mediaGrammar.test.ts | 194 ++++++++ .../src/extensions/media/mediaGrammar.ts | 299 ++++++++++++ packages/luthor/src/core/styles.css | 159 ++++++- .../papyra-collab/papyraCollab.test.ts | 28 ++ packages/luthor/src/presets/papyra/adapter.ts | 23 +- packages/luthor/src/presets/papyra/embeds.ts | 17 +- .../src/presets/papyra/mediaGolden.test.tsx | 302 ++++++++++++ packages/luthor/src/presets/papyra/papyra.css | 16 +- 20 files changed, 1974 insertions(+), 162 deletions(-) create mode 100644 packages/headless/src/extensions/media/MediaFrame.test.tsx create mode 100644 packages/headless/src/extensions/media/MediaFrame.tsx create mode 100644 packages/headless/src/extensions/media/mediaGrammar.test.ts create mode 100644 packages/headless/src/extensions/media/mediaGrammar.ts create mode 100644 packages/luthor/src/presets/papyra/mediaGolden.test.tsx diff --git a/packages/headless/src/core/markdown.test.ts b/packages/headless/src/core/markdown.test.ts index 5afb24ad..2114098f 100644 --- a/packages/headless/src/core/markdown.test.ts +++ b/packages/headless/src/core/markdown.test.ts @@ -1562,10 +1562,12 @@ describe("markdown bridge", () => { }; const markdown = jsonToMarkdown(input); - expect(markdown).toContain("![Example](https://example.com/photo.jpg"); + // Size is native markdown (Obsidian's alt-size); the rest rides in metadata. + expect(markdown).toContain("![Example|720x405](https://example.com/photo.jpg"); expect(markdown).toContain("luthor:meta v1"); + expect(markdown).not.toContain('"width":720'); - const editedMarkdown = markdown.replace("![Example]", "![Updated]"); + const editedMarkdown = markdown.replace("![Example|720x405]", "![Updated|720x405]"); const roundTrip = markdownToJSON(editedMarkdown) as JsonDocument; const imageNode = findTopLevelNode(roundTrip, "image") as { src?: string; diff --git a/packages/headless/src/core/source-capability.ts b/packages/headless/src/core/source-capability.ts index a81fec35..2c7d73de 100644 --- a/packages/headless/src/core/source-capability.ts +++ b/packages/headless/src/core/source-capability.ts @@ -79,6 +79,9 @@ export const MARKDOWN_NATIVE_KEY_MAP: Readonly string; + resolveMediaUrl?: (target: string, options?: MediaUrlOptions) => string; + /** + * Synchronous, cached metadata for a media target — `undefined` while unknown. + * Must return the *same object* for the same target until it changes (it is + * read through `useSyncExternalStore`). Pair with {@link subscribeMediaMeta} + * to have embeds re-render when a lookup lands. + */ + getMediaMeta?: (target: string) => MediaMeta | null | undefined; + /** Subscribe to metadata arriving/changing. Returns an unsubscribe function. */ + subscribeMediaMeta?: (listener: () => void) => () => void; + /** + * Extra content under a non-image file card — e.g. an inline PDF viewer the + * host renders on demand. Return `null` for files it has nothing to add to. + */ + renderFileExpansion?: (context: FileExpansionContext) => ReactNode; /** * Navigate to a link target (the `Target` inside `[[Target]]`). Invoked when a * reader activates a wikilink. When omitted, the wikilink renders as inert diff --git a/packages/headless/src/extensions/embeds/FileEmbedNode.tsx b/packages/headless/src/extensions/embeds/FileEmbedNode.tsx index 0f921fd3..974c384f 100644 --- a/packages/headless/src/extensions/embeds/FileEmbedNode.tsx +++ b/packages/headless/src/extensions/embeds/FileEmbedNode.tsx @@ -15,132 +15,183 @@ import { type SerializedLexicalNode, type Spread, } from "lexical"; -import type { ElementTransformer } from "@lexical/markdown"; +import type { ElementTransformer, TextMatchTransformer } from "@lexical/markdown"; import type { ReactNode } from "react"; import { ExtensionCategory } from "@lyfie/luthor-headless/extensions/types"; import { BaseExtension } from "@lyfie/luthor-headless/extensions/base"; -import { useEmbedResolvers } from "./EmbedResolverContext"; +import { MediaFrame } from "../media/MediaFrame"; +import { + formatEmbedTarget, + formatMediaDirectives, + isFileTarget, + parseEmbedTarget, + parseMediaDirectives, + type MediaAlignment, +} from "../media/mediaGrammar"; /** - * Serialized shape of a {@link FileEmbedNode}. Only the verbatim `target` (the - * `file.ext` reference) is persisted; the resolved URL and the chosen media - * element are derived at render time, so the stored data mirrors the markdown. + * Serialized shape of a {@link FileEmbedNode} (version 2). + * + * Version 1 stored only `target` — the whole text inside `![[…]]`, pipes and + * all. {@link FileEmbedNode.importJSON} still reads it (live collaboration rooms + * and saved editor states carry v1 nodes), parsing that text with the media + * grammar. */ export type SerializedFileEmbedNode = Spread< { + /** The file reference, without fragment or pipe segments. */ target: string; + fragment?: string; + alt?: string; + width?: number; + height?: number; + align?: MediaAlignment; + caption?: string; + /** Pipe segments the grammar doesn't own, verbatim. */ + extra?: string[]; + /** Trailing `` directives the grammar doesn't own, verbatim. */ + directives?: string[]; + escapedPipes?: boolean; + inline?: boolean; + /** The exact markdown this embed was parsed from (byte-stable export). */ + source?: string; }, SerializedLexicalNode >; -const IMAGE_EXTENSION = /\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i; -const AUDIO_EXTENSION = /\.(?:mp3|wav|ogg|oga|m4a|flac|aac)$/i; -const VIDEO_EXTENSION = /\.(?:mp4|webm|mov|m4v|ogv)$/i; - -type EmbedKind = "image" | "audio" | "video" | "file"; +/** The editable fields of an embed, as the markdown grammar sees them. */ +export interface FileEmbedFields { + target: string; + fragment?: string; + alt?: string; + width?: number; + height?: number; + align?: MediaAlignment; + caption?: string; + extra?: string[]; + directives?: string[]; + escapedPipes?: boolean; + inline?: boolean; +} -function classifyEmbed(target: string): EmbedKind { - if (IMAGE_EXTENSION.test(target)) { - return "image"; - } - if (AUDIO_EXTENSION.test(target)) { - return "audio"; - } - if (VIDEO_EXTENSION.test(target)) { - return "video"; - } - return "file"; +function formatFields(fields: FileEmbedFields): string { + const inner = formatEmbedTarget({ + target: fields.target, + fragment: fields.fragment ?? "", + alt: fields.alt, + width: fields.width, + height: fields.height, + extra: fields.extra ?? [], + escapedPipes: fields.escapedPipes ?? false, + }); + // Directives are a line-level syntax: an inline embed has none of its own. + const directives = fields.inline + ? "" + : formatMediaDirectives({ + align: fields.align, + caption: fields.caption, + unknown: fields.directives ?? [], + }); + return `![[${inner}]]${directives}`; } /** - * React view for a file embed. Resolves the target to a URL through the host - * {@link EmbedResolvers} and renders the matching media element; when no URL can - * be resolved (no host wired, or a non-media file) it renders a reference chip - * showing the raw target, so the embed is always visible and the markdown still - * round-trips. + * Parse one embed's markdown (`![[inner]]` plus, for a block, any trailing + * directives). Null when it isn't one. */ -function FileEmbedComponent({ target }: { target: string }): ReactNode { - const { resolveMediaUrl } = useEmbedResolvers(); - const url = - typeof resolveMediaUrl === "function" ? resolveMediaUrl(target) : ""; - const kind = classifyEmbed(target); - - if (url && kind === "image") { - return ( - {target} - ); - } - - if (url && kind === "audio") { - return ( -