From b9ee326aa479802ef03ae88d7454aa43040cc429 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:05:18 +0530 Subject: [PATCH 1/6] feat: validate MDX components at compile time Fail compilation with file, line, and column when content uses a component that is not a standard HTML/SVG element, a registered Chronicle component, or imported/defined in the MDX file. Tag lists come from html-tag-names/svg-tag-names; custom elements and member expressions on registered components are allowed. Co-Authored-By: Claude Fable 5 --- bun.lock | 7 ++ packages/chronicle/package.json | 5 +- .../chronicle/src/components/mdx/index.tsx | 2 + .../chronicle/src/lib/mdx-component-names.ts | 15 +++ .../src/lib/remark-validate-mdx.test.ts | 95 +++++++++++++++++++ .../chronicle/src/lib/remark-validate-mdx.ts | 85 +++++++++++++++++ packages/chronicle/src/server/vite-config.ts | 2 + 7 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 packages/chronicle/src/lib/mdx-component-names.ts create mode 100644 packages/chronicle/src/lib/remark-validate-mdx.test.ts create mode 100644 packages/chronicle/src/lib/remark-validate-mdx.ts diff --git a/bun.lock b/bun.lock index 6ad64e0..1f99fe3 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "glob": "^11.0.0", "gray-matter": "^4.0.3", "h3": "^2.0.1-rc.16", + "html-tag-names": "^2.1.0", "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", @@ -66,6 +67,7 @@ "sharp": "^0.34.5", "slugify": "^1.6.6", "std-env": "^4.1.0", + "svg-tag-names": "^3.0.1", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "use-analytics": "^1.1.0", @@ -89,6 +91,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "semver": "^7.7.4", "typescript": "5.9.3", + "vfile": "^6.0.3", }, }, }, @@ -773,6 +776,8 @@ "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], + "html-tag-names": ["html-tag-names@2.1.0", "", {}, "sha512-+NhBUNvS5Ig8g8LO1DBpyapuGsu+FLcpLwmMADCntvU9IUa30++WV3GmuBHZm8Y9cHdIson/uvbQ/6jVL63XZw=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], @@ -1147,6 +1152,8 @@ "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "svg-tag-names": ["svg-tag-names@3.0.1", "", {}, "sha512-R5EDS44RQSOwwKIOOUZLKZggMeFL8fDSSZr1hVK5Z0KkyuGVOZfyepBZDkPu/aoL+hSOysxxvAk8uN4yN2LOMQ=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 69d5f52..ed642c0 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -34,7 +34,8 @@ "@types/unist": "^3.0.3", "mdast-util-mdx-jsx": "^3.2.0", "semver": "^7.7.4", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vfile": "^6.0.3" }, "dependencies": { "@analytics/google-analytics": "^1.1.0", @@ -66,6 +67,7 @@ "glob": "^11.0.0", "gray-matter": "^4.0.3", "h3": "^2.0.1-rc.16", + "html-tag-names": "^2.1.0", "http-status-codes": "^2.3.0", "lodash-es": "^4.17.23", "mermaid": "^11.13.0", @@ -84,6 +86,7 @@ "sharp": "^0.34.5", "slugify": "^1.6.6", "std-env": "^4.1.0", + "svg-tag-names": "^3.0.1", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "use-analytics": "^1.1.0", diff --git a/packages/chronicle/src/components/mdx/index.tsx b/packages/chronicle/src/components/mdx/index.tsx index 2334333..731fc5b 100644 --- a/packages/chronicle/src/components/mdx/index.tsx +++ b/packages/chronicle/src/components/mdx/index.tsx @@ -24,6 +24,8 @@ MdxTabs.List = Tabs.List MdxTabs.Tab = Tabs.Tab MdxTabs.Content = Tabs.Content +// Capitalized keys must stay in sync with MDX_COMPONENT_NAMES in +// src/lib/mdx-component-names.ts (used for compile-time validation). export const mdxComponents: MDXComponents = { p: MdxParagraph, img: MDXImage, diff --git a/packages/chronicle/src/lib/mdx-component-names.ts b/packages/chronicle/src/lib/mdx-component-names.ts new file mode 100644 index 0000000..50ca457 --- /dev/null +++ b/packages/chronicle/src/lib/mdx-component-names.ts @@ -0,0 +1,15 @@ +import { htmlTagNames } from 'html-tag-names' +import { svgTagNames } from 'svg-tag-names' + +// Names of custom components available to MDX content. +// Keep in sync with the capitalized keys of `mdxComponents` in src/components/mdx/index.tsx. +// This lives in a React-free module so the CLI/vite-config can import it. +export const MDX_COMPONENT_NAMES = [ + 'Callout', + 'CalloutTitle', + 'CalloutDescription', + 'Tabs', + 'Mermaid', +] as const + +export const KNOWN_TAGS = new Set([...htmlTagNames, ...svgTagNames]) diff --git a/packages/chronicle/src/lib/remark-validate-mdx.test.ts b/packages/chronicle/src/lib/remark-validate-mdx.test.ts new file mode 100644 index 0000000..ba6b855 --- /dev/null +++ b/packages/chronicle/src/lib/remark-validate-mdx.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import type { Root } from 'mdast'; +import { VFile } from 'vfile'; +import remarkValidateMdxPlugin, { type RemarkValidateMdxOptions } from './remark-validate-mdx'; + +const remarkValidateMdx = remarkValidateMdxPlugin as unknown as ( + options?: RemarkValidateMdxOptions, +) => (tree: Root, file: VFile) => void; + +const position = { + start: { line: 3, column: 1, offset: 10 }, + end: { line: 3, column: 10, offset: 19 }, +}; + +function jsxNode(name: string | null, type = 'mdxJsxFlowElement') { + return { type, name, attributes: [], children: [], position } as unknown as Root['children'][number]; +} + +function importNode(localName: string) { + return { + type: 'mdxjsEsm', + value: `import ${localName} from './x'`, + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ImportDeclaration', + specifiers: [{ type: 'ImportDefaultSpecifier', local: { name: localName } }], + }, + ], + }, + }, + } as unknown as Root['children'][number]; +} + +function run(children: Root['children']) { + const tree: Root = { type: 'root', children }; + const file = new VFile({ path: '/project/.content/docs/test.mdx' }); + remarkValidateMdx()(tree, file); +} + +describe('remarkValidateMdx', () => { + test('fails on unknown capitalized component', () => { + expect(() => run([jsxNode('Foo')])).toThrow(/Unknown component /); + }); + + test('reports position of the offending node', () => { + try { + run([jsxNode('Foo')]); + throw new Error('expected failure'); + } catch (err) { + expect((err as { line?: number }).line).toBe(3); + expect((err as { column?: number }).column).toBe(1); + } + }); + + test('allows registered components', () => { + expect(() => run([jsxNode('Callout'), jsxNode('Mermaid')])).not.toThrow(); + }); + + test('allows member expressions on registered components', () => { + expect(() => run([jsxNode('Tabs.Tab'), jsxNode('Tabs.Content', 'mdxJsxTextElement')])).not.toThrow(); + }); + + test('fails on member expressions with unknown root', () => { + expect(() => run([jsxNode('Foo.Bar')])).toThrow(/Unknown component /); + }); + + test('fails on unknown lowercase tag', () => { + expect(() => run([jsxNode('foox')])).toThrow(/Unknown HTML tag /); + }); + + test('allows standard HTML and SVG tags', () => { + expect(() => run([jsxNode('div'), jsxNode('details'), jsxNode('clipPath'), jsxNode('svg')])).not.toThrow(); + }); + + test('allows custom elements with a dash', () => { + expect(() => run([jsxNode('my-widget')])).not.toThrow(); + }); + + test('allows fragments', () => { + expect(() => run([jsxNode(null)])).not.toThrow(); + }); + + test('allows components imported within the MDX file', () => { + expect(() => run([importNode('Chart'), jsxNode('Chart')])).not.toThrow(); + }); + + test('respects custom components option', () => { + const tree: Root = { type: 'root', children: [jsxNode('Special')] }; + const file = new VFile({ path: '/t.mdx' }); + expect(() => remarkValidateMdx({ components: ['Special'] })(tree, file)).not.toThrow(); + }); +}); diff --git a/packages/chronicle/src/lib/remark-validate-mdx.ts b/packages/chronicle/src/lib/remark-validate-mdx.ts new file mode 100644 index 0000000..d96857a --- /dev/null +++ b/packages/chronicle/src/lib/remark-validate-mdx.ts @@ -0,0 +1,85 @@ +import type { Root } from 'mdast' +import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' +import type { Plugin } from 'unified' +import type { Node } from 'unist' +import { visit } from 'unist-util-visit' +import type { VFile } from 'vfile' +import { KNOWN_TAGS, MDX_COMPONENT_NAMES } from './mdx-component-names' +import { MdxNodeType } from './mdx-utils' + +export interface RemarkValidateMdxOptions { + /** Custom component names allowed in MDX content. Defaults to MDX_COMPONENT_NAMES. */ + components?: readonly string[] +} + +interface EstreeNode { + type: string + specifiers?: Array<{ local?: { name?: string } }> + declaration?: EstreeNode | null + declarations?: Array<{ id?: { name?: string } }> + id?: { name?: string } + body?: EstreeNode[] +} + +/** Names declared by import/export statements inside the MDX file itself. */ +function collectLocalNames(tree: Root): Set { + const names = new Set() + visit(tree, 'mdxjsEsm', (node: Node) => { + const program = (node as Node & { data?: { estree?: EstreeNode } }).data?.estree + for (const stmt of program?.body ?? []) { + if (stmt.type === 'ImportDeclaration') { + for (const spec of stmt.specifiers ?? []) { + if (spec.local?.name) names.add(spec.local.name) + } + } + const decl = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt + if (!decl) continue + if (decl.type === 'VariableDeclaration') { + for (const d of decl.declarations ?? []) { + if (d.id?.name) names.add(d.id.name) + } + } else if ((decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') && decl.id?.name) { + names.add(decl.id.name) + } + } + }) + return names +} + +/** + * Fails compilation with a precise message (file, line, column) when MDX + * content uses a component that is neither a standard HTML/SVG element, + * a registered Chronicle component, nor imported/defined in the file. + * Unclosed or mismatched tags are already rejected earlier by the MDX parser. + */ +const remarkValidateMdx: Plugin<[RemarkValidateMdxOptions?], Root> = (options = {}) => { + const allowed = new Set(options.components ?? MDX_COMPONENT_NAMES) + + return (tree, file: VFile) => { + const locals = collectLocalNames(tree) + + visit(tree, [MdxNodeType.JsxFlow, MdxNodeType.JsxText], (node) => { + const { name, position } = node as MdxJsxFlowElement | MdxJsxTextElement + if (!name) return // fragment <>... + + const root = name.split('.')[0] + if (root.includes('-')) return // custom elements like + if (locals.has(root)) return + + if (/^[a-z]/.test(root)) { + if (KNOWN_TAGS.has(root)) return + file.fail( + `Unknown HTML tag <${name}> — not a standard HTML/SVG element. If it is a custom component, register it in Chronicle's MDX components.`, + position, + ) + } else if (!allowed.has(root)) { + file.fail( + `Unknown component <${name}> — available components: ${[...allowed].join(', ')}. Import it in this file or register it in Chronicle's MDX components.`, + position, + ) + } + }) + } +} + +export default remarkValidateMdx diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 62d7f7b..4a689a9 100644 --- a/packages/chronicle/src/server/vite-config.ts +++ b/packages/chronicle/src/server/vite-config.ts @@ -11,6 +11,7 @@ import remarkResolveImages from '../lib/remark-resolve-images'; import remarkResolveLinks from '../lib/remark-resolve-links'; import remarkReadingTime from 'remark-reading-time'; import remarkUnusedDirectives from '../lib/remark-unused-directives'; +import remarkValidateMdx from '../lib/remark-validate-mdx'; function getDatabaseConnector(preset?: string): { connector: string; options?: Record } { switch (preset) { @@ -105,6 +106,7 @@ export async function createViteConfig( [remarkResolveImages, { optimize: !isStaticPreset(preset) }], remarkMdxMermaid, remarkReadingTime, + remarkValidateMdx, ], }, }), From 65f1d6ee4f974baae5a21155b84dbcd646e44e5e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:05:26 +0530 Subject: [PATCH 2/6] feat: show detailed MDX error page in dev browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare 500/JSON response with a styled dev-only error page naming the failing file, position, and a highlighted code frame. Handles both compile failures (unclosed tags, unknown components) via the Nitro error handler — where they land because the eager frontmatter glob compiles all content at startup — and runtime MDX errors via the SSR catch. Co-Authored-By: Claude Fable 5 --- packages/chronicle/src/lib/mdx-error.test.ts | 78 ++++++++++++ packages/chronicle/src/lib/mdx-error.ts | 91 ++++++++++++++ .../chronicle/src/server/dev-error-page.ts | 118 ++++++++++++++++++ .../chronicle/src/server/entry-server.tsx | 5 + packages/chronicle/src/server/error.ts | 14 ++- 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 packages/chronicle/src/lib/mdx-error.test.ts create mode 100644 packages/chronicle/src/lib/mdx-error.ts create mode 100644 packages/chronicle/src/server/dev-error-page.ts diff --git a/packages/chronicle/src/lib/mdx-error.test.ts b/packages/chronicle/src/lib/mdx-error.test.ts new file mode 100644 index 0000000..09e3bac --- /dev/null +++ b/packages/chronicle/src/lib/mdx-error.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { buildCodeFrame, parseMdxError } from './mdx-error'; + +describe('parseMdxError', () => { + test('parses VFileMessage shape (parse errors, file.fail)', () => { + const info = parseMdxError({ + reason: 'Unknown component — available components: Callout', + line: 12, + column: 3, + file: '/project/.content/docs/setup.mdx', + message: 'ignored', + }); + expect(info).toEqual({ + message: 'Unknown component — available components: Callout', + file: '/project/.content/docs/setup.mdx', + line: 12, + column: 3, + }); + }); + + test('parses Rollup/Vite transform error shape', () => { + const info = parseMdxError({ + message: 'Expected a closing tag for `` (2:1-2:10)', + loc: { file: '/p/.content/docs/a.mdx', line: 2, column: 1 }, + }); + expect(info?.file).toBe('/p/.content/docs/a.mdx'); + expect(info?.line).toBe(2); + expect(info?.column).toBe(1); + }); + + test('falls back to id + message-embedded position', () => { + const info = parseMdxError({ + message: 'Unexpected closing tag ``, expected corresponding opening tag (5:1-5:7)', + id: '/p/.content/docs/b.mdx', + }); + expect(info?.file).toBe('/p/.content/docs/b.mdx'); + expect(info?.line).toBe(5); + expect(info?.column).toBe(1); + }); + + test('strips vite query suffix from module ids', () => { + const info = parseMdxError({ + message: 'boom at (1:1)', + id: '/p/.content/docs/c.mdx?collection=default', + }); + expect(info?.file).toBe('/p/.content/docs/c.mdx'); + }); + + test('detects MDX runtime missing-component errors', () => { + const info = parseMdxError(new Error('Expected component `Foo` to be defined: you likely forgot to import, pass, or provide it.')); + expect(info?.message).toContain('Unknown component '); + }); + + test('returns null for unrelated errors', () => { + expect(parseMdxError(new Error('connect ECONNREFUSED'))).toBeNull(); + expect(parseMdxError({ message: 'fail', id: '/p/src/app.tsx' })).toBeNull(); + expect(parseMdxError(null)).toBeNull(); + expect(parseMdxError('string error')).toBeNull(); + }); +}); + +describe('buildCodeFrame', () => { + const source = 'one\ntwo\nthree\nfour\nfive\nsix'; + + test('includes context lines around the target', () => { + const frame = buildCodeFrame(source, 3, 2); + expect(frame.lines.map(l => l.number)).toEqual([1, 2, 3, 4, 5]); + expect(frame.lines.find(l => l.target)?.text).toBe('three'); + expect(frame.caretColumn).toBe(2); + }); + + test('clamps at file boundaries', () => { + const first = buildCodeFrame(source, 1); + expect(first.lines.map(l => l.number)).toEqual([1, 2, 3]); + const last = buildCodeFrame(source, 6); + expect(last.lines.map(l => l.number)).toEqual([4, 5, 6]); + }); +}); diff --git a/packages/chronicle/src/lib/mdx-error.ts b/packages/chronicle/src/lib/mdx-error.ts new file mode 100644 index 0000000..a9c49b8 --- /dev/null +++ b/packages/chronicle/src/lib/mdx-error.ts @@ -0,0 +1,91 @@ +export interface MdxErrorInfo { + /** Human-readable reason, without file/position noise. */ + message: string + /** Absolute path of the failing content file, when known. */ + file?: string + line?: number + column?: number +} + +export interface CodeFrame { + lines: Array<{ number: number; text: string; target: boolean }> + /** 1-based caret column on the target line, when known. */ + caretColumn?: number +} + +interface ErrorLike { + message?: string + reason?: string + file?: string + line?: number + column?: number + place?: { line?: number; column?: number; start?: { line?: number; column?: number } } + loc?: { file?: string; line?: number; column?: number } + id?: string +} + +// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color codes +const ANSI_PATTERN = /\x1b\[[0-9;]*m/g + +const isContentFile = (p?: string): p is string => !!p && /\.mdx?$/.test(p.split('?')[0]) + +function cleanMessage(raw: string): string { + const lines = raw + .replace(ANSI_PATTERN, '') + .split('\n') + .map(line => + line + .replace(/^\[(?:plugin|vite)[^\]]*\]\s*/, '') + .replace(/^\S*\.mdx?(?::[\d:-]+)*:?\s*/, '') + .trim(), + ) + .filter(line => line && !/^Build failed with \d+ errors?:?$/.test(line)) + return lines[0] ?? raw.trim() +} + +/** + * Extracts file/position info from the various error shapes an MDX failure + * can surface as: a VFileMessage (parse errors, remark plugin `file.fail`), + * a Rollup/Vite transform error (`loc`/`id`), or MDX's runtime + * `_missingMdxReference` throw. Returns null for errors unrelated to MDX. + */ +export function parseMdxError(err: unknown): MdxErrorInfo | null { + if (!err || typeof err !== 'object') return null + const e = err as ErrorLike + const rawMessage = e.reason || e.message || '' + + // MDX runtime: unknown component reached rendering (no file context available) + const missing = rawMessage.match(/Expected component `([^`]+)` to be defined/) + if (missing) { + return { message: `Unknown component <${missing[1]}> — it is not registered in Chronicle's MDX components.` } + } + + const file = [e.file, e.loc?.file, e.id, ...(rawMessage.match(/((?:\/|\.\.?\/)[^\s:]+\.mdx?)/) ?? [])] + .find(isContentFile) + if (!file) return null + + const place = e.place?.start ?? e.place + let line = e.line ?? place?.line ?? e.loc?.line + let column = e.column ?? place?.column ?? e.loc?.column + if (line == null) { + // Fall back to `12:3` or `12:3-14:1` position embedded in the message text + const pos = rawMessage.match(/(\d+):(\d+)(?:-\d+:\d+)?/) + if (pos) { + line = Number(pos[1]) + column = Number(pos[2]) + } + } + + return { message: cleanMessage(rawMessage), file: file.split('?')[0], line, column } +} + +export function buildCodeFrame(source: string, line: number, column?: number, context = 2): CodeFrame { + const all = source.split(/\r?\n/) + const start = Math.max(1, line - context) + const end = Math.min(all.length, line + context) + const lines = [] + for (let n = start; n <= end; n++) { + lines.push({ number: n, text: all[n - 1] ?? '', target: n === line }) + } + return { lines, caretColumn: column } +} diff --git a/packages/chronicle/src/server/dev-error-page.ts b/packages/chronicle/src/server/dev-error-page.ts new file mode 100644 index 0000000..937209f --- /dev/null +++ b/packages/chronicle/src/server/dev-error-page.ts @@ -0,0 +1,118 @@ +import { buildCodeFrame, type CodeFrame, parseMdxError } from '@/lib/mdx-error' + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function displayPath(file: string): string { + const roots = [ + typeof __CHRONICLE_CONTENT_DIR__ !== 'undefined' ? __CHRONICLE_CONTENT_DIR__ : '', + typeof __CHRONICLE_PROJECT_ROOT__ !== 'undefined' ? __CHRONICLE_PROJECT_ROOT__ : '', + ] + for (const root of roots) { + if (root && file.startsWith(root)) return file.slice(root.length).replace(/^\//, '') + } + return file +} + +function frameHtml(frame: CodeFrame): string { + const gutter = String(frame.lines[frame.lines.length - 1]?.number ?? 0).length + const rows = frame.lines.flatMap(({ number, text, target }) => { + const row = `
${target ? '>' : ' '} ${String(number).padStart(gutter)}${escapeHtml(text)}
` + if (target && frame.caretColumn) { + return [row, `
${' '.repeat(gutter + 1)}${' '.repeat(frame.caretColumn - 1)}^
`] + } + return [row] + }) + return `
${rows.join('')}
` +} + +/** + * Dev-only: renders a styled error page for MDX failures (syntax errors, + * unknown components) naming the file, position, and a source code frame. + * Returns null when the error is not MDX-related. + */ +export async function renderMdxErrorResponse(err: unknown): Promise { + const info = parseMdxError(err) + if (!info) return null + + let frame: CodeFrame | null = null + if (info.file && info.line) { + try { + const fs = await import('node:fs/promises') + frame = buildCodeFrame(await fs.readFile(info.file, 'utf-8'), info.line, info.column) + } catch { + // source not readable — show the message without a code frame + } + } + + const location = info.file + ? `${displayPath(info.file)}${info.line ? `:${info.line}${info.column ? `:${info.column}` : ''}` : ''}` + : null + + const html = ` + + + + + MDX Error + + + +
+ MDX Error +

${escapeHtml(info.message)}

+ ${location ? `
${escapeHtml(location)}
` : ''} + ${frame ? frameHtml(frame) : ''} +

Fix the file and refresh the page.

+
+ +` + + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html;charset=utf-8' }, + }) +} diff --git a/packages/chronicle/src/server/entry-server.tsx b/packages/chronicle/src/server/entry-server.tsx index e5c4c3b..242d809 100644 --- a/packages/chronicle/src/server/entry-server.tsx +++ b/packages/chronicle/src/server/entry-server.tsx @@ -207,6 +207,11 @@ export default { }); } catch (err) { console.error(`[chronicle] SSR error for ${pathname}:`, err); + if (import.meta.env.DEV) { + const { renderMdxErrorResponse } = await import('./dev-error-page'); + const mdxError = await renderMdxErrorResponse(err); + if (mdxError) return mdxError; + } return errorResponse(500, 'Internal Server Error', err instanceof Error ? err.message : String(err)); } }, diff --git a/packages/chronicle/src/server/error.ts b/packages/chronicle/src/server/error.ts index 9662b1e..1131094 100644 --- a/packages/chronicle/src/server/error.ts +++ b/packages/chronicle/src/server/error.ts @@ -1,9 +1,21 @@ import { defineErrorHandler, HTTPError } from 'nitro'; -export default defineErrorHandler((error, _event) => { +export default defineErrorHandler(async (error, _event) => { const status = HTTPError.isError(error) ? error.status : 500; const message = error.message || 'Internal Server Error'; + // MDX failures in content (syntax errors, unknown components) surface here: + // the eager frontmatter glob in source.ts compiles every content file at + // module load, before the SSR fetch handler can catch anything. + if (import.meta.env.DEV) { + const { renderMdxErrorResponse } = await import('./dev-error-page'); + const candidates = [error, (error as { cause?: unknown }).cause]; + for (const candidate of candidates) { + const response = candidate ? await renderMdxErrorResponse(candidate) : null; + if (response) return response; + } + } + return new Response(JSON.stringify({ error: true, status, message }), { status, headers: { 'Content-Type': 'application/json' }, From 1f140707125f340126f4d1998dc9f6c2ace9c9f2 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:05:35 +0530 Subject: [PATCH 3/6] feat: report MDX errors with file and code frame in chronicle build Catch build failures, print a formatted report (relative path, line:column, message, code frame) for MDX-related errors, and exit non-zero; unrelated errors are rethrown unchanged. Co-Authored-By: Claude Fable 5 --- packages/chronicle/src/cli/commands/build.ts | 53 +++++++++++-------- .../src/cli/utils/mdx-error-report.ts | 48 +++++++++++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 packages/chronicle/src/cli/utils/mdx-error-report.ts diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index a3f42d4..da1570f 100644 --- a/packages/chronicle/src/cli/commands/build.ts +++ b/packages/chronicle/src/cli/commands/build.ts @@ -30,28 +30,37 @@ export const buildCommand = new Command('build') preset }); - if (isStaticPreset(preset)) { - await build(viteConfig); - } else { - const builder = await createBuilder({ ...viteConfig, builder: {} }); - await builder.buildApp(); - } + try { + if (isStaticPreset(preset)) { + await build(viteConfig); + } else { + const builder = await createBuilder({ ...viteConfig, builder: {} }); + await builder.buildApp(); + } + + if (isStaticPreset(preset)) { + const { generateStaticSite } = await import('@/cli/commands/static-generate'); + const outputDir = path.resolve(projectRoot, '.output/public'); + + await generateStaticSite({ + projectRoot, + config, + outputDir, + packageRoot: PACKAGE_ROOT, + }); - if (isStaticPreset(preset)) { - const { generateStaticSite } = await import('@/cli/commands/static-generate'); - const outputDir = path.resolve(projectRoot, '.output/public'); - - await generateStaticSite({ - projectRoot, - config, - outputDir, - packageRoot: PACKAGE_ROOT, - }); - - console.log(chalk.green('Static build complete')); - console.log(chalk.cyan(`Output: ${outputDir}`)); - } else { - console.log(chalk.green('Build complete')); - console.log(chalk.cyan('Run `chronicle start` to start the server')); + console.log(chalk.green('Static build complete')); + console.log(chalk.cyan(`Output: ${outputDir}`)); + } else { + console.log(chalk.green('Build complete')); + console.log(chalk.cyan('Run `chronicle start` to start the server')); + } + } catch (err) { + const { printMdxBuildError } = await import('@/cli/utils/mdx-error-report'); + if (await printMdxBuildError(err, PACKAGE_ROOT)) { + console.error(chalk.red('Build failed')); + process.exit(1); + } + throw err; } }); diff --git a/packages/chronicle/src/cli/utils/mdx-error-report.ts b/packages/chronicle/src/cli/utils/mdx-error-report.ts new file mode 100644 index 0000000..0c0ece7 --- /dev/null +++ b/packages/chronicle/src/cli/utils/mdx-error-report.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import chalk from 'chalk'; +import { buildCodeFrame, parseMdxError } from '@/lib/mdx-error'; + +/** + * Prints a formatted report for MDX build failures (file, line:column, + * message, code frame). Returns false when the error is not MDX-related + * so the caller can rethrow it. + */ +export async function printMdxBuildError(err: unknown, packageRoot: string): Promise { + const info = parseMdxError(err); + if (!info) return false; + + const contentMirror = path.resolve(packageRoot, '.content'); + const displayPath = info.file?.startsWith(contentMirror) + ? path.relative(contentMirror, info.file) + : info.file && !path.relative(process.cwd(), info.file).startsWith('..') + ? path.relative(process.cwd(), info.file) + : info.file; + + console.error(); + console.error(chalk.bgRed.white(' MDX Error '), chalk.red(info.message)); + if (displayPath) { + const position = info.line ? `:${info.line}${info.column ? `:${info.column}` : ''}` : ''; + console.error(chalk.dim(` ${displayPath}${position}`)); + } + + if (info.file && info.line) { + try { + const source = await fs.readFile(info.file, 'utf-8'); + const frame = buildCodeFrame(source, info.line, info.column); + const gutter = String(frame.lines[frame.lines.length - 1]?.number ?? 0).length; + console.error(); + for (const { number, text, target } of frame.lines) { + const ln = `${target ? '>' : ' '} ${String(number).padStart(gutter)} | `; + console.error(target ? chalk.red(ln) + text : chalk.dim(ln + text)); + if (target && frame.caretColumn) { + console.error(chalk.red(` ${' '.repeat(gutter)} | ${' '.repeat(frame.caretColumn - 1)}^`)); + } + } + } catch { + // source not readable — message-only report + } + } + console.error(); + return true; +} From 19d7838f016d26175a07c3240b7be1443834592e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:05:35 +0530 Subject: [PATCH 4/6] feat: surface render errors on client navigation instead of 404 Track the failure message in page context and show a dedicated 'Failed to render page' state with the error detail, instead of the misleading 'Page not found' empty state. Co-Authored-By: Claude Fable 5 --- packages/chronicle/src/lib/page-context.tsx | 14 ++++++++++++-- packages/chronicle/src/pages/DocsPage.tsx | 5 +++-- .../chronicle/src/pages/NotFound.module.css | 10 ++++++++++ packages/chronicle/src/pages/RenderError.tsx | 18 ++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 packages/chronicle/src/pages/RenderError.tsx diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 92aaff3..2ffbe43 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -26,6 +26,8 @@ interface PageContextValue { page: Page | null; isLoading: boolean; errorStatus: number | null; + /** Render/load failure detail (e.g. MDX compile error) — non-status errors only. */ + errorMessage: string | null; apiSpecs: ApiSpec[]; version: VersionContext; } @@ -45,6 +47,7 @@ export function usePageContext(): PageContextValue { page: null, isLoading: false, errorStatus: null, + errorMessage: null, apiSpecs: [], version: LATEST_CONTEXT, }; @@ -85,6 +88,7 @@ export function PageProvider({ const [tree] = useState(initialTree); const [page, setPage] = useState(initialPage); const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, initialConfig, pathname)); + const [errorMessage, setErrorMessage] = useState(null); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); const [version, setVersion] = useState(initialVersion); const [isLoading, setIsLoading] = useState(false); @@ -140,6 +144,7 @@ export function PageProvider({ const { content, toc } = await loadMdx(data.originalPath || data.relativePath); if (cancelled.current) return; setErrorStatus(null); + setErrorMessage(null); setPage({ slug, frontmatter: data.frontmatter, @@ -150,9 +155,11 @@ export function PageProvider({ }); } catch (err) { if (cancelled.current) return; - const status = Number((err as Error).message) || 500; + const raw = (err as Error).message; + const status = Number(raw) || 500; setPage(null); setErrorStatus(status); + setErrorMessage(Number(raw) ? null : raw); } finally { if (!cancelled.current) setIsLoading(false); } @@ -170,6 +177,7 @@ export function PageProvider({ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { setPage(null); setErrorStatus(null); + setErrorMessage(null); fetchApiSpecs(route, cancelled); return () => { cancelled.current = true; }; } @@ -177,6 +185,7 @@ export function PageProvider({ if (route.type !== RouteType.DocsPage) { setPage(null); setErrorStatus(null); + setErrorMessage(null); return () => { cancelled.current = true; }; } @@ -190,13 +199,14 @@ export function PageProvider({ setPage(null); setErrorStatus(null); + setErrorMessage(null); loadDocsPage(route.slug, cancelled); return () => { cancelled.current = true; }; }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage, navigate]); return ( {children} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index fdad235..c428a32 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -4,6 +4,7 @@ import { Head } from '@/lib/head'; import { usePageContext } from '@/lib/page-context'; import { resolveDocsRedirect } from '@/lib/tree-utils'; import { NotFound } from '@/pages/NotFound'; +import { RenderError } from '@/pages/RenderError'; import { getTheme } from '@/themes/registry'; interface DocsPageProps { @@ -11,7 +12,7 @@ interface DocsPageProps { } export function DocsPage({ slug }: DocsPageProps) { - const { config, tree, page, isLoading, errorStatus } = usePageContext(); + const { config, tree, page, isLoading, errorStatus, errorMessage } = usePageContext(); if (errorStatus === StatusCodes.NOT_FOUND) { const contentConfig = config.content?.find(c => c.dir === slug[0]); @@ -19,7 +20,7 @@ export function DocsPage({ slug }: DocsPageProps) { if (redirectUrl) return ; return ; } - if (errorStatus) return ; + if (errorStatus) return ; const { Page, Skeleton } = getTheme(config.theme?.name); if (isLoading || !page) return ; diff --git a/packages/chronicle/src/pages/NotFound.module.css b/packages/chronicle/src/pages/NotFound.module.css index 5228309..29dbeeb 100644 --- a/packages/chronicle/src/pages/NotFound.module.css +++ b/packages/chronicle/src/pages/NotFound.module.css @@ -1,3 +1,13 @@ .emptyState { justify-content: center; } + +.errorDetail { + max-width: 640px; + margin: 0; + white-space: pre-wrap; + text-align: left; + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-small); + color: var(--rs-color-foreground-base-secondary); +} diff --git a/packages/chronicle/src/pages/RenderError.tsx b/packages/chronicle/src/pages/RenderError.tsx new file mode 100644 index 0000000..00c5a21 --- /dev/null +++ b/packages/chronicle/src/pages/RenderError.tsx @@ -0,0 +1,18 @@ +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; +import { EmptyState } from '@raystack/apsara'; +import styles from './NotFound.module.css'; + +export function RenderError({ message }: { message: string | null }) { + return ( + } + heading="Failed to render page" + subHeading={ + message + ?
{message}
+ : 'Something went wrong while rendering this page.' + } + classNames={{ container: styles.emptyState }} + /> + ); +} From b902b89cc90541102ca816eaae92d2bb161576a6 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:12:17 +0530 Subject: [PATCH 5/6] feat: auto-reload dev error page when content is fixed The error page polls its own URL every second and reloads once the page compiles again, or when the error changes (e.g. the next broken file). No manual refresh or server restart needed. Co-Authored-By: Claude Fable 5 --- packages/chronicle/src/server/dev-error-page.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/server/dev-error-page.ts b/packages/chronicle/src/server/dev-error-page.ts index 937209f..29d014c 100644 --- a/packages/chronicle/src/server/dev-error-page.ts +++ b/packages/chronicle/src/server/dev-error-page.ts @@ -106,8 +106,23 @@ export async function renderMdxErrorResponse(err: unknown): Promise${escapeHtml(info.message)} ${location ? `
${escapeHtml(location)}
` : ''} ${frame ? frameHtml(frame) : ''} -

Fix the file and refresh the page.

+

Fix the file and save — the page reloads automatically.

+ ` From 2756c43f523d67182f9f84be22064d0acc057410 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 11:33:51 +0530 Subject: [PATCH 6/6] fix: pin @base-ui/react below 1.6.0 to fix fresh installs @base-ui/react@1.6.0 renamed the OTPFieldPreview export to OTPField, but @raystack/apsara (rc.7 and 1.0.1) still imports OTPFieldPreview under a ^1.4.1 range. Fresh installs (Docker CI runner stage, new dev machines) resolved 1.6.0 and failed the build with MISSING_EXPORT. A direct >=1.4.1 <1.6.0 dependency makes resolvers dedupe onto 1.5.0. Remove once apsara ships a base-ui 1.6-compatible release. Co-Authored-By: Claude Fable 5 --- bun.lock | 1 + packages/chronicle/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 1f99fe3..76c8603 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ }, "dependencies": { "@analytics/google-analytics": "^1.1.0", + "@base-ui/react": ">=1.4.1 <1.6.0", "@codemirror/lang-json": "^6.0.2", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index ed642c0..ede724e 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -51,6 +51,7 @@ "@opentelemetry/sdk-logs": "^0.218.0", "@opentelemetry/sdk-metrics": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", + "@base-ui/react": ">=1.4.1 <1.6.0", "@radix-ui/react-icons": "^1.3.2", "@raystack/apsara": "1.0.0-rc.7", "@shikijs/rehype": "^4.0.2",