@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.
Files changed (46) hide show
  1. package/README.md +57 -173
  2. package/bin/dist/dependency-overlay.d.ts +7 -0
  3. package/bin/dist/dependency-overlay.d.ts.map +1 -0
  4. package/bin/dist/dependency-overlay.js +137 -0
  5. package/bin/dist/dependency-overlay.js.map +1 -0
  6. package/bin/dist/dory.js +4 -2
  7. package/bin/dist/dory.js.map +1 -1
  8. package/bin/dist/preview-server.d.ts.map +1 -1
  9. package/bin/dist/preview-server.js +4 -17
  10. package/bin/dist/preview-server.js.map +1 -1
  11. package/bin/dist/preview-static.d.ts +7 -0
  12. package/bin/dist/preview-static.d.ts.map +1 -0
  13. package/bin/dist/preview-static.js +70 -0
  14. package/bin/dist/preview-static.js.map +1 -0
  15. package/dist/index.html +19 -19
  16. package/dist/runtime/dory-runtime.css +1 -1
  17. package/dist/runtime/dory-runtime.js +305 -305
  18. package/docs/components/layout.mdx +3 -0
  19. package/docs/components/lists.mdx +2 -2
  20. package/docs/components/overview.mdx +1 -1
  21. package/docs/components/typography.mdx +4 -4
  22. package/docs/configuration/navigation.mdx +3 -3
  23. package/docs/contributing.mdx +47 -0
  24. package/docs/dory.json +13 -6
  25. package/docs/getting-started/automation.mdx +2 -2
  26. package/docs/getting-started/custom-components.mdx +60 -0
  27. package/docs/social-card.png +0 -0
  28. package/docs/social-card.svg +1 -1
  29. package/package.json +1 -1
  30. package/scripts/verify-component-examples.mjs +126 -0
  31. package/scripts/verify-runtime-parity.mjs +5 -4
  32. package/src/app.css +3 -0
  33. package/src/main.tsx +1 -1
  34. package/src/mdx/latex.test.ts +38 -0
  35. package/src/mdx/latex.tsx +42 -65
  36. package/src/mdx/preprocess.ts +2 -2
  37. package/src/mdx/processor.test.ts +135 -37
  38. package/src/mdx/processor.ts +56 -2
  39. package/src/mdx/specimen.tsx +32 -50
  40. package/src/mdx/tabs.tsx +15 -12
  41. package/src/mdx/text.tsx +2 -2
  42. package/src/mdx/type-scale.tsx +3 -3
  43. package/src/plugins/sanitize.ts +5 -4
  44. package/vite.config.ssr.ts +1 -1
  45. package/vite.config.ts +1 -1
  46. package/docs/getting-started/whodb-ee-apps.mdx +0 -122
package/src/mdx/latex.tsx CHANGED
@@ -1,13 +1,13 @@
1
- import { CatalogIcon } from '@clidey/ux'
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) return extractText((node as any).props.children);
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
- export function Latex({ children, inline = false }: LatexProps) {
21
- const expression = extractText(children).trim();
20
+ type RenderResult = {
21
+ expression: string
22
+ inline: boolean
23
+ html?: string
24
+ error?: string
25
+ }
22
26
 
23
- if (typeof window === 'undefined') {
24
- return inline ? <code>{expression}</code> : <div className="my-4"><code>{expression}</code></div>
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
- const containerRef = useRef<HTMLDivElement>(null)
28
- const [error, setError] = useState<string | null>(null)
29
- const [isLoading, setIsLoading] = useState(true)
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 katex = katexModule.default
44
-
45
- if (!mounted || !containerRef.current) return
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
- <div className="my-4">
75
- <div className="flex flex-col items-center justify-center py-12 px-6 text-center bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 border-2 border-dashed border-red-300 dark:border-red-700 rounded-lg">
76
- <div className="w-16 h-16 mb-4 flex items-center justify-center bg-red-100 dark:bg-red-900/30 rounded-full">
77
- <CatalogIcon name="warning" className="w-8 h-8 text-red-500" />
78
- </div>
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
- {isLoading && (
99
- <div className="flex items-center justify-center py-4">
100
- <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--primary)]" />
101
- </div>
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
  }
@@ -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
- async function run(input: string): Promise<string> {
5
- const result = await preprocessMdxContent(input);
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
- return result.processedContent!;
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
- describe('preprocessMdxContent', () => {
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 output = await run('---\ntitle: Hello\n---\n# Heading\n');
13
- expect(output).not.toContain('title: Hello');
14
- expect(output).toContain('# Heading');
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
- it('converts URLs in angle brackets to markdown links', async () => {
18
- const output = await run('Visit <https://example.com/docs> now');
19
- expect(output).toBe('Visit [https://example.com/docs](https://example.com/docs) now');
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
- it('preserves angle-bracket URLs inside fenced code blocks', async () => {
23
- const input = '```\ncurl <https://example.com>\n```\n';
24
- expect(await run(input)).toBe(input);
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
- it('tracks code-block state across multiple fences', async () => {
33
- const input = [
34
- '```',
35
- '<https://in-block.com>',
36
- '```',
37
- '<https://outside.com>',
38
- '```',
39
- '<https://in-block-2.com>',
40
- '```',
41
- ].join('\n');
42
- const output = await run(input);
43
- expect(output).toContain('<https://in-block.com>');
44
- expect(output).toContain('<https://in-block-2.com>');
45
- expect(output).toContain('[https://outside.com](https://outside.com)');
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
  });
@@ -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
- return { success: true, processedContent: preprocessMdxSource(content, fileName) };
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
  }
@@ -1,6 +1,6 @@
1
- import classNames from 'classnames'
2
1
  import { Children, isValidElement } from 'preact/compat'
3
- import { useState } from 'preact/hooks'
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 isMdxSource(child: preact.ComponentChild): boolean {
36
- return isValidElement(child) && (child as preact.VNode).type === Pre && fencePropsOf(child as preact.JSX.Element).language === 'mdx'
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 ```mdx fence becomes the Code tab, a <SpecimenProps> block becomes the
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 }: { label?: string; children: preact.ComponentChildren }) {
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(isMdxSource)
56
+ const code = items.filter(isSourceFence)
51
57
  const props = items.filter(isPropsBlock)
52
- const preview = items.filter((child) => !isMdxSource(child) && !isPropsBlock(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 [active, setActive] = useState<SpecimenTab>('Preview')
59
- const panels: Record<SpecimenTab, preact.ComponentChildren[]> = { Preview: preview, Code: code, Props: props }
60
-
61
- return (
62
- <div className="dory-specimen not-prose my-5 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
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={(event) => {
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-x-16 gap-y-10 xl:max-w-none`, className)} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
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={{
@@ -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-baseline gap-6 border-b border-[var(--border)] py-4 last:border-b-0">
15
- <span className="w-36 shrink-0 font-mono text-[11px] tracking-[0.12em] text-muted-foreground">
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
@@ -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 (!KNOWN_COMPONENTS.includes(tag)) {
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 (KNOWN_COMPONENTS.includes(tag)) {
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 (KNOWN_COMPONENTS.includes(tag)) {
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)) {