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
8 changes: 8 additions & 0 deletions bun.lock

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

6 changes: 5 additions & 1 deletion packages/chronicle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
53 changes: 31 additions & 22 deletions packages/chronicle/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
48 changes: 48 additions & 0 deletions packages/chronicle/src/cli/utils/mdx-error-report.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
2 changes: 2 additions & 0 deletions packages/chronicle/src/components/mdx/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/chronicle/src/lib/mdx-component-names.ts
Original file line number Diff line number Diff line change
@@ -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<string>([...htmlTagNames, ...svgTagNames])
78 changes: 78 additions & 0 deletions packages/chronicle/src/lib/mdx-error.test.ts
Original file line number Diff line number Diff line change
@@ -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 <Foo> — available components: Callout',
line: 12,
column: 3,
file: '/project/.content/docs/setup.mdx',
message: 'ignored',
});
expect(info).toEqual({
message: 'Unknown component <Foo> — 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 `<Callout>` (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 `</div>`, 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 <Foo>');
});

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]);
});
});
91 changes: 91 additions & 0 deletions packages/chronicle/src/lib/mdx-error.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading