Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions docs/authoring/mdx-to-write-source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
/(<Figure caption=(?:"([\s\S]*?)"|\{("(?:[^"\\]|\\.)*")\})>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)/g;
/(<Figure caption=(?:"([\s\S]*?)"|\{("(?:[^"\\]|\\.)*")\})>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(<Interactive\b([^>]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)/g;

let cursor = 0;
const segments = [];
Expand All @@ -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) });
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions docs/authoring/write-source-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Interactive>` 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).
Expand Down
103 changes: 103 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
68 changes: 68 additions & 0 deletions src/components/Interactive.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const inlineSlotRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const dialogSlotRef = useRef<HTMLDivElement>(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 (
<div className={`ix ix--${size}`}>
{(title || expand) && (
<div className="ix-head">
{title && <span className="ix-title">{title}</span>}
{expand && (
<button type="button" className="ix-expand" onClick={open} aria-label="Expand">
</button>
)}
</div>
)}
<div ref={inlineSlotRef}>
<div ref={bodyRef}>{children}</div>
</div>
{expand && (
<dialog ref={dialogRef} className="ix-dialog" onClose={onClose}>
<div className="ix-dialog-head">
<span className="ix-title">{title ?? 'Interactive'}</span>
<button
type="button"
className="ix-expand"
onClick={() => dialogRef.current?.close()}
aria-label="Close"
>
</button>
</div>
<div ref={dialogSlotRef} />
</dialog>
)}
</div>
);
}
63 changes: 63 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading