@clidey/dory 1.0.7 → 1.9.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 (198) hide show
  1. package/README.md +12 -1
  2. package/bin/chat-server.ts +270 -0
  3. package/bin/dist/analytics-server.d.ts +11 -0
  4. package/bin/dist/analytics-server.d.ts.map +1 -0
  5. package/bin/dist/analytics-server.js +121 -0
  6. package/bin/dist/analytics-server.js.map +1 -0
  7. package/bin/dist/automation.d.ts +35 -0
  8. package/bin/dist/automation.d.ts.map +1 -0
  9. package/bin/dist/automation.js +259 -0
  10. package/bin/dist/automation.js.map +1 -0
  11. package/bin/dist/chat-server.d.ts +8 -0
  12. package/bin/dist/chat-server.d.ts.map +1 -0
  13. package/bin/dist/chat-server.js +322 -0
  14. package/bin/dist/chat-server.js.map +1 -0
  15. package/bin/dist/check.d.ts +16 -0
  16. package/bin/dist/check.d.ts.map +1 -0
  17. package/bin/dist/check.js +247 -0
  18. package/bin/dist/check.js.map +1 -0
  19. package/bin/dist/dory.js +252 -402
  20. package/bin/dist/dory.js.map +1 -1
  21. package/bin/dist/i18n-init.d.ts +6 -0
  22. package/bin/dist/i18n-init.d.ts.map +1 -0
  23. package/bin/dist/i18n-init.js +56 -0
  24. package/bin/dist/i18n-init.js.map +1 -0
  25. package/bin/dist/mcp-http.d.ts +8 -0
  26. package/bin/dist/mcp-http.d.ts.map +1 -0
  27. package/bin/dist/mcp-http.js +72 -0
  28. package/bin/dist/mcp-http.js.map +1 -0
  29. package/bin/dist/mcp.d.ts +2 -0
  30. package/bin/dist/mcp.d.ts.map +1 -0
  31. package/bin/dist/mcp.js +81 -0
  32. package/bin/dist/mcp.js.map +1 -0
  33. package/bin/dist/preview-entry.d.ts +2 -0
  34. package/bin/dist/preview-entry.d.ts.map +1 -0
  35. package/bin/dist/preview-entry.js +6 -0
  36. package/bin/dist/preview-entry.js.map +1 -0
  37. package/bin/dist/preview-server.d.ts +5 -0
  38. package/bin/dist/preview-server.d.ts.map +1 -0
  39. package/bin/dist/preview-server.js +159 -0
  40. package/bin/dist/preview-server.js.map +1 -0
  41. package/bin/dist/project-config.d.ts +22 -0
  42. package/bin/dist/project-config.d.ts.map +1 -0
  43. package/bin/dist/project-config.js +44 -0
  44. package/bin/dist/project-config.js.map +1 -0
  45. package/dist/index.html +57 -0
  46. package/dist/runtime/dory-runtime.css +2 -0
  47. package/dist/runtime/dory-runtime.html +14 -0
  48. package/dist/runtime/dory-runtime.js +5490 -0
  49. package/docs/api-playground-demo.mdx +119 -0
  50. package/docs/api-reference/asyncapi.json +324 -0
  51. package/docs/api-reference/openapi.json +366 -0
  52. package/docs/api-reference/operations/create.mdx +5 -0
  53. package/docs/api-reference/operations/delete.mdx +5 -0
  54. package/docs/api-reference/operations/get.mdx +5 -0
  55. package/docs/api-reference/operations/update.mdx +5 -0
  56. package/docs/api-reference/websocket-operations/receive-chat-message.mdx +5 -0
  57. package/docs/api-reference/websocket-operations/receive-notifications.mdx +5 -0
  58. package/docs/api-reference/websocket-operations/receive-presence-update.mdx +5 -0
  59. package/docs/api-reference/websocket-operations/send-chat-message.mdx +5 -0
  60. package/docs/api-reference/websocket-operations/send-presence-update.mdx +5 -0
  61. package/docs/assets/dory.jpeg +0 -0
  62. package/docs/components/accordion.mdx +145 -0
  63. package/docs/components/callouts.mdx +134 -0
  64. package/docs/components/cards.mdx +153 -0
  65. package/docs/components/code-blocks.mdx +281 -0
  66. package/docs/components/expandable.mdx +189 -0
  67. package/docs/components/icons.mdx +71 -0
  68. package/docs/components/images.mdx +82 -0
  69. package/docs/components/latex.mdx +148 -0
  70. package/docs/components/layout.mdx +175 -0
  71. package/docs/components/lists.mdx +134 -0
  72. package/docs/components/mermaid.mdx +149 -0
  73. package/docs/components/overview.mdx +81 -0
  74. package/docs/components/source.mdx +130 -0
  75. package/docs/components/steps.mdx +122 -0
  76. package/docs/components/tables.mdx +114 -0
  77. package/docs/components/tags.mdx +179 -0
  78. package/docs/components/typography.mdx +185 -0
  79. package/docs/configuration/analytics.mdx +45 -0
  80. package/docs/configuration/chat.mdx +59 -0
  81. package/docs/configuration/dory-json.mdx +278 -0
  82. package/docs/configuration/embed.mdx +73 -0
  83. package/docs/configuration/fonts.mdx +86 -0
  84. package/docs/configuration/internationalization.mdx +61 -0
  85. package/docs/configuration/navigation.mdx +217 -0
  86. package/docs/configuration/theming.mdx +166 -0
  87. package/docs/dory.json +129 -0
  88. package/docs/embed-example.html +176 -0
  89. package/docs/favicon.svg +18 -0
  90. package/docs/features.mdx +440 -0
  91. package/docs/getting-started/automation.mdx +76 -0
  92. package/docs/getting-started/configuration.mdx +95 -0
  93. package/docs/getting-started/installation.mdx +88 -0
  94. package/docs/getting-started/quick-start.mdx +53 -0
  95. package/docs/getting-started/whodb-ee-apps.mdx +122 -0
  96. package/docs/introduction.mdx +50 -0
  97. package/docs/logo/logo.svg +18 -0
  98. package/docs/playgrounds/api-playground.mdx +122 -0
  99. package/docs/playgrounds/websocket-playground.mdx +98 -0
  100. package/docs/social-card.png +0 -0
  101. package/docs/social-card.svg +17 -0
  102. package/docs/websocket-playground-demo.mdx +135 -0
  103. package/package.json +23 -17
  104. package/scripts/verify-runtime-parity.mjs +146 -0
  105. package/src/app.css +19 -1
  106. package/src/assets/fonts/HankenGrotesk.ttf +0 -0
  107. package/src/assets/fonts/OFL.txt +94 -0
  108. package/src/components/chat-assistant.tsx +229 -0
  109. package/src/components/dropdown.tsx +3 -3
  110. package/src/components/error-boundary.tsx +2 -2
  111. package/src/components/hooks.tsx +20 -1
  112. package/src/components/navigation.tsx +82 -51
  113. package/src/components/notification.tsx +2 -3
  114. package/src/components/routes.tsx +17 -19
  115. package/src/components/search-index.test.ts +15 -0
  116. package/src/components/search-index.ts +49 -48
  117. package/src/components/search.tsx +208 -104
  118. package/src/components/store.ts +27 -9
  119. package/src/config/mdx-common.ts +11 -0
  120. package/src/config/mdx.ts +10 -11
  121. package/src/embed/loader.ts +0 -3
  122. package/src/embed/types.ts +0 -2
  123. package/src/embed/widget-iframe.ts +19 -21
  124. package/src/embedded-runtime/config.ts +138 -0
  125. package/src/embedded-runtime/index.tsx +680 -0
  126. package/src/embedded-runtime/runtime.html +14 -0
  127. package/src/entry-server.tsx +46 -124
  128. package/src/main.tsx +16 -4
  129. package/src/mdx/accordion.tsx +3 -3
  130. package/src/mdx/api-playground.tsx +18 -16
  131. package/src/mdx/async-api.tsx +2 -2
  132. package/src/mdx/card.tsx +3 -2
  133. package/src/mdx/code.tsx +11 -9
  134. package/src/mdx/fence.tsx +6 -5
  135. package/src/mdx/icon-grid.tsx +4 -1
  136. package/src/mdx/icon-names.test.ts +15 -0
  137. package/src/mdx/icon-names.ts +60 -0
  138. package/src/mdx/icon-source-variants.ts +102 -0
  139. package/src/mdx/icon.tsx +12 -73
  140. package/src/mdx/latex.tsx +3 -2
  141. package/src/mdx/mdx.tsx +1 -1
  142. package/src/mdx/mermaid.tsx +12 -6
  143. package/src/mdx/open-api.tsx +130 -74
  144. package/src/mdx/preprocess.test.ts +16 -0
  145. package/src/mdx/preprocess.ts +61 -0
  146. package/src/mdx/processor.ts +4 -96
  147. package/src/mdx/source.tsx +5 -4
  148. package/src/mdx/specimen.tsx +38 -6
  149. package/src/mdx/tabs.tsx +29 -21
  150. package/src/mdx/text.tsx +9 -9
  151. package/src/mdx/try-it-panel.tsx +284 -0
  152. package/src/mdx/type-scale.tsx +4 -4
  153. package/src/mdx/websocket-playground.tsx +21 -14
  154. package/src/plugins/analytics-injector.ts +8 -0
  155. package/src/plugins/chat-dev-server.ts +32 -0
  156. package/src/plugins/docs-assets.test.ts +25 -0
  157. package/src/plugins/docs-assets.ts +6 -2
  158. package/src/plugins/frontmatter-dev-server.ts +2 -2
  159. package/src/plugins/frontmatter-generator.ts +1 -1
  160. package/src/plugins/html-files-middleware.test.ts +21 -1
  161. package/src/plugins/html-files-middleware.ts +26 -6
  162. package/src/plugins/html-metadata-injector.ts +24 -4
  163. package/src/plugins/llm-txt-dev-server.ts +2 -2
  164. package/src/plugins/llm-txt-generator.ts +21 -4
  165. package/src/plugins/prerender.ts +47 -20
  166. package/src/plugins/robots-generator.ts +1 -1
  167. package/src/plugins/sanitize.ts +1 -1
  168. package/src/plugins/shared.ts +6 -18
  169. package/src/plugins/sitemap-generator.ts +1 -1
  170. package/src/plugins/social-preview.test.ts +71 -0
  171. package/src/plugins/social-preview.ts +87 -0
  172. package/src/plugins/unified-mdx-plugin.ts +1 -1
  173. package/src/theme.scss +630 -207
  174. package/src/types/config.ts +53 -1
  175. package/src/ui/breadcrumb.tsx +17 -0
  176. package/src/ui/docs-header-view.tsx +32 -0
  177. package/src/ui/docs-navigation-view.tsx +69 -0
  178. package/src/ui/docs-page-frame.tsx +50 -0
  179. package/src/ui/dory-mark.tsx +9 -0
  180. package/src/ui/header.tsx +33 -90
  181. package/src/ui/layout.tsx +99 -153
  182. package/src/ui/mobile-navigation.tsx +10 -8
  183. package/src/ui/page-actions.tsx +67 -0
  184. package/src/ui/page-feedback.tsx +50 -0
  185. package/src/ui/page-footer.tsx +23 -0
  186. package/src/ui/prev-next-link.tsx +15 -69
  187. package/src/ui/prev-next-view.tsx +27 -0
  188. package/src/ui/table-of-content-view.tsx +167 -0
  189. package/src/ui/table-of-content.tsx +25 -183
  190. package/src/utils/analytics.test.ts +36 -0
  191. package/src/utils/analytics.ts +35 -0
  192. package/src/utils/colors.ts +7 -0
  193. package/src/utils/content-scope.test.ts +26 -0
  194. package/src/utils/content-scope.ts +39 -0
  195. package/src/utils/search-content.ts +12 -0
  196. package/src/utils/theme.ts +1 -7
  197. package/vite.config.ssr.ts +3 -3
  198. package/vite.config.ts +21 -15
@@ -0,0 +1,61 @@
1
+ import { preprocessMdxTags } from '../plugins/sanitize.ts';
2
+
3
+ /** Browser-safe preprocessing shared by the static build and embedded runtime. */
4
+ export function preprocessMdxSource(content: string, fileName = 'content.mdx'): string {
5
+ let processed = content;
6
+ const frontmatter = processed.match(/^---\n([\s\S]*?)\n---\n?/);
7
+ if (frontmatter) processed = processed.slice(frontmatter[0].length);
8
+
9
+ // The page frame already renders the frontmatter title. Remove only its
10
+ // matching Markdown heading; headings in examples and other titles survive.
11
+ const rawTitle = frontmatter?.[1].match(/^title:\s*(.+?)\s*$/m)?.[1];
12
+ let title = rawTitle;
13
+ if (rawTitle?.startsWith('"')) {
14
+ try { title = JSON.parse(rawTitle) as string; } catch { /* Keep unfamiliar YAML unchanged. */ }
15
+ } else if (rawTitle?.startsWith("'") && rawTitle.endsWith("'")) {
16
+ title = rawTitle.slice(1, -1).replace(/''/g, "'");
17
+ }
18
+ if (title) {
19
+ let fence = '';
20
+ let removed = false;
21
+ processed = processed.split('\n').filter(line => {
22
+ const delimiter = line.trim().match(/^(`{3,}|~{3,})/);
23
+ if (delimiter) {
24
+ if (!fence) fence = delimiter[1];
25
+ else if (delimiter[1][0] === fence[0] && delimiter[1].length >= fence.length) fence = '';
26
+ return true;
27
+ }
28
+ if (fence || removed) return true;
29
+ const heading = line.match(/^#\s+(.+?)\s*#*\s*$/);
30
+ if (heading?.[1] !== title) return true;
31
+ removed = true;
32
+ return false;
33
+ }).join('\n');
34
+ }
35
+
36
+ const strayYaml = processed.match(/^```yaml\n([\s\S]*?)\n---\n?/);
37
+ if (strayYaml) processed = processed.slice(strayYaml[0].length);
38
+
39
+ const transformed = preprocessMdxTags().transform(processed, fileName);
40
+ processed = typeof transformed === 'string' ? transformed : transformed?.code ?? processed;
41
+
42
+ let inCodeBlock = false;
43
+ return processed.split('\n').map(line => {
44
+ if (line.trim().startsWith('```')) {
45
+ inCodeBlock = !inCodeBlock;
46
+ return line;
47
+ }
48
+ if (inCodeBlock) return line;
49
+
50
+ let output = '';
51
+ let lastIndex = 0;
52
+ const inlineCode = /`([^`]+)`/g;
53
+ let match: RegExpExecArray | null;
54
+ while ((match = inlineCode.exec(line)) !== null) {
55
+ output += line.slice(lastIndex, match.index).replace(/<(https?:\/\/[^\s>]+)>/g, '[$1]($1)');
56
+ output += match[0];
57
+ lastIndex = match.index + match[0].length;
58
+ }
59
+ return output + line.slice(lastIndex).replace(/<(https?:\/\/[^\s>]+)>/g, '[$1]($1)');
60
+ }).join('\n');
61
+ }
@@ -1,6 +1,6 @@
1
1
  import { compile } from '@mdx-js/mdx';
2
- import { preprocessMdxTags } from '../plugins/sanitize';
3
- import { getMdxConfig } from '../config/mdx';
2
+ import { preprocessMdxSource } from './preprocess.ts';
3
+ import { getMdxConfig } from '../config/mdx.ts';
4
4
 
5
5
  export interface ProcessMdxOptions {
6
6
  isDevelopment?: boolean;
@@ -25,101 +25,9 @@ export async function preprocessMdxContent(
25
25
  error?: Error;
26
26
  }> {
27
27
  try {
28
- // Step 0: Strip YAML frontmatter before any processing.
29
- // Frontmatter is parsed separately by the frontmatter-generator plugin.
30
- let processedContent = content;
31
- const frontMatterMatch = processedContent.match(/^---\n([\s\S]*?)\n---\n?/);
32
- if (frontMatterMatch) {
33
- processedContent = processedContent.slice(frontMatterMatch[0].length);
34
- }
35
-
36
- // Step 0b: Strip stray ```yaml metadata blocks that follow frontmatter.
37
- // Some content generators produce a second YAML block (```yaml...---) after
38
- // the frontmatter. This is a broken code fence (closed with --- instead of ```)
39
- // that leaks metadata into the rendered page.
40
- const yamlBlockMatch = processedContent.match(/^```yaml\n([\s\S]*?)\n---\n?/);
41
- if (yamlBlockMatch) {
42
- processedContent = processedContent.slice(yamlBlockMatch[0].length);
43
- }
44
-
45
- // Step 1: Apply preprocessor (handles MDX tags, code blocks, etc.)
46
- const preprocessor = preprocessMdxTags();
47
-
48
- if (preprocessor.transform) {
49
- const result = preprocessor.transform(processedContent, fileName);
50
- if (result && typeof result === 'object' && result.code) {
51
- processedContent = result.code;
52
- } else if (typeof result === 'string') {
53
- processedContent = result;
54
- }
55
- }
56
-
57
- // Step 2: Convert URLs in angle brackets to markdown links
58
- // Handles cases like <https://example.com> which MDX would interpret as JSX
59
- // But preserves them inside code blocks
60
-
61
- // Process line by line to handle code blocks correctly
62
- const lines = processedContent.split('\n');
63
- let inCodeBlock = false;
64
-
65
- processedContent = lines.map(line => {
66
- // Check if we're entering or leaving a code block (with optional indentation)
67
- if (line.trim().startsWith('```')) {
68
- inCodeBlock = !inCodeBlock;
69
- return line;
70
- }
71
-
72
- // Don't process lines inside code blocks
73
- if (inCodeBlock) {
74
- return line;
75
- }
76
-
77
- // For lines outside code blocks, preserve inline code
78
- // More robust regex that handles inline code with potential spaces
79
- // Matches: `...` including multi-line content
80
- let result = '';
81
- let lastIndex = 0;
82
-
83
- // Find all inline code spans in the line
84
- const inlineCodeRegex = /`([^`]+)`/g;
85
- let match;
86
-
87
- while ((match = inlineCodeRegex.exec(line)) !== null) {
88
- // Process text before the inline code
89
- const beforeCode = line.substring(lastIndex, match.index);
90
- result += beforeCode.replace(/<(https?:\/\/[^\s>]+)>/g, '[$1]($1)');
91
-
92
- // Keep inline code as-is
93
- result += match[0];
94
-
95
- lastIndex = match.index + match[0].length;
96
- }
97
-
98
- // Process remaining text after last inline code (or entire line if no inline code)
99
- const remainingText = line.substring(lastIndex);
100
- result += remainingText.replace(/<(https?:\/\/[^\s>]+)>/g, '[$1]($1)');
101
-
102
- return result;
103
- }).join('\n');
104
-
105
- // Step 3: Add any future preprocessing steps here
106
- // Example: processedContent = await yourNewPreprocessor(processedContent);
107
- // Example: processedContent = removeHTMLComments(processedContent);
108
- // Example: processedContent = expandCustomShortcodes(processedContent);
109
- // ALL preprocessing added here will automatically apply to:
110
- // - CLI commands (verify:content)
111
- // - Vite build (pnpm run build)
112
- // - Vite dev server (pnpm run dev)
113
-
114
- return {
115
- success: true,
116
- processedContent
117
- };
28
+ return { success: true, processedContent: preprocessMdxSource(content, fileName) };
118
29
  } catch (error) {
119
- return {
120
- success: false,
121
- error: error as Error
122
- };
30
+ return { success: false, error: error as Error };
123
31
  }
124
32
  }
125
33
 
@@ -1,5 +1,6 @@
1
+ import { CatalogIcon } from '@clidey/ux'
1
2
  import classNames from 'classnames'
2
- import { ExternalLink, File } from 'lucide-react'
3
+
3
4
  import { useEffect, useState } from 'preact/hooks'
4
5
  import { Fence } from './fence'
5
6
 
@@ -121,7 +122,7 @@ export const Source = ({ url, branch = "main", paths }: SourceProps) => {
121
122
  : 'border-[var(--border)] bg-muted text-foreground hover:border-[var(--primary)]',
122
123
  )}
123
124
  >
124
- <File aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
125
+ <CatalogIcon name="file" aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
125
126
  {path}
126
127
  {range && <span className="text-muted-foreground">{range}</span>}
127
128
  </button>
@@ -129,7 +130,7 @@ export const Source = ({ url, branch = "main", paths }: SourceProps) => {
129
130
  </div>
130
131
  <div className="dory-code mt-3 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
131
132
  <div className="flex min-h-10 items-center gap-2 border-b border-[var(--border)] bg-muted px-4">
132
- <File aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
133
+ <CatalogIcon name="file" aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
133
134
  <p className="mr-auto truncate font-mono text-xs text-foreground">
134
135
  {repo} · {current.path}
135
136
  {current.range && <span className="ml-2 text-muted-foreground">{current.range}</span>}
@@ -141,7 +142,7 @@ export const Source = ({ url, branch = "main", paths }: SourceProps) => {
141
142
  className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--primary)] hover:underline"
142
143
  >
143
144
  {openLabel}
144
- <ExternalLink aria-hidden="true" className="h-3 w-3" />
145
+ <CatalogIcon name="external" aria-hidden="true" className="h-3 w-3" />
145
146
  </a>
146
147
  </div>
147
148
  {ready ? (
@@ -1,12 +1,33 @@
1
1
  import classNames from 'classnames'
2
2
  import { Children, isValidElement } from 'preact/compat'
3
3
  import { useState } from 'preact/hooks'
4
- import { fencePropsOf } from './code'
5
- import { Pre } from './fence'
4
+ import { CopyButton, fencePropsOf } from './code'
5
+ import { Fence, Pre } from './fence'
6
6
 
7
- /** Marks the props table inside a Specimen; its children render in the Props tab. */
7
+ /** Props tab body: a PROP / DESCRIPTION header followed by <Prop> rows. */
8
8
  export function SpecimenProps({ children }: { children: preact.ComponentChildren }) {
9
- return <>{children}</>
9
+ return (
10
+ <div className="dory-props">
11
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.7fr)] gap-6 bg-muted px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
12
+ <span>Prop</span>
13
+ <span>Description</span>
14
+ </div>
15
+ {children}
16
+ </div>
17
+ )
18
+ }
19
+
20
+ /** One row of a Specimen's Props tab: mono name with its type underneath, and a description. */
21
+ export function Prop({ name, type, children }: { name: string; type?: string; children: preact.ComponentChildren }) {
22
+ return (
23
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.7fr)] gap-6 border-t border-[var(--border)] px-5 py-3.5">
24
+ <div className="min-w-0">
25
+ <p className="font-mono text-[13px] font-semibold text-foreground">{name}</p>
26
+ {type && <p className="mt-0.5 font-mono text-xs text-[var(--primary)]">{type}</p>}
27
+ </div>
28
+ <div className="text-sm leading-relaxed text-muted-foreground [&_p]:mb-0">{children}</div>
29
+ </div>
30
+ )
10
31
  }
11
32
 
12
33
  type SpecimenTab = 'Preview' | 'Code' | 'Props'
@@ -61,8 +82,19 @@ export function Specimen({ label, children }: { label?: string; children: preact
61
82
  </div>
62
83
  )}
63
84
  </div>
64
- <div role="tabpanel" className="p-5 [&>:first-child]:mt-0 [&>:last-child]:mb-0">
65
- {panels[active]}
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]}
66
98
  </div>
67
99
  </div>
68
100
  )
package/src/mdx/tabs.tsx CHANGED
@@ -1,39 +1,47 @@
1
- import classNames from 'classnames'
1
+ import { Tabs as UxTabs, TabsContent, TabsList, TabsTrigger } from '@clidey/ux'
2
2
  import { Children, isValidElement } from 'preact/compat'
3
- import { useState } from 'preact/hooks'
4
3
 
5
4
  /** One tab panel inside <Tabs>. Renders its children when active. */
6
5
  export function Tab({ children }: { title: string; children: preact.ComponentChildren }) {
7
6
  return <>{children}</>
8
7
  }
9
8
 
10
- /** Underlined tab strip for any MDX content, e.g. several diagrams of one thing. */
9
+ /** UX tab strip for any MDX content, e.g. several diagrams of one thing. */
11
10
  export function Tabs({ children }: { children: preact.ComponentChildren }) {
12
11
  const panels = Children.toArray(children).filter(isValidElement) as preact.VNode<{ title: string }>[]
13
- const [active, setActive] = useState(0)
14
12
 
15
13
  return (
16
- <div className="dory-tabs not-prose my-5">
17
- <div role="tablist" className="flex gap-5 border-b border-[var(--border)]">
14
+ <UxTabs defaultValue="0" className="dory-tabs not-prose my-5">
15
+ <TabsList
16
+ 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
+ }}
29
+ >
18
30
  {panels.map((panel, index) => (
19
- <button
31
+ <TabsTrigger
20
32
  key={index}
21
- type="button"
22
- role="tab"
23
- aria-selected={active === index}
24
- onClick={() => setActive(index)}
25
- className={classNames('-mb-px border-b-2 pb-2.5 pt-1 text-sm font-medium transition', {
26
- 'border-[var(--primary)] text-foreground': active === index,
27
- 'border-transparent text-muted-foreground hover:text-foreground': active !== index,
28
- })}
33
+ value={String(index)}
34
+ className="whitespace-nowrap"
29
35
  >
30
36
  {panel.props.title}
31
- </button>
37
+ </TabsTrigger>
32
38
  ))}
33
- </div>
34
- <div role="tabpanel" className="pt-4 [&>:first-child]:mt-0 [&>:last-child]:mb-0">
35
- {panels[active]}
36
- </div>
37
- </div>
39
+ </TabsList>
40
+ {panels.map((panel, index) => (
41
+ <TabsContent key={index} value={String(index)} className="pt-4 [&>:first-child]:mt-0 [&>:last-child]:mb-0">
42
+ {panel}
43
+ </TabsContent>
44
+ ))}
45
+ </UxTabs>
38
46
  )
39
47
  }
package/src/mdx/text.tsx CHANGED
@@ -4,36 +4,36 @@ import classNames from "classnames"
4
4
  import { useState } from "preact/hooks"
5
5
 
6
6
 
7
- export const h1 = function H1(props: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
7
+ export const h1 = function H1(props: preact.ComponentProps<'h1'>) {
8
8
  return <h1 className="dory-h1 font-heading mb-3 mt-10" {...props} />
9
9
  }
10
10
 
11
- export const h2 = function H2({ children, ...props }: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
11
+ export const h2 = function H2({ children, ...props }: preact.ComponentProps<'h2'>) {
12
12
  return (
13
- <h2 className="dory-h2 font-heading mb-2 mt-10" {...props}>
13
+ <h2 className="dory-h2 font-heading mb-4 mt-16" {...props}>
14
14
  <span>{children}</span>
15
15
  {props.id && <a href={`#${props.id}`} className="dory-anchor" aria-label="Link to this section">#</a>}
16
16
  </h2>
17
17
  )
18
18
  }
19
19
 
20
- export const h3 = function H3(props: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
21
- return <h3 className="dory-h3 font-heading mb-2 mt-7" {...props} />
20
+ export const h3 = function H3(props: preact.ComponentProps<'h3'>) {
21
+ return <h3 className="dory-h3 font-heading mb-3 mt-10" {...props} />
22
22
  }
23
23
 
24
- export const h4 = function H4(props: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
24
+ export const h4 = function H4(props: preact.ComponentProps<'h4'>) {
25
25
  return <h4 className="dory-h4 font-heading mb-1.5 mt-5" {...props} />
26
26
  }
27
27
 
28
- export const h5 = function H5(props: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
28
+ export const h5 = function H5(props: preact.ComponentProps<'h5'>) {
29
29
  return <h5 className="text-lg font-bold mb-2" {...props} />
30
30
  }
31
31
 
32
- export const h6 = function H6(props: preact.JSX.HTMLAttributes<HTMLHeadingElement>) {
32
+ export const h6 = function H6(props: preact.ComponentProps<'h6'>) {
33
33
  return <h6 className="text-base font-bold mb-2" {...props} />
34
34
  }
35
35
 
36
- export const p = function P(props: preact.JSX.HTMLAttributes<HTMLParagraphElement>) {
36
+ export const p = function P(props: preact.ComponentProps<'p'>) {
37
37
  return <p className="mb-2 inline-block" {...props} />
38
38
  }
39
39
 
@@ -0,0 +1,284 @@
1
+ import { CatalogIcon, Sheet, SheetContent, SheetTitle } from '@clidey/ux'
2
+ import classNames from 'classnames'
3
+
4
+ import { useEffect, useState } from 'preact/hooks'
5
+ import type { Server } from './api-playground'
6
+ import { Fence } from './fence'
7
+ import { Tag } from './tag'
8
+
9
+ /** An editable request value: a body property, a path/query parameter, or an auth credential. */
10
+ export interface TryItField {
11
+ name: string
12
+ /** Short type shown beside the name, e.g. `string`, `integer`, `enum`. */
13
+ type: string
14
+ value: string
15
+ /** Where a parameter goes. Body fields leave this unset. */
16
+ in?: 'path' | 'query' | 'header'
17
+ }
18
+
19
+ interface TryItPanelProps {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ method: string
23
+ path: string
24
+ servers: Server[]
25
+ parameters: TryItField[]
26
+ body: TryItField[]
27
+ /** Auth headers, e.g. `{ name: 'Authorization', value: 'Bearer ' }`. */
28
+ auth: TryItField[]
29
+ }
30
+
31
+ interface ResponseData {
32
+ status: number
33
+ statusText: string
34
+ duration: number
35
+ body: string
36
+ }
37
+
38
+ /** Turns a form string back into JSON for its declared type; unparseable values stay strings. */
39
+ function bodyValue(field: TryItField): unknown {
40
+ if (field.type === 'integer' || field.type === 'number') return Number(field.value)
41
+ if (field.type === 'boolean') return field.value === 'true'
42
+ if (field.type === 'object' || field.type.startsWith('array')) {
43
+ try {
44
+ return JSON.parse(field.value)
45
+ } catch {
46
+ return field.value
47
+ }
48
+ }
49
+ return field.value
50
+ }
51
+
52
+ function FieldInput({ field, onChange }: { field: TryItField; onChange: (value: string) => void }) {
53
+ return (
54
+ <label className="block">
55
+ <span className="flex items-baseline gap-2">
56
+ <span className="font-mono text-sm font-semibold text-foreground">{field.name}</span>
57
+ <span className="font-mono text-xs text-[var(--primary)]">{field.type}</span>
58
+ </span>
59
+ <input
60
+ type="text"
61
+ value={field.value}
62
+ onInput={(event) => onChange((event.target as HTMLInputElement).value)}
63
+ className="dory-tryit-input mt-1.5"
64
+ />
65
+ </label>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Right-hand "Try it" panel for a generated API page: server picker, a form
71
+ * built from the operation's body, parameters and auth, and the live response.
72
+ */
73
+ export function TryItPanel({ open, onOpenChange, method, path, servers, parameters, body, auth }: TryItPanelProps) {
74
+ const [serverUrl, setServerUrl] = useState(servers[0]?.url ?? '')
75
+ const [params, setParams] = useState(parameters)
76
+ const [bodyFields, setBodyFields] = useState(body)
77
+ const [authFields, setAuthFields] = useState(auth)
78
+ const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>([])
79
+ const tabs = [
80
+ bodyFields.length > 0 && 'Body',
81
+ params.length > 0 && 'Params',
82
+ 'Headers',
83
+ authFields.length > 0 && 'Auth',
84
+ ].filter((tab): tab is string => Boolean(tab))
85
+ const [tab, setTab] = useState(tabs[0])
86
+ const [loading, setLoading] = useState(false)
87
+ const [response, setResponse] = useState<ResponseData | null>(null)
88
+ const [error, setError] = useState<string | null>(null)
89
+
90
+ // A different operation (client-side navigation) starts from its own defaults.
91
+ useEffect(() => {
92
+ setParams(parameters)
93
+ setBodyFields(body)
94
+ setAuthFields(auth)
95
+ setResponse(null)
96
+ setError(null)
97
+ }, [method, path])
98
+
99
+ const update = (setter: typeof setParams, index: number, value: string) =>
100
+ setter((fields) => fields.map((field, i) => (i === index ? { ...field, value } : field)))
101
+
102
+ async function send() {
103
+ setLoading(true)
104
+ setError(null)
105
+ setResponse(null)
106
+
107
+ let url = `${serverUrl.replace(/\/$/, '')}${path}`
108
+ const query = new URLSearchParams()
109
+ const requestHeaders: Record<string, string> = {}
110
+ for (const param of params) {
111
+ if (param.in === 'path') url = url.replace(`{${param.name}}`, encodeURIComponent(param.value))
112
+ else if (param.in === 'header' && param.value) requestHeaders[param.name] = param.value
113
+ else if (param.value) query.set(param.name, param.value)
114
+ }
115
+ const queryString = query.toString()
116
+ if (queryString) url += `?${queryString}`
117
+ for (const field of authFields) if (field.value.trim()) requestHeaders[field.name] = field.value
118
+ for (const header of headers) if (header.key.trim()) requestHeaders[header.key] = header.value
119
+
120
+ const init: RequestInit = { method: method.toUpperCase(), headers: requestHeaders }
121
+ const filled = bodyFields.filter((field) => field.value !== '')
122
+ if (filled.length > 0) {
123
+ requestHeaders['Content-Type'] = 'application/json'
124
+ init.body = JSON.stringify(Object.fromEntries(filled.map((field) => [field.name, bodyValue(field)])))
125
+ }
126
+
127
+ const started = performance.now()
128
+ try {
129
+ const res = await fetch(url, init)
130
+ const text = await res.text()
131
+ let pretty = text
132
+ try {
133
+ pretty = JSON.stringify(JSON.parse(text), null, 2)
134
+ } catch {
135
+ // Not JSON; show the raw text.
136
+ }
137
+ setResponse({ status: res.status, statusText: res.statusText, duration: Math.round(performance.now() - started), body: pretty })
138
+ } catch (err) {
139
+ setError(err instanceof Error ? err.message : 'Request failed')
140
+ } finally {
141
+ setLoading(false)
142
+ }
143
+ }
144
+
145
+ return (
146
+ <Sheet open={open} onOpenChange={onOpenChange}>
147
+ <SheetContent
148
+ side="right"
149
+ className="dory-tryit w-full gap-0 bg-background sm:max-w-[34rem]"
150
+ footer={
151
+ <div className="flex gap-3">
152
+ <button type="button" className="dory-tryit-cancel" onClick={() => onOpenChange(false)}>Cancel</button>
153
+ <button type="button" className="dory-tryit-send" onClick={send} disabled={loading}>
154
+ <CatalogIcon name="play" aria-hidden="true" className="h-3 w-3 fill-current" />
155
+ {loading ? 'Sending…' : 'Send request'}
156
+ </button>
157
+ </div>
158
+ }
159
+ >
160
+ <div className="-mx-4 -mt-4 flex items-center gap-3 border-b border-[var(--border)] px-5 py-4 pr-12">
161
+ <Tag>{method}</Tag>
162
+ <SheetTitle className="truncate font-mono text-base font-medium">{path}</SheetTitle>
163
+ </div>
164
+
165
+ <div className="space-y-6 px-1 pt-5">
166
+ {servers.length > 0 && (
167
+ <label className="block">
168
+ <span className="dory-tryit-label">Server</span>
169
+ <span className="relative mt-2 block">
170
+ <select
171
+ value={serverUrl}
172
+ onChange={(event) => setServerUrl((event.target as HTMLSelectElement).value)}
173
+ className="dory-tryit-input appearance-none pr-9 font-sans"
174
+ >
175
+ {servers.map((server) => (
176
+ <option key={server.url} value={server.url}>
177
+ {server.description ? `${server.description} · ${server.url}` : server.url}
178
+ </option>
179
+ ))}
180
+ </select>
181
+ <CatalogIcon name="up-down" aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
182
+ </span>
183
+ </label>
184
+ )}
185
+
186
+ <div>
187
+ <div role="tablist" className="flex gap-5 border-b border-[var(--border)]">
188
+ {tabs.map((name) => (
189
+ <button
190
+ key={name}
191
+ type="button"
192
+ role="tab"
193
+ aria-selected={tab === name}
194
+ onClick={() => setTab(name)}
195
+ className={classNames('-mb-px border-b-2 pb-2 text-sm font-medium transition', {
196
+ 'border-[var(--primary)] text-foreground': tab === name,
197
+ 'border-transparent text-muted-foreground hover:text-foreground': tab !== name,
198
+ })}
199
+ >
200
+ {name}
201
+ </button>
202
+ ))}
203
+ </div>
204
+
205
+ <div className="space-y-4 pt-4">
206
+ {tab === 'Body' && bodyFields.map((field, index) => (
207
+ <FieldInput key={field.name} field={field} onChange={(value) => update(setBodyFields, index, value)} />
208
+ ))}
209
+ {tab === 'Params' && params.map((field, index) => (
210
+ <FieldInput key={`${field.in}-${field.name}`} field={field} onChange={(value) => update(setParams, index, value)} />
211
+ ))}
212
+ {tab === 'Auth' && authFields.map((field, index) => (
213
+ <FieldInput key={field.name} field={field} onChange={(value) => update(setAuthFields, index, value)} />
214
+ ))}
215
+ {tab === 'Headers' && (
216
+ <>
217
+ {headers.map((header, index) => (
218
+ <div key={index} className="flex gap-2">
219
+ <input
220
+ type="text"
221
+ placeholder="Header"
222
+ value={header.key}
223
+ onInput={(event) => setHeaders((list) => list.map((h, i) => (i === index ? { ...h, key: (event.target as HTMLInputElement).value } : h)))}
224
+ className="dory-tryit-input w-2/5"
225
+ />
226
+ <input
227
+ type="text"
228
+ placeholder="Value"
229
+ value={header.value}
230
+ onInput={(event) => setHeaders((list) => list.map((h, i) => (i === index ? { ...h, value: (event.target as HTMLInputElement).value } : h)))}
231
+ className="dory-tryit-input flex-1"
232
+ />
233
+ <button
234
+ type="button"
235
+ aria-label="Remove header"
236
+ onClick={() => setHeaders((list) => list.filter((_, i) => i !== index))}
237
+ className="rounded-lg px-2 text-muted-foreground hover:text-foreground"
238
+ >
239
+ <CatalogIcon name="trash" className="h-4 w-4" />
240
+ </button>
241
+ </div>
242
+ ))}
243
+ <button
244
+ type="button"
245
+ onClick={() => setHeaders((list) => [...list, { key: '', value: '' }])}
246
+ className="flex items-center gap-1.5 text-sm font-medium text-[var(--primary)]"
247
+ >
248
+ <CatalogIcon name="plus" className="h-3.5 w-3.5" />
249
+ Add header
250
+ </button>
251
+ </>
252
+ )}
253
+ </div>
254
+ </div>
255
+
256
+ <div className="pb-2">
257
+ <div className="flex items-center justify-between">
258
+ <span className="dory-tryit-label">Response</span>
259
+ {response && (
260
+ <span className="flex items-center gap-2 font-mono text-xs">
261
+ <span className={classNames('font-semibold', response.status < 300 ? 'text-green-600' : response.status < 400 ? 'text-[var(--highlight)]' : 'text-[var(--destructive)]')}>
262
+ {response.status} {response.statusText}
263
+ </span>
264
+ <span className="text-muted-foreground">{response.duration}ms</span>
265
+ </span>
266
+ )}
267
+ </div>
268
+ <div className="dory-tryit-response mt-2">
269
+ {error ? (
270
+ <p className="p-4 font-mono text-sm text-[var(--destructive)]">{error}</p>
271
+ ) : response ? (
272
+ <div className="overflow-x-auto p-4 font-mono text-[12.5px] leading-[1.7]">
273
+ <Fence language="json">{response.body}</Fence>
274
+ </div>
275
+ ) : (
276
+ <p className="flex min-h-36 items-center justify-center text-sm text-muted-foreground">Send the request to see the response</p>
277
+ )}
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </SheetContent>
282
+ </Sheet>
283
+ )
284
+ }