diff --git a/bun.lock b/bun.lock index 6ad64e09..76c86038 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", @@ -48,6 +49,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 +68,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 +92,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "semver": "^7.7.4", "typescript": "5.9.3", + "vfile": "^6.0.3", }, }, }, @@ -773,6 +777,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 +1153,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 69d5f522..ede724ea 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", @@ -50,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", @@ -66,6 +68,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 +87,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/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index a3f42d42..da1570fc 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 00000000..0c0ece71 --- /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; +} diff --git a/packages/chronicle/src/components/mdx/index.tsx b/packages/chronicle/src/components/mdx/index.tsx index 23343339..731fc5b3 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 00000000..50ca4575 --- /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/mdx-error.test.ts b/packages/chronicle/src/lib/mdx-error.test.ts new file mode 100644 index 00000000..09e3bac0 --- /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 00000000..a9c49b89 --- /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/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 92aaff33..2ffbe431 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/lib/remark-validate-mdx.test.ts b/packages/chronicle/src/lib/remark-validate-mdx.test.ts new file mode 100644 index 00000000..ba6b8550 --- /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 00000000..d96857ab --- /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/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index fdad2357..c428a321 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 52283099..29dbeebc 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 00000000..00c5a21d --- /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 }} + /> + ); +} 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 00000000..29d014c7 --- /dev/null +++ b/packages/chronicle/src/server/dev-error-page.ts @@ -0,0 +1,133 @@ +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 save — the page reloads automatically.

+
+ + +` + + 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 e5c4c3bb..242d8099 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 9662b1ed..1131094b 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' }, diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts index 62d7f7bc..4a689a9e 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, ], }, }),