From 713f3a79562d100db17a179ef487fd6c8274ee16 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:47:19 -0700 Subject: [PATCH 1/2] Add Interactive component frame with size, expand, and live editor preview --- docs/authoring/write-source-format.md | 8 ++ package-lock.json | 103 +++++++++++++++++++++ package.json | 1 + src/components/Interactive.tsx | 68 ++++++++++++++ src/styles/global.css | 63 +++++++++++++ src/write/WritePortal.tsx | 1 + src/write/editor/blocks/ComponentBlock.tsx | 34 +++++-- src/write/editor/editor-theme.css | 60 +++++++++++- src/write/preview/LiveComponent.tsx | 59 ++++++++++++ src/write/preview/PreviewPane.tsx | 25 ++++- src/write/serialize/toMdx.test.ts | 32 +++++++ src/write/serialize/toMdx.ts | 19 +++- 12 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 src/components/Interactive.tsx create mode 100644 src/write/preview/LiveComponent.tsx diff --git a/docs/authoring/write-source-format.md b/docs/authoring/write-source-format.md index 2cc1207..161ff15 100644 --- a/docs/authoring/write-source-format.md +++ b/docs/authoring/write-source-format.md @@ -257,6 +257,14 @@ Agents **can and should** author complete diagrams this way. Rules: use `stroke= `source` is a complete TSX module with a default export; `componentName` matches the function name. It ships as a separate file next to the post. +Optional display props (all default off — omit them for a component that renders inline at normal width): + +- `frameTitle` (string) — small mono label above the component. +- `frameSize` (`"normal"` | `"wide"`) — `"wide"` renders at up to 960px, extending past the text column; clamps to the screen on mobile. +- `frameExpand` (boolean) — adds an expand button that opens the component in a fullscreen overlay (Esc closes, state preserved). + +When any of these is set, the published MDX wraps the component in the site's `` frame automatically. + ## Rules of thumb for a good agent draft 1. Use heading level 1 for sections and 2 for subsections — `meta.title` is the page's H1, so editor levels publish one step deeper (level 1 → H2). diff --git a/package-lock.json b/package-lock.json index eaa0606..67c8365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "shiki": "^1.24.0", + "sucrase": "^3.35.1", "zod": "^4.4.3" }, "devDependencies": { @@ -54,6 +55,9 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.59.4", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@astrojs/check": { @@ -4798,6 +4802,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -9144,6 +9154,12 @@ "unicode-trie": "^2.0.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/lint-staged": { "version": "17.0.5", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.5.tgz", @@ -10542,6 +10558,17 @@ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -10631,6 +10658,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11093,6 +11129,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12963,6 +13008,37 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/suf-log": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", @@ -13046,6 +13122,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -13149,6 +13246,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 1910d8e..60206b4 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "remark-math": "^6.0.0", "satori": "^0.26.0", "shiki": "^1.24.0", + "sucrase": "^3.35.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/components/Interactive.tsx b/src/components/Interactive.tsx new file mode 100644 index 0000000..a8313a2 --- /dev/null +++ b/src/components/Interactive.tsx @@ -0,0 +1,68 @@ +import { useRef, type ReactNode } from 'react'; + +export default function Interactive({ + title, + size = 'normal', + expand = false, + children, +}: { + title?: string; + size?: 'normal' | 'wide'; + expand?: boolean; + children: ReactNode; +}) { + const bodyRef = useRef(null); + const inlineSlotRef = useRef(null); + const dialogRef = useRef(null); + const dialogSlotRef = useRef(null); + + const open = () => { + if (!bodyRef.current || !dialogRef.current || !dialogSlotRef.current) return; + dialogSlotRef.current.appendChild(bodyRef.current); + dialogRef.current.showModal(); + document.documentElement.style.overflow = 'hidden'; + }; + + // Fires for ✕ and Esc alike — the single restore point. The widget's DOM + // node is moved, not re-rendered, so its React state survives the trip. + const onClose = () => { + if (bodyRef.current && inlineSlotRef.current) { + inlineSlotRef.current.appendChild(bodyRef.current); + } + document.documentElement.style.overflow = ''; + }; + + return ( +
+ {(title || expand) && ( +
+ {title && {title}} + {expand && ( + + )} +
+ )} +
+
{children}
+
+ {expand && ( + +
+ {title ?? 'Interactive'} + +
+
+
+ )} +
+ ); +} diff --git a/src/styles/global.css b/src/styles/global.css index 692f984..98a9feb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3725,3 +3725,66 @@ mark.reader-hl:hover { padding: 24px 0; } } + +/* ---------- Interactive component frame ---------- */ + +.ix { + margin: 32px 0; +} +.ix-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 18px; +} +.ix-title { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink); + border-bottom: 2px solid var(--accent); + padding-bottom: 3px; +} +.ix-expand { + margin-left: auto; + border: 1px solid var(--line-2); + background: transparent; + color: var(--ink-2); + border-radius: 5px; + padding: 2px 8px; + cursor: pointer; + font-size: 13px; + line-height: 1.4; +} +.ix-expand:hover { + border-color: var(--accent); + color: var(--accent); +} +.ix--wide { + position: relative; + width: min(960px, calc(100vw - 48px)); + left: 50%; + transform: translateX(-50%); +} +.ix-dialog { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper); + color: var(--ink-body); + padding: 20px 24px; + width: min(1100px, calc(100vw - 40px)); + max-height: calc(100vh - 48px); +} +.ix-dialog::backdrop { + background: rgba(24, 23, 26, 0.55); + backdrop-filter: blur(2px); +} +.ix-dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} diff --git a/src/write/WritePortal.tsx b/src/write/WritePortal.tsx index 0d06e0c..30743e7 100644 --- a/src/write/WritePortal.tsx +++ b/src/write/WritePortal.tsx @@ -390,6 +390,7 @@ export default function WritePortal({ authors, topics, repoUrl, contactEmail }: const handleSelectAll = (e: ReactKeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'a' || e.shiftKey || e.altKey) return; + if ((e.target as HTMLElement).closest('input, textarea, select')) return; const view = editor.prosemirrorView; if (!view) return; const { state } = view; diff --git a/src/write/editor/blocks/ComponentBlock.tsx b/src/write/editor/blocks/ComponentBlock.tsx index dd64fd1..a1e39fb 100644 --- a/src/write/editor/blocks/ComponentBlock.tsx +++ b/src/write/editor/blocks/ComponentBlock.tsx @@ -7,6 +7,9 @@ export const createComponentBlock = createReactBlockSpec( propSchema: { componentName: { default: '' }, source: { default: '' }, + frameTitle: { default: '' }, + frameSize: { default: 'normal', values: ['normal', 'wide'] }, + frameExpand: { default: false }, }, content: 'none', }, @@ -19,9 +22,7 @@ export const createComponentBlock = createReactBlockSpec( return (
- - Custom React component — shows here as a placeholder, renders after publish - + Custom React component setProps({ source: e.target.value })} /> - {name && !badName && block.props.source && ( -
⚙ {name}.tsx — ships with your post folder
- )} +
+ + + setProps({ frameTitle: e.target.value })} + /> +
); }, diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css index f73a333..8d83112 100644 --- a/src/write/editor/editor-theme.css +++ b/src/write/editor/editor-theme.css @@ -220,19 +220,17 @@ font-weight: 600; color: var(--accent); background: var(--paper); - border: 1px solid var(--accent-soft); + border: 1.5px solid var(--accent); border-radius: var(--radius-pill, 99px); padding: 10px 20px; cursor: pointer; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); transition: - border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; } .write-preview-btn:hover { - border-color: var(--accent); - background: var(--accent-soft); + background: color-mix(in srgb, var(--accent) 10%, var(--paper)); transform: translateY(-1px); } :root[data-theme='dark'] .write-preview-btn { @@ -1356,3 +1354,57 @@ .bn-editor .bn-block-content[data-content-type='codeBlock'] > div > select:focus { opacity: 1; } + +.write-component-frame { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.write-component-frame select { + border: 1px solid var(--line-2); + border-radius: 6px; + padding: 6px 8px; + font-family: inherit; + font-size: 13px; + background: var(--paper); + color: var(--ink); +} +.write-component-frame label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--ink-2); + white-space: nowrap; +} +.write-component-frame input[type='checkbox'] { + width: auto; + accent-color: var(--accent); +} +.write-component-frame input[type='text'] { + flex: 1; + min-width: 160px; +} + +.write-component { + display: grid; + gap: 10px; +} +.write-component .write-block-label { + margin-bottom: 0; +} +.write-component textarea { + resize: vertical; + width: 100%; +} + +.write-live-error { + font-family: var(--font-mono); + font-size: 13px; + color: #b0342c; + border: 1px dashed #b0342c; + border-radius: 6px; + padding: 10px 14px; + background: color-mix(in srgb, #b0342c 6%, transparent); +} diff --git a/src/write/preview/LiveComponent.tsx b/src/write/preview/LiveComponent.tsx new file mode 100644 index 0000000..163108e --- /dev/null +++ b/src/write/preview/LiveComponent.tsx @@ -0,0 +1,59 @@ +import * as ReactAll from 'react'; +import { Component, useMemo, type ComponentType, type ReactNode } from 'react'; +import { transform } from 'sucrase'; + +const reactModule = { ...ReactAll, default: ReactAll, __esModule: true }; + +export function compileComponent(source: string): { Comp?: ComponentType; error?: string } { + try { + const { code } = transform(source, { + transforms: ['typescript', 'jsx', 'imports'], + jsxRuntime: 'classic', + production: true, + }); + const mod: { exports: Record } = { exports: {} }; + const req = (name: string) => { + if (name === 'react') return reactModule; + throw new Error(`import '${name}' isn't available in preview — only 'react' is.`); + }; + new Function('require', 'module', 'exports', 'React', code)(req, mod, mod.exports, ReactAll); + const Comp = mod.exports.default as ComponentType | undefined; + if (typeof Comp !== 'function') return { error: 'No default-exported component found.' }; + return { Comp }; + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } +} + +class Boundary extends Component<{ children: ReactNode; resetKey: string }, { error: string }> { + state = { error: '' }; + static getDerivedStateFromError(err: unknown) { + return { error: err instanceof Error ? err.message : String(err) }; + } + componentDidUpdate(prev: { resetKey: string }) { + if (prev.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: '' }); + } + render() { + if (this.state.error) { + return
⚠ Component crashed: {this.state.error}
; + } + return this.props.children; + } +} + +export default function LiveComponent({ name, source }: { name: string; source: string }) { + const { Comp, error } = useMemo(() => compileComponent(source), [source]); + if (error) { + return ( +
+ ⚠ {name || 'Component'}: {error} +
+ ); + } + if (!Comp) return null; + return ( + + + + ); +} diff --git a/src/write/preview/PreviewPane.tsx b/src/write/preview/PreviewPane.tsx index 86a9edc..6977d02 100644 --- a/src/write/preview/PreviewPane.tsx +++ b/src/write/preview/PreviewPane.tsx @@ -1,6 +1,8 @@ import type { CSSProperties, ReactNode } from 'react'; import katex from 'katex'; import { mdxComponents as C } from '@/components/MDXComponents'; +import Interactive from '@/components/Interactive'; +import LiveComponent from './LiveComponent'; import { getAssetUrl } from '../storage/assets'; import { sanitizeSvg } from '../lib/sanitizeSvg'; import { @@ -225,11 +227,28 @@ function renderOne(block: SBlock, variants: Variants): ReactNode { } case 'customComponent': { const name = String(block.props.componentName ?? ''); - return ( -
- ⚡ Interactive component {name ? `<${name}>` : ''} — runs live on the published site + const source = String(block.props.source ?? ''); + const placeholder = source.trim() ? ( + + ) : ( +
+ ⚡ Interactive component {name ? `<${name}>` : ''} — add code to see it run
); + const title = String(block.props.frameTitle ?? '').trim(); + const wide = block.props.frameSize === 'wide'; + const expand = Boolean(block.props.frameExpand); + if (!title && !wide && !expand) return
{placeholder}
; + return ( + + {placeholder} + + ); } case 'table': return renderTable(block, variants); diff --git a/src/write/serialize/toMdx.test.ts b/src/write/serialize/toMdx.test.ts index e84c540..0a1be1c 100644 --- a/src/write/serialize/toMdx.test.ts +++ b/src/write/serialize/toMdx.test.ts @@ -371,8 +371,40 @@ describe('custom component blocks', () => { ); expect(mdx).toContain("import Viz from './Viz';"); expect(mdx).toContain(''); + expect(mdx).not.toContain(' when frame options are set', () => { + const source = 'export default function Viz() {\n return
hi
;\n}'; + const { mdx } = serializePost( + meta, + [ + block('customComponent', { + componentName: 'Viz', + source, + frameTitle: 'Throughput, live', + frameSize: 'wide', + frameExpand: true, + }), + ], + { tableVariants: {}, today }, + ); + expect(mdx).toContain("import Interactive from '../../../components/Interactive';"); + expect(mdx).toContain( + '\n \n', + ); + }); + + it('omits the wrapper import when only defaults are used', () => { + const source = 'export default function Viz() { return null; }'; + const { mdx } = serializePost( + meta, + [block('customComponent', { componentName: 'Viz', source, frameSize: 'normal' })], + { tableVariants: {}, today }, + ); + expect(mdx).not.toContain('Interactive'); + }); }); describe('svg blocks', () => { diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts index 0ccad0c..a87a572 100644 --- a/src/write/serialize/toMdx.ts +++ b/src/write/serialize/toMdx.ts @@ -279,6 +279,8 @@ function serializeGallery(block: SBlock, ctx: Ctx): string { return `\n${images}\n`; } +const INTERACTIVE_IMPORT = "import Interactive from '../../../components/Interactive';"; + function serializeComponent(block: SBlock, ctx: Ctx): string { const name = String(block.props.componentName ?? ''); const source = String(block.props.source ?? ''); @@ -287,7 +289,22 @@ function serializeComponent(block: SBlock, ctx: Ctx): string { ctx.componentFiles.push({ fileName: `${name}.tsx`, source }); ctx.componentImports.push(`import ${name} from './${name}';`); } - return `<${name} client:visible />`; + const el = `<${name} client:visible />`; + const title = String(block.props.frameTitle ?? '').trim(); + const wide = block.props.frameSize === 'wide'; + const expand = Boolean(block.props.frameExpand); + if (!title && !wide && !expand) return el; + if (!ctx.componentImports.includes(INTERACTIVE_IMPORT)) { + ctx.componentImports.push(INTERACTIVE_IMPORT); + } + const attrs = [ + title ? attr('title', title) : null, + wide ? 'size="wide"' : null, + expand ? 'expand' : null, + ] + .filter(Boolean) + .join(' '); + return `\n ${el}\n`; } function aligned(block: SBlock, s: string): string { From 3b02305eb87598f6ac6da94623622fba88b59200 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:49:18 -0700 Subject: [PATCH 2/2] Converter maps custom components and Interactive frames --- docs/authoring/mdx-to-write-source.mjs | 37 ++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/authoring/mdx-to-write-source.mjs b/docs/authoring/mdx-to-write-source.mjs index c531c90..fe3ce26 100644 --- a/docs/authoring/mdx-to-write-source.mjs +++ b/docs/authoring/mdx-to-write-source.mjs @@ -101,9 +101,29 @@ const cell = (text) => ({ props: { colspan: 1, rowspan: 1, ...D }, }); +const DIR = SRC.replace(/\/index\.mdx$/, ''); + +function componentSource(name) { + const file = `${DIR}/${name}.tsx`; + if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8'); + console.warn(`warning: ${name}.tsx not found next to index.mdx — source left empty`); + return ''; +} + +function frameProps(attrs) { + const title = + attrs.match(/title="([^"]*)"/)?.[1] ?? + JSON.parse(attrs.match(/title=\{("(?:[^"\\]|\\.)*")\}/)?.[1] ?? '""'); + return { + frameTitle: title, + frameSize: /size="wide"/.test(attrs) ? 'wide' : 'normal', + frameExpand: /(^|\s)expand(\s|$|=)/.test(attrs), + }; +} + let rest = body; const pattern = - /(
\s*([\s\S]*?)\s*<\/Figure>)|(\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)/g; + /(
\s*([\s\S]*?)\s*<\/Figure>)|(\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)/g; let cursor = 0; const segments = []; @@ -120,6 +140,13 @@ while ((m = pattern.exec(rest))) { } else if (m[5]) segments.push({ kind: 'note', text: m[6].replace(/\s+/g, ' ').trim() }); else if (m[7]) segments.push({ kind: 'code', lang: m[8] || 'text', code: m[9].replace(/\n$/, '') }); + else if (m[10]) segments.push({ kind: 'component', name: m[12], frame: frameProps(m[11]) }); + else if (m[13]) + segments.push({ + kind: 'component', + name: m[14], + frame: { frameTitle: '', frameSize: 'normal', frameExpand: false }, + }); cursor = m.index + m[0].length; } if (cursor < rest.length) segments.push({ kind: 'md', text: rest.slice(cursor) }); @@ -129,7 +156,7 @@ function emitMd(text) { let i = 0; while (i < lines.length) { const line = lines[i]; - if (!line.trim()) { + if (!line.trim() || /^import\s.+\sfrom\s/.test(line)) { i++; continue; } @@ -233,6 +260,12 @@ for (const seg of segments) { else if (seg.kind === 'note') push('note', {}, inline(seg.text)); else if (seg.kind === 'code') push('codeBlock', { language: seg.lang }, [{ type: 'text', text: seg.code, styles: {} }]); + else if (seg.kind === 'component') + push('customComponent', { + componentName: seg.name, + source: componentSource(seg.name), + ...seg.frame, + }); } const doc = {