@raystack/chronicle 0.12.4 → 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.
- package/dist/cli/index.js +1763 -252
- package/package.json +8 -2
- package/src/cli/commands/build.ts +36 -7
- package/src/cli/commands/static-generate.ts +1071 -0
- package/src/cli/utils/mdx-error-report.ts +48 -0
- package/src/components/api/playground-dialog.tsx +65 -14
- package/src/components/mdx/code.tsx +8 -3
- package/src/components/mdx/image.tsx +15 -1
- package/src/components/mdx/index.tsx +10 -3
- package/src/components/ui/search.tsx +226 -64
- package/src/lib/data-urls.ts +23 -0
- package/src/lib/mdx-component-names.ts +15 -0
- package/src/lib/mdx-error.test.ts +78 -0
- package/src/lib/mdx-error.ts +91 -0
- package/src/lib/openapi.ts +1 -1
- package/src/lib/page-context.tsx +29 -14
- package/src/lib/preload.ts +4 -2
- package/src/lib/remark-validate-mdx.test.ts +95 -0
- package/src/lib/remark-validate-mdx.ts +85 -0
- package/src/lib/route-resolver.ts +7 -0
- package/src/lib/static-mode.ts +3 -0
- package/src/pages/DocsPage.tsx +3 -2
- package/src/pages/NotFound.module.css +10 -0
- package/src/pages/RenderError.tsx +18 -0
- package/src/server/App.tsx +16 -12
- package/src/server/api/apis-proxy.ts +32 -7
- package/src/server/dev-error-page.ts +133 -0
- package/src/server/entry-server.tsx +6 -0
- package/src/server/entry-static.tsx +151 -0
- package/src/server/error.ts +13 -1
- package/src/server/plugins/telemetry.ts +3 -2
- package/src/server/vite-config.ts +22 -9
- package/src/themes/default/Page.module.css +2 -0
- package/src/themes/paper/Layout.module.css +0 -4
- package/src/themes/paper/Layout.tsx +0 -2
- 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
|
+
}
|
package/src/lib/openapi.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -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
|
|
98
|
-
|
|
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 =
|
|
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
|
|
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>
|
package/src/lib/preload.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/pages/DocsPage.tsx
CHANGED
|
@@ -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 <
|
|
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
|
+
}
|
package/src/server/App.tsx
CHANGED
|
@@ -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
|
-
<
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
);
|