@clidey/dory 1.9.0 → 1.10.0
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/README.md +57 -173
- package/bin/dist/dependency-overlay.d.ts +7 -0
- package/bin/dist/dependency-overlay.d.ts.map +1 -0
- package/bin/dist/dependency-overlay.js +137 -0
- package/bin/dist/dependency-overlay.js.map +1 -0
- package/bin/dist/dory.js +4 -2
- package/bin/dist/dory.js.map +1 -1
- package/bin/dist/preview-server.d.ts.map +1 -1
- package/bin/dist/preview-server.js +4 -17
- package/bin/dist/preview-server.js.map +1 -1
- package/bin/dist/preview-static.d.ts +7 -0
- package/bin/dist/preview-static.d.ts.map +1 -0
- package/bin/dist/preview-static.js +70 -0
- package/bin/dist/preview-static.js.map +1 -0
- package/dist/index.html +19 -19
- package/dist/runtime/dory-runtime.css +1 -1
- package/dist/runtime/dory-runtime.js +305 -305
- package/docs/components/layout.mdx +3 -0
- package/docs/components/lists.mdx +2 -2
- package/docs/components/overview.mdx +1 -1
- package/docs/components/typography.mdx +4 -4
- package/docs/configuration/navigation.mdx +3 -3
- package/docs/contributing.mdx +47 -0
- package/docs/dory.json +13 -6
- package/docs/getting-started/automation.mdx +2 -2
- package/docs/getting-started/custom-components.mdx +60 -0
- package/docs/social-card.png +0 -0
- package/docs/social-card.svg +1 -1
- package/package.json +1 -1
- package/scripts/verify-component-examples.mjs +126 -0
- package/scripts/verify-runtime-parity.mjs +5 -4
- package/src/app.css +3 -0
- package/src/main.tsx +1 -1
- package/src/mdx/latex.test.ts +38 -0
- package/src/mdx/latex.tsx +42 -65
- package/src/mdx/preprocess.ts +2 -2
- package/src/mdx/processor.test.ts +135 -37
- package/src/mdx/processor.ts +56 -2
- package/src/mdx/specimen.tsx +32 -50
- package/src/mdx/tabs.tsx +15 -12
- package/src/mdx/text.tsx +2 -2
- package/src/mdx/type-scale.tsx +3 -3
- package/src/plugins/sanitize.ts +5 -4
- package/vite.config.ssr.ts +1 -1
- package/vite.config.ts +1 -1
- package/docs/getting-started/whodb-ee-apps.mdx +0 -122
package/src/mdx/latex.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
4
2
|
|
|
5
3
|
function extractText(node: React.ReactNode): string {
|
|
6
4
|
if (typeof node === 'string') return node;
|
|
7
5
|
if (typeof node === 'number') return String(node);
|
|
8
6
|
if (!node) return '';
|
|
9
7
|
if (Array.isArray(node)) return node.map(extractText).join('');
|
|
10
|
-
if (typeof node === 'object' && 'props' in node)
|
|
8
|
+
if (typeof node === 'object' && 'props' in node) {
|
|
9
|
+
return extractText((node as { props: { children?: React.ReactNode } }).props.children);
|
|
10
|
+
}
|
|
11
11
|
return '';
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -17,90 +17,67 @@ interface LatexProps {
|
|
|
17
17
|
inline?: boolean
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
type RenderResult = {
|
|
21
|
+
expression: string
|
|
22
|
+
inline: boolean
|
|
23
|
+
html?: string
|
|
24
|
+
error?: string
|
|
25
|
+
}
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
/** Only KaTeX produces markup; TeX commands cannot opt into trusted HTML or URLs. */
|
|
28
|
+
export function renderLatexExpression(katex: Pick<typeof import('katex').default, 'renderToString'>, expression: string, inline: boolean): string {
|
|
29
|
+
return katex.renderToString(expression, {
|
|
30
|
+
displayMode: !inline,
|
|
31
|
+
throwOnError: false,
|
|
32
|
+
trust: false,
|
|
33
|
+
maxExpand: 1000,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const [
|
|
37
|
+
export function Latex({ children, inline = false }: LatexProps) {
|
|
38
|
+
const expression = extractText(children).trim()
|
|
39
|
+
const [result, setResult] = useState<RenderResult | null>(null)
|
|
40
|
+
// A changed expression immediately shows its own fallback while it loads.
|
|
41
|
+
const current = result?.expression === expression && result.inline === inline ? result : null
|
|
42
|
+
const isLoading = current === null
|
|
30
43
|
|
|
31
44
|
useEffect(() => {
|
|
32
45
|
let mounted = true
|
|
33
|
-
|
|
34
46
|
const renderLatex = async () => {
|
|
35
47
|
try {
|
|
36
|
-
setError(null)
|
|
37
|
-
setIsLoading(true)
|
|
38
|
-
|
|
39
48
|
const [katexModule] = await Promise.all([
|
|
40
49
|
import('katex'),
|
|
41
50
|
import('katex/dist/katex.min.css'),
|
|
42
51
|
])
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
katex.render(expression, containerRef.current, {
|
|
48
|
-
displayMode: !inline,
|
|
49
|
-
throwOnError: false,
|
|
50
|
-
})
|
|
51
|
-
setIsLoading(false)
|
|
52
|
-
} catch (error) {
|
|
53
|
-
if (mounted) {
|
|
54
|
-
console.error('Error rendering LaTeX:', error)
|
|
55
|
-
setError(error instanceof Error ? error.message : 'Failed to render LaTeX')
|
|
56
|
-
setIsLoading(false)
|
|
57
|
-
}
|
|
52
|
+
const html = renderLatexExpression(katexModule.default, expression, inline)
|
|
53
|
+
if (mounted) setResult({ expression, inline, html })
|
|
54
|
+
} catch {
|
|
55
|
+
if (mounted) setResult({ expression, inline, error: 'The equation could not be rendered.' })
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
|
-
|
|
61
58
|
renderLatex()
|
|
62
|
-
|
|
63
|
-
return () => {
|
|
64
|
-
mounted = false
|
|
65
|
-
}
|
|
59
|
+
return () => { mounted = false }
|
|
66
60
|
}, [expression, inline])
|
|
67
61
|
|
|
62
|
+
// Server rendering and the first browser render use the same readable fallback.
|
|
63
|
+
// Keep KaTeX lazy, and let Preact own the markup instead of mutating a ref's DOM.
|
|
68
64
|
if (inline) {
|
|
69
|
-
return <span ref={containerRef as unknown as preact.RefObject<HTMLSpanElement>} className="dory-latex-inline">{isLoading && expression}</span>
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (error) {
|
|
73
65
|
return (
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<h3 className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
|
|
80
|
-
LaTeX Rendering Error
|
|
81
|
-
</h3>
|
|
82
|
-
<p className="text-red-600 dark:text-red-400 mb-4 max-w-md">
|
|
83
|
-
The LaTeX expression could not be rendered.
|
|
84
|
-
</p>
|
|
85
|
-
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-3 max-w-md">
|
|
86
|
-
<p className="text-sm text-red-700 dark:text-red-300 font-mono break-all">
|
|
87
|
-
{error}
|
|
88
|
-
</p>
|
|
89
|
-
</div>
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
66
|
+
<span className="dory-latex-inline" aria-busy={isLoading} title={current?.error}>
|
|
67
|
+
{current?.html !== undefined
|
|
68
|
+
? <span dangerouslySetInnerHTML={{ __html: current.html }} />
|
|
69
|
+
: <span data-latex-fallback>{expression}</span>}
|
|
70
|
+
</span>
|
|
92
71
|
)
|
|
93
72
|
}
|
|
94
73
|
|
|
95
74
|
return (
|
|
96
|
-
<div className="dory-latex not-prose my-5 rounded-lg border border-[var(--border)] bg-muted/50 px-5 pb-5 pt-3">
|
|
75
|
+
<div className="dory-latex not-prose my-5 rounded-lg border border-[var(--border)] bg-muted/50 px-5 pb-5 pt-3" aria-busy={isLoading}>
|
|
97
76
|
<span className="block font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">Display</span>
|
|
98
|
-
{
|
|
99
|
-
<div className="
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)}
|
|
103
|
-
<div ref={containerRef} className={isLoading ? 'hidden' : 'mt-3 text-center'} />
|
|
77
|
+
{current?.html !== undefined
|
|
78
|
+
? <div className="mt-3 overflow-x-auto text-center" dangerouslySetInnerHTML={{ __html: current.html }} />
|
|
79
|
+
: <span data-latex-fallback className="block overflow-x-auto px-2 py-3 text-center font-mono text-sm text-muted-foreground">{expression}</span>}
|
|
80
|
+
{current?.error && <p role="alert" className="mt-2 text-center text-sm text-destructive">{current.error}</p>}
|
|
104
81
|
</div>
|
|
105
82
|
)
|
|
106
83
|
}
|
package/src/mdx/preprocess.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { preprocessMdxTags } from '../plugins/sanitize.ts';
|
|
2
2
|
|
|
3
3
|
/** Browser-safe preprocessing shared by the static build and embedded runtime. */
|
|
4
|
-
export function preprocessMdxSource(content: string, fileName = 'content.mdx'): string {
|
|
4
|
+
export function preprocessMdxSource(content: string, fileName = 'content.mdx', importedComponents: readonly string[] = []): string {
|
|
5
5
|
let processed = content;
|
|
6
6
|
const frontmatter = processed.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
7
7
|
if (frontmatter) processed = processed.slice(frontmatter[0].length);
|
|
@@ -36,7 +36,7 @@ export function preprocessMdxSource(content: string, fileName = 'content.mdx'):
|
|
|
36
36
|
const strayYaml = processed.match(/^```yaml\n([\s\S]*?)\n---\n?/);
|
|
37
37
|
if (strayYaml) processed = processed.slice(strayYaml[0].length);
|
|
38
38
|
|
|
39
|
-
const transformed = preprocessMdxTags().transform(processed, fileName);
|
|
39
|
+
const transformed = preprocessMdxTags(importedComponents).transform(processed, fileName);
|
|
40
40
|
processed = typeof transformed === 'string' ? transformed : transformed?.code ?? processed;
|
|
41
41
|
|
|
42
42
|
let inCodeBlock = false;
|
|
@@ -1,47 +1,145 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { preprocessMdxContent } from './processor';
|
|
1
|
+
import { describe, it, expect, test } from 'vitest';
|
|
2
|
+
import { preprocessMdxContent, processMdxContent } from './processor';
|
|
3
|
+
import { preprocessMdxSource } from './preprocess';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const source = `import Demo, { Button as DemoButton } from './example.tsx';
|
|
6
|
+
import * as Controls from './controls.tsx';
|
|
7
|
+
|
|
8
|
+
<Demo />
|
|
9
|
+
<DemoButton>Try it</DemoButton>
|
|
10
|
+
<Controls.Toggle />
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
test('trusted static MDX preserves default, aliased and namespace component imports', async () => {
|
|
14
|
+
const result = await processMdxContent(source);
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
expect(result.processedContent).toContain('<Demo />');
|
|
17
|
+
expect(result.processedContent).toContain('<DemoButton>Try it</DemoButton>');
|
|
18
|
+
expect(result.processedContent).toContain('<Controls.Toggle />');
|
|
19
|
+
expect(result.processedContent).not.toContain('`<Demo');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('imports shown in code fences do not authorize unknown JSX components', async () => {
|
|
23
|
+
const result = await preprocessMdxContent("```tsx\nimport Demo from './example.tsx';\n```\n\n<Demo />");
|
|
6
24
|
expect(result.success).toBe(true);
|
|
7
|
-
|
|
8
|
-
}
|
|
25
|
+
expect(result.processedContent).toContain('`<Demo />`');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('unimported components keep the existing text behavior in static MDX', async () => {
|
|
29
|
+
const result = await preprocessMdxContent("import Demo from './example.tsx';\n\n<Demo />\n<Unregistered />");
|
|
30
|
+
expect(result.processedContent).toContain('<Demo />');
|
|
31
|
+
expect(result.processedContent).toContain('`<Unregistered />`');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('embedded preprocessing does not trust component names from MDX imports', () => {
|
|
35
|
+
const result = preprocessMdxSource(source);
|
|
36
|
+
expect(result).toContain('`<Demo />`');
|
|
37
|
+
expect(result).toContain('`<Controls.Toggle />`');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('malformed import syntax reports a compile error instead of bypassing preprocessing', async () => {
|
|
41
|
+
const result = await preprocessMdxContent('import { Broken from "./example.tsx";\n\n<Broken />');
|
|
42
|
+
expect(result.success).toBe(false);
|
|
43
|
+
expect(result.error).toBeInstanceOf(Error);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
test('imported components with props coexist with nested JSX and prose placeholders', async () => {
|
|
48
|
+
const result = await processMdxContent(`import Demo, { Child } from './example.tsx';
|
|
49
|
+
|
|
50
|
+
<Demo start={0}><Child /></Demo>
|
|
51
|
+
|
|
52
|
+
Replace <token> with your key.
|
|
53
|
+
`);
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
expect(result.processedContent).toContain('<Demo start={0}><Child /></Demo>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('multiline imports work while tilde-fenced imports stay inert', async () => {
|
|
59
|
+
const result = await processMdxContent(`import {
|
|
60
|
+
Button as DemoButton,
|
|
61
|
+
} from './example.tsx';
|
|
62
|
+
|
|
63
|
+
<DemoButton>Try it</DemoButton>
|
|
9
64
|
|
|
10
|
-
|
|
65
|
+
~~~tsx
|
|
66
|
+
import Unregistered from './other.tsx';
|
|
67
|
+
~~~
|
|
68
|
+
|
|
69
|
+
<Unregistered />
|
|
70
|
+
`);
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
expect(result.processedContent).toContain('<DemoButton>Try it</DemoButton>');
|
|
73
|
+
expect(result.processedContent).toContain('`<Unregistered />`');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
describe('existing preprocessing behavior', () => {
|
|
11
78
|
it('strips YAML frontmatter', async () => {
|
|
12
|
-
const
|
|
13
|
-
expect(
|
|
14
|
-
expect(
|
|
79
|
+
const result = await preprocessMdxContent('---\ntitle: Hello\n---\n# Heading\n');
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(result.processedContent).not.toContain('title: Hello');
|
|
82
|
+
expect(result.processedContent).toContain('# Heading');
|
|
15
83
|
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
84
|
+
it('converts angle bracket URLs while preserving fenced and inline code', async () => {
|
|
85
|
+
const result = await preprocessMdxContent('Visit <https://example.com/docs> now');
|
|
86
|
+
expect(result.processedContent).toBe('Visit [https://example.com/docs](https://example.com/docs) now');
|
|
87
|
+
const fenced = '```\ncurl <https://example.com>\n```\n';
|
|
88
|
+
expect((await preprocessMdxContent(fenced)).processedContent).toBe(fenced);
|
|
89
|
+
const inline = 'Use `<https://example.com>` here';
|
|
90
|
+
expect((await preprocessMdxContent(inline)).processedContent).toBe(inline);
|
|
20
91
|
});
|
|
92
|
+
});
|
|
21
93
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
94
|
+
test('preserves original fence-state behavior across multiple code blocks', async () => {
|
|
95
|
+
const input = ['```', '<https://in-block.com>', '```', '<https://outside.com>', '```', '<https://in-block-2.com>', '```'].join('\n');
|
|
96
|
+
const result = await preprocessMdxContent(input);
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.processedContent).toContain('<https://in-block.com>');
|
|
99
|
+
expect(result.processedContent).toContain('<https://in-block-2.com>');
|
|
100
|
+
expect(result.processedContent).toContain('[https://outside.com](https://outside.com)');
|
|
101
|
+
});
|
|
26
102
|
|
|
27
|
-
it('preserves angle-bracket URLs inside inline code', async () => {
|
|
28
|
-
const input = 'run `curl <https://example.com>` locally';
|
|
29
|
-
expect(await run(input)).toBe(input);
|
|
30
|
-
});
|
|
31
103
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
104
|
+
test('comments, multiline code spans and exported strings do not register fake imports', async () => {
|
|
105
|
+
for (const prefix of [
|
|
106
|
+
"{/*\nimport Demo from './demo.tsx';\n*/}",
|
|
107
|
+
"`example\nimport Demo from './demo.tsx';\nexample`",
|
|
108
|
+
"export const example = `\nimport Demo from './demo.tsx';\n`;",
|
|
109
|
+
]) {
|
|
110
|
+
const result = await preprocessMdxContent(prefix + '\n\n<Demo />');
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
expect(result.processedContent).toContain('`<Demo />`');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('import-looking prose without an MDX ESM declaration does not register names', async () => {
|
|
117
|
+
const result = await processMdxContent("import\nDemo from './demo.tsx';\n\n<Demo />");
|
|
118
|
+
expect(result.success).toBe(true);
|
|
119
|
+
expect(result.processedContent).toContain('`<Demo />`');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
test('provisional names are AST-verified alongside real JSX with props', async () => {
|
|
124
|
+
const input = [
|
|
125
|
+
"import Real from './real.tsx';",
|
|
126
|
+
'',
|
|
127
|
+
'<Real count={0}>Try it</Real>',
|
|
128
|
+
'',
|
|
129
|
+
'{/*',
|
|
130
|
+
"import Fake from './fake.tsx';",
|
|
131
|
+
'*/}',
|
|
132
|
+
'',
|
|
133
|
+
'export const example = `',
|
|
134
|
+
"import StringFake from './fake.tsx';",
|
|
135
|
+
'`;',
|
|
136
|
+
'',
|
|
137
|
+
'<Fake />',
|
|
138
|
+
'<StringFake />',
|
|
139
|
+
].join('\n');
|
|
140
|
+
const result = await processMdxContent(input);
|
|
141
|
+
expect(result.success).toBe(true);
|
|
142
|
+
expect(result.processedContent).toContain('<Real count={0}>Try it</Real>');
|
|
143
|
+
expect(result.processedContent).toContain('`<Fake />`');
|
|
144
|
+
expect(result.processedContent).toContain('`<StringFake />`');
|
|
47
145
|
});
|
package/src/mdx/processor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compile } from '@mdx-js/mdx';
|
|
1
|
+
import { compile, createProcessor } from '@mdx-js/mdx';
|
|
2
2
|
import { preprocessMdxSource } from './preprocess.ts';
|
|
3
3
|
import { getMdxConfig } from '../config/mdx.ts';
|
|
4
4
|
|
|
@@ -25,7 +25,61 @@ export async function preprocessMdxContent(
|
|
|
25
25
|
error?: Error;
|
|
26
26
|
}> {
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
const processed = preprocessMdxSource(content, fileName);
|
|
29
|
+
// Only real top-level ESM declarations register static JSX components.
|
|
30
|
+
// The browser's embedded runtime does not call this trusted build processor.
|
|
31
|
+
const importedNames = (source: string) => {
|
|
32
|
+
const names: string[] = [];
|
|
33
|
+
let declarations = 0;
|
|
34
|
+
for (const node of createProcessor().parse(source).children) {
|
|
35
|
+
if (node.type !== 'mdxjsEsm') continue;
|
|
36
|
+
for (const statement of node.data?.estree?.body ?? []) {
|
|
37
|
+
if (statement.type !== 'ImportDeclaration') continue;
|
|
38
|
+
declarations++;
|
|
39
|
+
for (const specifier of statement.specifiers) names.push(specifier.local.name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { names, declarations };
|
|
43
|
+
};
|
|
44
|
+
let importedComponents: string[] = [];
|
|
45
|
+
if (/^import(?:\s|$)/m.test(processed)) {
|
|
46
|
+
try {
|
|
47
|
+
importedComponents = importedNames(processed).names;
|
|
48
|
+
} catch {
|
|
49
|
+
// Escaped closing tags can temporarily make imported JSX with props
|
|
50
|
+
// malformed. Gather provisional names from isolated import declarations.
|
|
51
|
+
// They are never trusted until the complete document's AST verifies them.
|
|
52
|
+
const candidates = new Set<string>();
|
|
53
|
+
const lines = processed.split('\n');
|
|
54
|
+
let fence = '';
|
|
55
|
+
for (let index = 0; index < lines.length; index++) {
|
|
56
|
+
const marker = lines[index].match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
57
|
+
if (marker) {
|
|
58
|
+
if (!fence) fence = marker[1];
|
|
59
|
+
else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = '';
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (fence || !/^import(?:\s|$)/.test(lines[index])) continue;
|
|
63
|
+
for (let end = index; end < lines.length; end++) {
|
|
64
|
+
try {
|
|
65
|
+
const result = importedNames(lines.slice(index, end + 1).join('\n'));
|
|
66
|
+
if (!result.declarations) continue;
|
|
67
|
+
for (const name of result.names) candidates.add(name);
|
|
68
|
+
index = end;
|
|
69
|
+
break;
|
|
70
|
+
} catch { /* The declaration may span more lines. */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const provisional = preprocessMdxSource(content, fileName, [...candidates]);
|
|
74
|
+
importedComponents = importedNames(provisional).names;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
processedContent: importedComponents.length
|
|
80
|
+
? preprocessMdxSource(content, fileName, importedComponents)
|
|
81
|
+
: processed,
|
|
82
|
+
};
|
|
29
83
|
} catch (error) {
|
|
30
84
|
return { success: false, error: error as Error };
|
|
31
85
|
}
|
package/src/mdx/specimen.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import classNames from 'classnames'
|
|
2
1
|
import { Children, isValidElement } from 'preact/compat'
|
|
3
|
-
import {
|
|
2
|
+
import { Tabs as UxTabs, TabsList, TabsTrigger, TabsContent, cn } from '@clidey/ux'
|
|
3
|
+
import { handleTabKeyDown } from './tabs'
|
|
4
4
|
import { CopyButton, fencePropsOf } from './code'
|
|
5
5
|
import { Fence, Pre } from './fence'
|
|
6
6
|
|
|
@@ -32,8 +32,8 @@ export function Prop({ name, type, children }: { name: string; type?: string; ch
|
|
|
32
32
|
|
|
33
33
|
type SpecimenTab = 'Preview' | 'Code' | 'Props'
|
|
34
34
|
|
|
35
|
-
function
|
|
36
|
-
return isValidElement(child) && (child as preact.VNode).type === Pre && fencePropsOf(child as preact.JSX.Element).language
|
|
35
|
+
function isSourceFence(child: preact.ComponentChild): boolean {
|
|
36
|
+
return isValidElement(child) && (child as preact.VNode).type === Pre && ['mdx', 'tsx', 'jsx'].includes(fencePropsOf(child as preact.JSX.Element).language)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function isPropsBlock(child: preact.ComponentChild): boolean {
|
|
@@ -42,60 +42,42 @@ function isPropsBlock(child: preact.ComponentChild): boolean {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Framed component example with Preview / Code / Props tabs.
|
|
45
|
-
* An
|
|
45
|
+
* An MDX, TSX, or JSX fence becomes the Code tab, a <SpecimenProps> block becomes the
|
|
46
46
|
* Props tab, and everything else is the rendered preview.
|
|
47
47
|
*/
|
|
48
|
-
export function Specimen({ label, children
|
|
48
|
+
export function Specimen({ label, children, padding = 'default' }: {
|
|
49
|
+
label?: string
|
|
50
|
+
children: preact.ComponentChildren
|
|
51
|
+
/** Use none only when the example supplies its own preview spacing. */
|
|
52
|
+
padding?: 'default' | 'none'
|
|
53
|
+
}) {
|
|
54
|
+
const previewClassName = cn('m-0 min-w-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0', padding === 'none' ? 'p-0' : 'p-5 sm:p-6')
|
|
49
55
|
const items = Children.toArray(children)
|
|
50
|
-
const code = items.filter(
|
|
56
|
+
const code = items.filter(isSourceFence)
|
|
51
57
|
const props = items.filter(isPropsBlock)
|
|
52
|
-
const preview = items.filter((child) => !
|
|
58
|
+
const preview = items.filter((child) => !isSourceFence(child) && !isPropsBlock(child))
|
|
53
59
|
|
|
54
60
|
const tabs: SpecimenTab[] = ['Preview']
|
|
55
61
|
if (code.length > 0) tabs.push('Code')
|
|
56
62
|
if (props.length > 0) tabs.push('Props')
|
|
57
63
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<div className="flex min-h-10 items-center justify-between gap-3 border-b border-[var(--border)] py-1.5 pl-4 pr-1.5">
|
|
64
|
-
<p className="truncate font-mono text-xs text-[var(--primary)]">{label}</p>
|
|
65
|
-
{tabs.length > 1 && (
|
|
66
|
-
<div role="tablist" className="flex shrink-0 rounded-md bg-muted p-0.5">
|
|
67
|
-
{tabs.map((tab) => (
|
|
68
|
-
<button
|
|
69
|
-
key={tab}
|
|
70
|
-
type="button"
|
|
71
|
-
role="tab"
|
|
72
|
-
aria-selected={active === tab}
|
|
73
|
-
onClick={() => setActive(tab)}
|
|
74
|
-
className={classNames('rounded-[5px] px-2.5 py-1 text-xs font-medium transition', {
|
|
75
|
-
'bg-card text-foreground shadow-[0_1px_2px_rgb(14_34_64/0.08)] dark:bg-[var(--border)]': active === tab,
|
|
76
|
-
'text-muted-foreground hover:text-foreground': active !== tab,
|
|
77
|
-
})}
|
|
78
|
-
>
|
|
79
|
-
{tab}
|
|
80
|
-
</button>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
<div role="tabpanel" className={classNames('[&>:first-child]:mt-0 [&>:last-child]:mb-0', { 'p-5': active === 'Preview' })}>
|
|
86
|
-
{active === 'Code'
|
|
87
|
-
? code.map((child, index) => {
|
|
88
|
-
// Bare source with a copy button, without the filename frame a standalone fence gets.
|
|
89
|
-
const fence = fencePropsOf(child as preact.JSX.Element)
|
|
90
|
-
return (
|
|
91
|
-
<div key={index} className="relative px-5 py-4 font-mono text-[12.5px] leading-[1.7]">
|
|
92
|
-
<Fence language={fence.language} meta={{ ...fence.meta, title: undefined }}>{fence.code}</Fence>
|
|
93
|
-
<CopyButton code={fence.code} />
|
|
94
|
-
</div>
|
|
95
|
-
)
|
|
96
|
-
})
|
|
97
|
-
: panels[active]}
|
|
98
|
-
</div>
|
|
64
|
+
const renderCode = () => code.map((child, index) => {
|
|
65
|
+
const fence = fencePropsOf(child as preact.JSX.Element)
|
|
66
|
+
return <div key={index} className="relative max-h-[32rem] overflow-auto px-5 py-4 font-mono text-[12.5px] leading-[1.7]">
|
|
67
|
+
<Fence language={fence.language} meta={{ ...fence.meta, title: undefined }}>{fence.code}</Fence>
|
|
68
|
+
<CopyButton code={fence.code} />
|
|
99
69
|
</div>
|
|
100
|
-
)
|
|
70
|
+
})
|
|
71
|
+
if (tabs.length === 1) return <div className="dory-specimen not-prose my-5 overflow-hidden rounded-lg border border-[var(--border)] bg-card"><div className="px-4 py-3 font-mono text-xs text-[var(--primary)]">{label}</div><div data-dory-preview data-padding={padding} className={previewClassName}>{preview}</div></div>
|
|
72
|
+
return <UxTabs defaultValue="Preview" className="dory-specimen not-prose my-5 gap-0 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
|
|
73
|
+
<div className="flex min-h-12 flex-wrap items-center justify-between gap-3 border-b border-[var(--border)] py-2 pl-4 pr-2">
|
|
74
|
+
<p className="truncate font-mono text-xs text-[var(--primary)]">{label}</p>
|
|
75
|
+
<TabsList aria-label={`${label ?? 'Component'} example views`} onKeyDown={handleTabKeyDown}>
|
|
76
|
+
{tabs.map(tab => <TabsTrigger key={tab} value={tab}>{tab}</TabsTrigger>)}
|
|
77
|
+
</TabsList>
|
|
78
|
+
</div>
|
|
79
|
+
<TabsContent value="Preview" data-dory-preview data-padding={padding} className={previewClassName}>{preview}</TabsContent>
|
|
80
|
+
{code.length > 0 && <TabsContent value="Code" className="m-0 p-0">{renderCode()}</TabsContent>}
|
|
81
|
+
{props.length > 0 && <TabsContent value="Props" className="m-0 p-0">{props}</TabsContent>}
|
|
82
|
+
</UxTabs>
|
|
101
83
|
}
|
package/src/mdx/tabs.tsx
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { Tabs as UxTabs, TabsContent, TabsList, TabsTrigger } from '@clidey/ux'
|
|
2
2
|
import { Children, isValidElement } from 'preact/compat'
|
|
3
3
|
|
|
4
|
+
/** Keyboard fallback for UX tabs rendered through Preact compatibility. */
|
|
5
|
+
export function handleTabKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
|
6
|
+
// Radix roving focus does not handle arrow keys through Preact compat.
|
|
7
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
8
|
+
const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'))
|
|
9
|
+
const current = tabs.indexOf(event.target as HTMLButtonElement)
|
|
10
|
+
if (current < 0 || tabs.length === 0) return
|
|
11
|
+
event.preventDefault()
|
|
12
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1
|
|
13
|
+
: (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
|
|
14
|
+
tabs[next].focus()
|
|
15
|
+
tabs[next].click()
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
/** One tab panel inside <Tabs>. Renders its children when active. */
|
|
5
19
|
export function Tab({ children }: { title: string; children: preact.ComponentChildren }) {
|
|
6
20
|
return <>{children}</>
|
|
@@ -14,18 +28,7 @@ export function Tabs({ children }: { children: preact.ComponentChildren }) {
|
|
|
14
28
|
<UxTabs defaultValue="0" className="dory-tabs not-prose my-5">
|
|
15
29
|
<TabsList
|
|
16
30
|
className="max-w-full justify-start overflow-x-auto"
|
|
17
|
-
onKeyDown={
|
|
18
|
-
// Radix roving focus does not handle arrow keys through Preact compat.
|
|
19
|
-
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
20
|
-
const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'))
|
|
21
|
-
const current = tabs.indexOf(event.target as HTMLButtonElement)
|
|
22
|
-
if (current < 0 || tabs.length === 0) return
|
|
23
|
-
event.preventDefault()
|
|
24
|
-
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1
|
|
25
|
-
: (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
|
|
26
|
-
tabs[next].focus()
|
|
27
|
-
tabs[next].click()
|
|
28
|
-
}}
|
|
31
|
+
onKeyDown={handleTabKeyDown}
|
|
29
32
|
>
|
|
30
33
|
{panels.map((panel, index) => (
|
|
31
34
|
<TabsTrigger
|
package/src/mdx/text.tsx
CHANGED
|
@@ -39,7 +39,7 @@ export const p = function P(props: preact.ComponentProps<'p'>) {
|
|
|
39
39
|
|
|
40
40
|
export function Row({ children, cols = 1, className }: { children: preact.ComponentChildren, cols?: number, className?: string }) {
|
|
41
41
|
return (
|
|
42
|
-
<div className={cn(`py-6 grid grid-cols-1 items-start gap-
|
|
42
|
+
<div className={cn(`dory-row py-6 grid min-w-0 grid-cols-1 items-start gap-6 sm:grid-cols-[repeat(var(--dory-columns),minmax(0,1fr))] xl:max-w-none`, className)} style={{ '--dory-columns': cols }}>
|
|
43
43
|
{children}
|
|
44
44
|
</div>
|
|
45
45
|
)
|
|
@@ -58,7 +58,7 @@ export function Col({
|
|
|
58
58
|
}) {
|
|
59
59
|
return (
|
|
60
60
|
<div
|
|
61
|
-
className={classNames("[&>:first-child]:mt-0 [&>:last-child]:mb-0", {
|
|
61
|
+
className={classNames("min-w-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0", {
|
|
62
62
|
'xl:sticky xl:top-24': sticky,
|
|
63
63
|
})}
|
|
64
64
|
style={{
|
package/src/mdx/type-scale.tsx
CHANGED
|
@@ -9,10 +9,10 @@ const scale = [
|
|
|
9
9
|
/** Shows each heading level with its size and letter-spacing, without emitting real headings. */
|
|
10
10
|
export function TypeScale() {
|
|
11
11
|
return (
|
|
12
|
-
<div className="not-prose my-2">
|
|
12
|
+
<div className="dory-type-scale not-prose my-2 min-w-0">
|
|
13
13
|
{scale.map(({ level, size, tracking, weight }) => (
|
|
14
|
-
<div key={level} className="flex items-
|
|
15
|
-
<span className="
|
|
14
|
+
<div key={level} className="flex min-w-0 flex-col items-start gap-2 border-b border-[var(--border)] py-4 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-6">
|
|
15
|
+
<span className="shrink-0 font-mono text-[11px] tracking-[0.12em] text-muted-foreground sm:w-36">
|
|
16
16
|
{level} · {size} / {tracking}
|
|
17
17
|
</span>
|
|
18
18
|
<span
|
package/src/plugins/sanitize.ts
CHANGED
|
@@ -117,7 +117,8 @@ function hasMatchingClosingTag(code: string, tagName: string, openingTagOffset:
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// Allow any <[a-z][a-z0-9]*> tag inside code blocks to be left as-is (not parsed as JSX).
|
|
120
|
-
export function preprocessMdxTags() {
|
|
120
|
+
export function preprocessMdxTags(importedComponents: readonly string[] = []) {
|
|
121
|
+
const knownComponents = new Set([...KNOWN_COMPONENTS, ...importedComponents]);
|
|
121
122
|
return {
|
|
122
123
|
name: 'preprocess-mdx-tags',
|
|
123
124
|
enforce: 'pre' as const,
|
|
@@ -137,7 +138,7 @@ export function preprocessMdxTags() {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
// If unknown component, treat as text
|
|
140
|
-
if (!
|
|
141
|
+
if (!knownComponents.has(tag)) {
|
|
141
142
|
return `\`${match}\``;
|
|
142
143
|
}
|
|
143
144
|
return match;
|
|
@@ -152,7 +153,7 @@ export function preprocessMdxTags() {
|
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// If known component, check if it has a closing tag
|
|
155
|
-
if (
|
|
156
|
+
if (knownComponents.has(tag)) {
|
|
156
157
|
// If it doesn't have a matching closing tag, treat it as text
|
|
157
158
|
if (!hasMatchingClosingTag(processed, tag, offset)) {
|
|
158
159
|
return `\`<${tag}>\``;
|
|
@@ -173,7 +174,7 @@ export function preprocessMdxTags() {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// If known component, check if it has a closing tag (only for opening tags)
|
|
176
|
-
if (
|
|
177
|
+
if (knownComponents.has(tag)) {
|
|
177
178
|
// Only check for matching closing tag for opening tags
|
|
178
179
|
if (!match.startsWith('</')) {
|
|
179
180
|
if (!hasMatchingClosingTag(processed, tag, offset)) {
|