@raystack/chronicle 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli/index.js +1763 -252
  2. package/package.json +8 -2
  3. package/src/cli/commands/build.ts +36 -7
  4. package/src/cli/commands/static-generate.ts +1071 -0
  5. package/src/cli/utils/mdx-error-report.ts +48 -0
  6. package/src/components/api/playground-dialog.tsx +65 -14
  7. package/src/components/mdx/code.tsx +8 -3
  8. package/src/components/mdx/image.tsx +15 -1
  9. package/src/components/mdx/index.tsx +10 -3
  10. package/src/components/ui/search.tsx +226 -64
  11. package/src/lib/data-urls.ts +23 -0
  12. package/src/lib/head.tsx +2 -1
  13. package/src/lib/mdx-component-names.ts +15 -0
  14. package/src/lib/mdx-error.test.ts +78 -0
  15. package/src/lib/mdx-error.ts +91 -0
  16. package/src/lib/openapi.ts +1 -1
  17. package/src/lib/page-context.tsx +29 -14
  18. package/src/lib/preload.ts +4 -2
  19. package/src/lib/remark-validate-mdx.test.ts +95 -0
  20. package/src/lib/remark-validate-mdx.ts +85 -0
  21. package/src/lib/route-resolver.ts +7 -0
  22. package/src/lib/static-mode.ts +3 -0
  23. package/src/pages/DocsPage.tsx +3 -2
  24. package/src/pages/NotFound.module.css +10 -0
  25. package/src/pages/RenderError.tsx +18 -0
  26. package/src/server/App.tsx +16 -12
  27. package/src/server/api/apis-proxy.ts +32 -7
  28. package/src/server/dev-error-page.ts +133 -0
  29. package/src/server/entry-server.tsx +6 -0
  30. package/src/server/entry-static.tsx +151 -0
  31. package/src/server/error.ts +13 -1
  32. package/src/server/plugins/telemetry.ts +3 -2
  33. package/src/server/vite-config.ts +22 -9
  34. package/src/themes/default/Page.module.css +2 -0
  35. package/src/themes/paper/Layout.module.css +0 -4
  36. package/src/themes/paper/Layout.tsx +0 -2
  37. package/src/themes/paper/Page.module.css +2 -0
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildCodeFrame, parseMdxError } from './mdx-error';
3
+
4
+ describe('parseMdxError', () => {
5
+ test('parses VFileMessage shape (parse errors, file.fail)', () => {
6
+ const info = parseMdxError({
7
+ reason: 'Unknown component <Foo> — available components: Callout',
8
+ line: 12,
9
+ column: 3,
10
+ file: '/project/.content/docs/setup.mdx',
11
+ message: 'ignored',
12
+ });
13
+ expect(info).toEqual({
14
+ message: 'Unknown component <Foo> — available components: Callout',
15
+ file: '/project/.content/docs/setup.mdx',
16
+ line: 12,
17
+ column: 3,
18
+ });
19
+ });
20
+
21
+ test('parses Rollup/Vite transform error shape', () => {
22
+ const info = parseMdxError({
23
+ message: 'Expected a closing tag for `<Callout>` (2:1-2:10)',
24
+ loc: { file: '/p/.content/docs/a.mdx', line: 2, column: 1 },
25
+ });
26
+ expect(info?.file).toBe('/p/.content/docs/a.mdx');
27
+ expect(info?.line).toBe(2);
28
+ expect(info?.column).toBe(1);
29
+ });
30
+
31
+ test('falls back to id + message-embedded position', () => {
32
+ const info = parseMdxError({
33
+ message: 'Unexpected closing tag `</div>`, expected corresponding opening tag (5:1-5:7)',
34
+ id: '/p/.content/docs/b.mdx',
35
+ });
36
+ expect(info?.file).toBe('/p/.content/docs/b.mdx');
37
+ expect(info?.line).toBe(5);
38
+ expect(info?.column).toBe(1);
39
+ });
40
+
41
+ test('strips vite query suffix from module ids', () => {
42
+ const info = parseMdxError({
43
+ message: 'boom at (1:1)',
44
+ id: '/p/.content/docs/c.mdx?collection=default',
45
+ });
46
+ expect(info?.file).toBe('/p/.content/docs/c.mdx');
47
+ });
48
+
49
+ test('detects MDX runtime missing-component errors', () => {
50
+ const info = parseMdxError(new Error('Expected component `Foo` to be defined: you likely forgot to import, pass, or provide it.'));
51
+ expect(info?.message).toContain('Unknown component <Foo>');
52
+ });
53
+
54
+ test('returns null for unrelated errors', () => {
55
+ expect(parseMdxError(new Error('connect ECONNREFUSED'))).toBeNull();
56
+ expect(parseMdxError({ message: 'fail', id: '/p/src/app.tsx' })).toBeNull();
57
+ expect(parseMdxError(null)).toBeNull();
58
+ expect(parseMdxError('string error')).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('buildCodeFrame', () => {
63
+ const source = 'one\ntwo\nthree\nfour\nfive\nsix';
64
+
65
+ test('includes context lines around the target', () => {
66
+ const frame = buildCodeFrame(source, 3, 2);
67
+ expect(frame.lines.map(l => l.number)).toEqual([1, 2, 3, 4, 5]);
68
+ expect(frame.lines.find(l => l.target)?.text).toBe('three');
69
+ expect(frame.caretColumn).toBe(2);
70
+ });
71
+
72
+ test('clamps at file boundaries', () => {
73
+ const first = buildCodeFrame(source, 1);
74
+ expect(first.lines.map(l => l.number)).toEqual([1, 2, 3]);
75
+ const last = buildCodeFrame(source, 6);
76
+ expect(last.lines.map(l => l.number)).toEqual([4, 5, 6]);
77
+ });
78
+ });
@@ -0,0 +1,91 @@
1
+ export interface MdxErrorInfo {
2
+ /** Human-readable reason, without file/position noise. */
3
+ message: string
4
+ /** Absolute path of the failing content file, when known. */
5
+ file?: string
6
+ line?: number
7
+ column?: number
8
+ }
9
+
10
+ export interface CodeFrame {
11
+ lines: Array<{ number: number; text: string; target: boolean }>
12
+ /** 1-based caret column on the target line, when known. */
13
+ caretColumn?: number
14
+ }
15
+
16
+ interface ErrorLike {
17
+ message?: string
18
+ reason?: string
19
+ file?: string
20
+ line?: number
21
+ column?: number
22
+ place?: { line?: number; column?: number; start?: { line?: number; column?: number } }
23
+ loc?: { file?: string; line?: number; column?: number }
24
+ id?: string
25
+ }
26
+
27
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color codes
28
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g
29
+
30
+ const isContentFile = (p?: string): p is string => !!p && /\.mdx?$/.test(p.split('?')[0])
31
+
32
+ function cleanMessage(raw: string): string {
33
+ const lines = raw
34
+ .replace(ANSI_PATTERN, '')
35
+ .split('\n')
36
+ .map(line =>
37
+ line
38
+ .replace(/^\[(?:plugin|vite)[^\]]*\]\s*/, '')
39
+ .replace(/^\S*\.mdx?(?::[\d:-]+)*:?\s*/, '')
40
+ .trim(),
41
+ )
42
+ .filter(line => line && !/^Build failed with \d+ errors?:?$/.test(line))
43
+ return lines[0] ?? raw.trim()
44
+ }
45
+
46
+ /**
47
+ * Extracts file/position info from the various error shapes an MDX failure
48
+ * can surface as: a VFileMessage (parse errors, remark plugin `file.fail`),
49
+ * a Rollup/Vite transform error (`loc`/`id`), or MDX's runtime
50
+ * `_missingMdxReference` throw. Returns null for errors unrelated to MDX.
51
+ */
52
+ export function parseMdxError(err: unknown): MdxErrorInfo | null {
53
+ if (!err || typeof err !== 'object') return null
54
+ const e = err as ErrorLike
55
+ const rawMessage = e.reason || e.message || ''
56
+
57
+ // MDX runtime: unknown component reached rendering (no file context available)
58
+ const missing = rawMessage.match(/Expected component `([^`]+)` to be defined/)
59
+ if (missing) {
60
+ return { message: `Unknown component <${missing[1]}> — it is not registered in Chronicle's MDX components.` }
61
+ }
62
+
63
+ const file = [e.file, e.loc?.file, e.id, ...(rawMessage.match(/((?:\/|\.\.?\/)[^\s:]+\.mdx?)/) ?? [])]
64
+ .find(isContentFile)
65
+ if (!file) return null
66
+
67
+ const place = e.place?.start ?? e.place
68
+ let line = e.line ?? place?.line ?? e.loc?.line
69
+ let column = e.column ?? place?.column ?? e.loc?.column
70
+ if (line == null) {
71
+ // Fall back to `12:3` or `12:3-14:1` position embedded in the message text
72
+ const pos = rawMessage.match(/(\d+):(\d+)(?:-\d+:\d+)?/)
73
+ if (pos) {
74
+ line = Number(pos[1])
75
+ column = Number(pos[2])
76
+ }
77
+ }
78
+
79
+ return { message: cleanMessage(rawMessage), file: file.split('?')[0], line, column }
80
+ }
81
+
82
+ export function buildCodeFrame(source: string, line: number, column?: number, context = 2): CodeFrame {
83
+ const all = source.split(/\r?\n/)
84
+ const start = Math.max(1, line - context)
85
+ const end = Math.min(all.length, line + context)
86
+ const lines = []
87
+ for (let n = start; n <= end; n++) {
88
+ lines.push({ number: n, text: all[n - 1] ?? '', target: n === line })
89
+ }
90
+ return { lines, caretColumn: column }
91
+ }
@@ -95,7 +95,7 @@ function deepResolveRefs(
95
95
  return result
96
96
  }
97
97
 
98
- function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document {
98
+ export function resolveDocument(doc: OpenAPIV3.Document): OpenAPIV3.Document {
99
99
  const root = doc as unknown as JsonObject
100
100
  return deepResolveRefs(doc, root) as unknown as OpenAPIV3.Document
101
101
  }
@@ -7,9 +7,11 @@ import {
7
7
  useRef,
8
8
  useState
9
9
  } from 'react';
10
- import { useLocation } from 'react-router';
10
+ import { useLocation, useNavigate } from 'react-router';
11
11
  import type { ApiSpec } from '@/lib/openapi';
12
- import { resolveRoute, RouteType } from '@/lib/route-resolver';
12
+ import { resolveRoute, resolveContentRootRedirect, RouteType } from '@/lib/route-resolver';
13
+ import { isStaticMode } from '@/lib/static-mode';
14
+ import { pageDataUrl, specsUrl } from '@/lib/data-urls';
13
15
  import type { VersionContext } from '@/lib/version-source';
14
16
  import { LATEST_CONTEXT } from '@/lib/version-source';
15
17
  import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
@@ -24,6 +26,8 @@ interface PageContextValue {
24
26
  page: Page | null;
25
27
  isLoading: boolean;
26
28
  errorStatus: number | null;
29
+ /** Render/load failure detail (e.g. MDX compile error) — non-status errors only. */
30
+ errorMessage: string | null;
27
31
  apiSpecs: ApiSpec[];
28
32
  version: VersionContext;
29
33
  }
@@ -43,6 +47,7 @@ export function usePageContext(): PageContextValue {
43
47
  page: null,
44
48
  isLoading: false,
45
49
  errorStatus: null,
50
+ errorMessage: null,
46
51
  apiSpecs: [],
47
52
  version: LATEST_CONTEXT,
48
53
  };
@@ -60,10 +65,6 @@ interface PageProviderProps {
60
65
  children: ReactNode;
61
66
  }
62
67
 
63
- function isApisRoute(pathname: string): boolean {
64
- return pathname === '/apis' || pathname.startsWith('/apis/');
65
- }
66
-
67
68
  function getInitialErrorStatus(page: Page | null, config: ChronicleConfig, pathname: string): number | null {
68
69
  if (page) return null;
69
70
  const route = resolveRoute(pathname, config);
@@ -83,9 +84,11 @@ export function PageProvider({
83
84
  children
84
85
  }: PageProviderProps) {
85
86
  const { pathname } = useLocation();
87
+ const navigate = useNavigate();
86
88
  const [tree] = useState<Root>(initialTree);
87
89
  const [page, setPage] = useState<Page | null>(initialPage);
88
90
  const [errorStatus, setErrorStatus] = useState<number | null>(getInitialErrorStatus(initialPage, initialConfig, pathname));
91
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
89
92
  const [apiSpecs, setApiSpecs] = useState<ApiSpec[]>(initialApiSpecs);
90
93
  const [version, setVersion] = useState<VersionContext>(initialVersion);
91
94
  const [isLoading, setIsLoading] = useState(false);
@@ -94,10 +97,8 @@ export function PageProvider({
94
97
  const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => {
95
98
  setIsLoading(true);
96
99
  try {
97
- const specsUrl = route.version.dir
98
- ? `/api/specs?version=${encodeURIComponent(route.version.dir)}`
99
- : '/api/specs';
100
- const res = await fetch(specsUrl);
100
+ const url = specsUrl(route.version.dir);
101
+ const res = await fetch(url);
101
102
  const specs = await res.json();
102
103
  if (!cancelled.current) setApiSpecs(specs);
103
104
  } catch {
@@ -118,7 +119,7 @@ export function PageProvider({
118
119
 
119
120
  const fetchPageData = useCallback(async (slug: string[]): Promise<PageData> => {
120
121
  const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
121
- const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
122
+ const apiPath = pageDataUrl(slug);
122
123
  return queryClient.fetchQuery({
123
124
  queryKey: ['pageData', key],
124
125
  queryFn: async () => {
@@ -143,6 +144,7 @@ export function PageProvider({
143
144
  const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
144
145
  if (cancelled.current) return;
145
146
  setErrorStatus(null);
147
+ setErrorMessage(null);
146
148
  setPage({
147
149
  slug,
148
150
  frontmatter: data.frontmatter,
@@ -153,9 +155,11 @@ export function PageProvider({
153
155
  });
154
156
  } catch (err) {
155
157
  if (cancelled.current) return;
156
- const status = Number((err as Error).message) || 500;
158
+ const raw = (err as Error).message;
159
+ const status = Number(raw) || 500;
157
160
  setPage(null);
158
161
  setErrorStatus(status);
162
+ setErrorMessage(Number(raw) ? null : raw);
159
163
  } finally {
160
164
  if (!cancelled.current) setIsLoading(false);
161
165
  }
@@ -173,6 +177,7 @@ export function PageProvider({
173
177
  if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) {
174
178
  setPage(null);
175
179
  setErrorStatus(null);
180
+ setErrorMessage(null);
176
181
  fetchApiSpecs(route, cancelled);
177
182
  return () => { cancelled.current = true; };
178
183
  }
@@ -180,18 +185,28 @@ export function PageProvider({
180
185
  if (route.type !== RouteType.DocsPage) {
181
186
  setPage(null);
182
187
  setErrorStatus(null);
188
+ setErrorMessage(null);
183
189
  return () => { cancelled.current = true; };
184
190
  }
185
191
 
192
+ if (isStaticMode()) {
193
+ const redirect = resolveContentRootRedirect(route.slug, initialConfig);
194
+ if (redirect) {
195
+ navigate(redirect, { replace: true });
196
+ return () => { cancelled.current = true; };
197
+ }
198
+ }
199
+
186
200
  setPage(null);
187
201
  setErrorStatus(null);
202
+ setErrorMessage(null);
188
203
  loadDocsPage(route.slug, cancelled);
189
204
  return () => { cancelled.current = true; };
190
- }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]);
205
+ }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage, navigate]);
191
206
 
192
207
  return (
193
208
  <PageContext.Provider
194
- value={{ config: initialConfig, tree, page, isLoading, errorStatus, apiSpecs, version }}
209
+ value={{ config: initialConfig, tree, page, isLoading, errorStatus, errorMessage, apiSpecs, version }}
195
210
  >
196
211
  {children}
197
212
  </PageContext.Provider>
@@ -1,4 +1,6 @@
1
1
  import { QueryClient } from '@tanstack/react-query';
2
+ import { isStaticMode } from '@/lib/static-mode';
3
+ import { pageDataUrl } from '@/lib/data-urls';
2
4
 
3
5
  export const queryClient = new QueryClient({
4
6
  defaultOptions: {
@@ -17,8 +19,7 @@ export function pageDataQueryKey(pathname: string) {
17
19
 
18
20
  async function fetchPageDataByPathname(pathname: string) {
19
21
  const slug = pathname.split('/').filter(Boolean);
20
- const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
21
- const apiPath = key ? `/api/page?slug=${key}` : '/api/page';
22
+ const apiPath = pageDataUrl(slug);
22
23
  const res = await fetch(apiPath);
23
24
  if (!res.ok) throw new Error(String(res.status));
24
25
  return res.json();
@@ -42,6 +43,7 @@ export function prefetchPageData(pathname: string) {
42
43
  }
43
44
 
44
45
  export function prefetchSearchSuggestions() {
46
+ if (isStaticMode()) return;
45
47
  queryClient.prefetchQuery({
46
48
  queryKey: ['search', '', undefined],
47
49
  queryFn: async () => {
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Root } from 'mdast';
3
+ import { VFile } from 'vfile';
4
+ import remarkValidateMdxPlugin, { type RemarkValidateMdxOptions } from './remark-validate-mdx';
5
+
6
+ const remarkValidateMdx = remarkValidateMdxPlugin as unknown as (
7
+ options?: RemarkValidateMdxOptions,
8
+ ) => (tree: Root, file: VFile) => void;
9
+
10
+ const position = {
11
+ start: { line: 3, column: 1, offset: 10 },
12
+ end: { line: 3, column: 10, offset: 19 },
13
+ };
14
+
15
+ function jsxNode(name: string | null, type = 'mdxJsxFlowElement') {
16
+ return { type, name, attributes: [], children: [], position } as unknown as Root['children'][number];
17
+ }
18
+
19
+ function importNode(localName: string) {
20
+ return {
21
+ type: 'mdxjsEsm',
22
+ value: `import ${localName} from './x'`,
23
+ data: {
24
+ estree: {
25
+ type: 'Program',
26
+ body: [
27
+ {
28
+ type: 'ImportDeclaration',
29
+ specifiers: [{ type: 'ImportDefaultSpecifier', local: { name: localName } }],
30
+ },
31
+ ],
32
+ },
33
+ },
34
+ } as unknown as Root['children'][number];
35
+ }
36
+
37
+ function run(children: Root['children']) {
38
+ const tree: Root = { type: 'root', children };
39
+ const file = new VFile({ path: '/project/.content/docs/test.mdx' });
40
+ remarkValidateMdx()(tree, file);
41
+ }
42
+
43
+ describe('remarkValidateMdx', () => {
44
+ test('fails on unknown capitalized component', () => {
45
+ expect(() => run([jsxNode('Foo')])).toThrow(/Unknown component <Foo>/);
46
+ });
47
+
48
+ test('reports position of the offending node', () => {
49
+ try {
50
+ run([jsxNode('Foo')]);
51
+ throw new Error('expected failure');
52
+ } catch (err) {
53
+ expect((err as { line?: number }).line).toBe(3);
54
+ expect((err as { column?: number }).column).toBe(1);
55
+ }
56
+ });
57
+
58
+ test('allows registered components', () => {
59
+ expect(() => run([jsxNode('Callout'), jsxNode('Mermaid')])).not.toThrow();
60
+ });
61
+
62
+ test('allows member expressions on registered components', () => {
63
+ expect(() => run([jsxNode('Tabs.Tab'), jsxNode('Tabs.Content', 'mdxJsxTextElement')])).not.toThrow();
64
+ });
65
+
66
+ test('fails on member expressions with unknown root', () => {
67
+ expect(() => run([jsxNode('Foo.Bar')])).toThrow(/Unknown component <Foo.Bar>/);
68
+ });
69
+
70
+ test('fails on unknown lowercase tag', () => {
71
+ expect(() => run([jsxNode('foox')])).toThrow(/Unknown HTML tag <foox>/);
72
+ });
73
+
74
+ test('allows standard HTML and SVG tags', () => {
75
+ expect(() => run([jsxNode('div'), jsxNode('details'), jsxNode('clipPath'), jsxNode('svg')])).not.toThrow();
76
+ });
77
+
78
+ test('allows custom elements with a dash', () => {
79
+ expect(() => run([jsxNode('my-widget')])).not.toThrow();
80
+ });
81
+
82
+ test('allows fragments', () => {
83
+ expect(() => run([jsxNode(null)])).not.toThrow();
84
+ });
85
+
86
+ test('allows components imported within the MDX file', () => {
87
+ expect(() => run([importNode('Chart'), jsxNode('Chart')])).not.toThrow();
88
+ });
89
+
90
+ test('respects custom components option', () => {
91
+ const tree: Root = { type: 'root', children: [jsxNode('Special')] };
92
+ const file = new VFile({ path: '/t.mdx' });
93
+ expect(() => remarkValidateMdx({ components: ['Special'] })(tree, file)).not.toThrow();
94
+ });
95
+ });
@@ -0,0 +1,85 @@
1
+ import type { Root } from 'mdast'
2
+ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
3
+ import type { Plugin } from 'unified'
4
+ import type { Node } from 'unist'
5
+ import { visit } from 'unist-util-visit'
6
+ import type { VFile } from 'vfile'
7
+ import { KNOWN_TAGS, MDX_COMPONENT_NAMES } from './mdx-component-names'
8
+ import { MdxNodeType } from './mdx-utils'
9
+
10
+ export interface RemarkValidateMdxOptions {
11
+ /** Custom component names allowed in MDX content. Defaults to MDX_COMPONENT_NAMES. */
12
+ components?: readonly string[]
13
+ }
14
+
15
+ interface EstreeNode {
16
+ type: string
17
+ specifiers?: Array<{ local?: { name?: string } }>
18
+ declaration?: EstreeNode | null
19
+ declarations?: Array<{ id?: { name?: string } }>
20
+ id?: { name?: string }
21
+ body?: EstreeNode[]
22
+ }
23
+
24
+ /** Names declared by import/export statements inside the MDX file itself. */
25
+ function collectLocalNames(tree: Root): Set<string> {
26
+ const names = new Set<string>()
27
+ visit(tree, 'mdxjsEsm', (node: Node) => {
28
+ const program = (node as Node & { data?: { estree?: EstreeNode } }).data?.estree
29
+ for (const stmt of program?.body ?? []) {
30
+ if (stmt.type === 'ImportDeclaration') {
31
+ for (const spec of stmt.specifiers ?? []) {
32
+ if (spec.local?.name) names.add(spec.local.name)
33
+ }
34
+ }
35
+ const decl = stmt.type === 'ExportNamedDeclaration' ? stmt.declaration : stmt
36
+ if (!decl) continue
37
+ if (decl.type === 'VariableDeclaration') {
38
+ for (const d of decl.declarations ?? []) {
39
+ if (d.id?.name) names.add(d.id.name)
40
+ }
41
+ } else if ((decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') && decl.id?.name) {
42
+ names.add(decl.id.name)
43
+ }
44
+ }
45
+ })
46
+ return names
47
+ }
48
+
49
+ /**
50
+ * Fails compilation with a precise message (file, line, column) when MDX
51
+ * content uses a component that is neither a standard HTML/SVG element,
52
+ * a registered Chronicle component, nor imported/defined in the file.
53
+ * Unclosed or mismatched tags are already rejected earlier by the MDX parser.
54
+ */
55
+ const remarkValidateMdx: Plugin<[RemarkValidateMdxOptions?], Root> = (options = {}) => {
56
+ const allowed = new Set<string>(options.components ?? MDX_COMPONENT_NAMES)
57
+
58
+ return (tree, file: VFile) => {
59
+ const locals = collectLocalNames(tree)
60
+
61
+ visit(tree, [MdxNodeType.JsxFlow, MdxNodeType.JsxText], (node) => {
62
+ const { name, position } = node as MdxJsxFlowElement | MdxJsxTextElement
63
+ if (!name) return // fragment <>...</>
64
+
65
+ const root = name.split('.')[0]
66
+ if (root.includes('-')) return // custom elements like <my-widget>
67
+ if (locals.has(root)) return
68
+
69
+ if (/^[a-z]/.test(root)) {
70
+ if (KNOWN_TAGS.has(root)) return
71
+ file.fail(
72
+ `Unknown HTML tag <${name}> — not a standard HTML/SVG element. If it is a custom component, register it in Chronicle's MDX components.`,
73
+ position,
74
+ )
75
+ } else if (!allowed.has(root)) {
76
+ file.fail(
77
+ `Unknown component <${name}> — available components: ${[...allowed].join(', ')}. Import it in this file or register it in Chronicle's MDX components.`,
78
+ position,
79
+ )
80
+ }
81
+ })
82
+ }
83
+ }
84
+
85
+ export default remarkValidateMdx
@@ -81,3 +81,10 @@ export function resolveRoute(
81
81
 
82
82
  return { type: RouteType.DocsPage, version, slug: parts }
83
83
  }
84
+
85
+ export function resolveContentRootRedirect(slug: string[], config: ChronicleConfig): string | null {
86
+ if (slug.length !== 1) return null;
87
+ const entry = config.content?.find(c => c.dir === slug[0]);
88
+ if (entry?.index_page) return `/${entry.dir}/${entry.index_page}`;
89
+ return null;
90
+ }
@@ -0,0 +1,3 @@
1
+ export function isStaticMode(): boolean {
2
+ return typeof window !== 'undefined' && (window as unknown as Record<string, unknown>).__STATIC_MODE__ === true;
3
+ }
@@ -4,6 +4,7 @@ import { Head } from '@/lib/head';
4
4
  import { usePageContext } from '@/lib/page-context';
5
5
  import { resolveDocsRedirect } from '@/lib/tree-utils';
6
6
  import { NotFound } from '@/pages/NotFound';
7
+ import { RenderError } from '@/pages/RenderError';
7
8
  import { getTheme } from '@/themes/registry';
8
9
 
9
10
  interface DocsPageProps {
@@ -11,7 +12,7 @@ interface DocsPageProps {
11
12
  }
12
13
 
13
14
  export function DocsPage({ slug }: DocsPageProps) {
14
- const { config, tree, page, isLoading, errorStatus } = usePageContext();
15
+ const { config, tree, page, isLoading, errorStatus, errorMessage } = usePageContext();
15
16
 
16
17
  if (errorStatus === StatusCodes.NOT_FOUND) {
17
18
  const contentConfig = config.content?.find(c => c.dir === slug[0]);
@@ -19,7 +20,7 @@ export function DocsPage({ slug }: DocsPageProps) {
19
20
  if (redirectUrl) return <Navigate to={redirectUrl} replace />;
20
21
  return <NotFound />;
21
22
  }
22
- if (errorStatus) return <NotFound />;
23
+ if (errorStatus) return <RenderError message={errorMessage} />;
23
24
  const { Page, Skeleton } = getTheme(config.theme?.name);
24
25
 
25
26
  if (isLoading || !page) return <Skeleton />;
@@ -1,3 +1,13 @@
1
1
  .emptyState {
2
2
  justify-content: center;
3
3
  }
4
+
5
+ .errorDetail {
6
+ max-width: 640px;
7
+ margin: 0;
8
+ white-space: pre-wrap;
9
+ text-align: left;
10
+ font-family: var(--rs-font-mono);
11
+ font-size: var(--rs-font-size-small);
12
+ color: var(--rs-color-foreground-base-secondary);
13
+ }
@@ -0,0 +1,18 @@
1
+ import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
2
+ import { EmptyState } from '@raystack/apsara';
3
+ import styles from './NotFound.module.css';
4
+
5
+ export function RenderError({ message }: { message: string | null }) {
6
+ return (
7
+ <EmptyState
8
+ icon={<ExclamationTriangleIcon width={32} height={32} />}
9
+ heading="Failed to render page"
10
+ subHeading={
11
+ message
12
+ ? <pre className={styles.errorDetail}>{message}</pre>
13
+ : 'Something went wrong while rendering this page.'
14
+ }
15
+ classNames={{ container: styles.emptyState }}
16
+ />
17
+ );
18
+ }
@@ -4,6 +4,7 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
4
4
  import { lazy, Suspense } from 'react';
5
5
  import { Navigate, useLocation } from 'react-router';
6
6
  import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
7
+ import { SearchDialog, SearchProvider } from '@/components/ui/search';
7
8
  import { usePageContext } from '@/lib/page-context';
8
9
  import { resolveRoute, RouteType } from '@/lib/route-resolver';
9
10
  import type { ChronicleConfig } from '@/types';
@@ -38,18 +39,21 @@ export function App() {
38
39
  forcedTheme={themeConfig.forcedTheme}
39
40
  >
40
41
  <AnalyticsProvider config={config.analytics ?? { enabled: false }} appName={config.site.title}>
41
- <RootHead config={config} />
42
- <Suspense fallback={<PageFallback />}>
43
- {isApi ? (
44
- <ApiLayout>
45
- <ApiPage slug={apiSlug} />
46
- </ApiLayout>
47
- ) : (
48
- <DocsLayout hideSidebar={isLanding}>
49
- {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
50
- </DocsLayout>
51
- )}
52
- </Suspense>
42
+ <SearchProvider>
43
+ <RootHead config={config} />
44
+ {config.search?.enabled && <SearchDialog />}
45
+ <Suspense fallback={<PageFallback />}>
46
+ {isApi ? (
47
+ <ApiLayout>
48
+ <ApiPage slug={apiSlug} />
49
+ </ApiLayout>
50
+ ) : (
51
+ <DocsLayout hideSidebar={isLanding}>
52
+ {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
53
+ </DocsLayout>
54
+ )}
55
+ </Suspense>
56
+ </SearchProvider>
53
57
  </AnalyticsProvider>
54
58
  </ThemeProvider>
55
59
  );