@honeydeck/honeydeck 0.1.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 (144) hide show
  1. package/AGENTS.md +25 -0
  2. package/DEVELOPMENT.md +522 -0
  3. package/LICENSE +21 -0
  4. package/Readme.md +49 -0
  5. package/SPEC.md +88 -0
  6. package/docs/components.md +63 -0
  7. package/docs/configuration.md +91 -0
  8. package/docs/getting-started.md +116 -0
  9. package/docs/kit-authoring.md +207 -0
  10. package/docs/kits.md +387 -0
  11. package/docs/local-development.md +95 -0
  12. package/docs/mermaid.md +198 -0
  13. package/docs/mobile.md +108 -0
  14. package/docs/navigation.md +93 -0
  15. package/docs/next-steps.md +377 -0
  16. package/docs/pdf-export.md +91 -0
  17. package/docs/presenter-mode.md +104 -0
  18. package/docs/slides.md +130 -0
  19. package/docs/slidev-migration.md +42 -0
  20. package/docs/steps-and-reveals.md +171 -0
  21. package/package.json +134 -0
  22. package/skills/SPEC.md +21 -0
  23. package/skills/honeydeck/SKILL.md +65 -0
  24. package/skills/presentation-writing/SKILL.md +75 -0
  25. package/skills/slidev-migration/SKILL.md +153 -0
  26. package/src/SPEC.md +89 -0
  27. package/src/assets.d.ts +30 -0
  28. package/src/cli/SPEC.md +230 -0
  29. package/src/cli/args.ts +3 -0
  30. package/src/cli/banner.ts +9 -0
  31. package/src/cli/bin.js +5 -0
  32. package/src/cli/build.ts +229 -0
  33. package/src/cli/deck-path.ts +32 -0
  34. package/src/cli/dev.ts +263 -0
  35. package/src/cli/index.ts +126 -0
  36. package/src/cli/init.ts +369 -0
  37. package/src/cli/pdf.ts +923 -0
  38. package/src/cli/skill.ts +75 -0
  39. package/src/cli/templates/SPEC.md +70 -0
  40. package/src/cli/templates/deck-mdx.ts +15 -0
  41. package/src/cli/templates/package-json.ts +36 -0
  42. package/src/cli/templates/sparkle-button.ts +15 -0
  43. package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
  44. package/src/cli/templates/starter/deck.mdx +153 -0
  45. package/src/cli/templates/starter/styles.css +14 -0
  46. package/src/cli/templates/styles-css.ts +14 -0
  47. package/src/defaults.ts +1 -0
  48. package/src/layouts/ColorModeImage.tsx +55 -0
  49. package/src/layouts/SPEC.md +393 -0
  50. package/src/layouts/SlideFrame.tsx +48 -0
  51. package/src/layouts/bee/Blank.tsx +12 -0
  52. package/src/layouts/bee/Cover.tsx +70 -0
  53. package/src/layouts/bee/Default.tsx +42 -0
  54. package/src/layouts/bee/Image/Image.tsx +151 -0
  55. package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
  56. package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
  57. package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
  58. package/src/layouts/bee/Image/placeholder.webp +0 -0
  59. package/src/layouts/bee/ImageLeft.tsx +27 -0
  60. package/src/layouts/bee/ImageRight.tsx +27 -0
  61. package/src/layouts/bee/ImageSide.tsx +107 -0
  62. package/src/layouts/bee/Section.tsx +40 -0
  63. package/src/layouts/bee/TwoCol.tsx +108 -0
  64. package/src/layouts/bee/index.ts +40 -0
  65. package/src/layouts/clean/Blank.tsx +12 -0
  66. package/src/layouts/clean/Cover.tsx +58 -0
  67. package/src/layouts/clean/Default.tsx +33 -0
  68. package/src/layouts/clean/Image/Image.tsx +103 -0
  69. package/src/layouts/clean/ImageLeft.tsx +27 -0
  70. package/src/layouts/clean/ImageRight.tsx +27 -0
  71. package/src/layouts/clean/ImageSide.tsx +113 -0
  72. package/src/layouts/clean/Section.tsx +35 -0
  73. package/src/layouts/clean/TwoCol.tsx +63 -0
  74. package/src/layouts/clean/index.ts +40 -0
  75. package/src/layouts/index.ts +60 -0
  76. package/src/layouts/placeholders.ts +9 -0
  77. package/src/layouts/utils.ts +13 -0
  78. package/src/remark/SPEC.md +49 -0
  79. package/src/remark/h1-extract.ts +124 -0
  80. package/src/remark/index.ts +4 -0
  81. package/src/remark/shiki-code-blocks.ts +325 -0
  82. package/src/remark/step-numbering.ts +412 -0
  83. package/src/runtime/Deck.tsx +533 -0
  84. package/src/runtime/SPEC.md +256 -0
  85. package/src/runtime/SlideCanvas.tsx +95 -0
  86. package/src/runtime/TimelineContext.tsx +122 -0
  87. package/src/runtime/app-shell/index.html +31 -0
  88. package/src/runtime/app-shell/main.tsx +42 -0
  89. package/src/runtime/aspectRatio.ts +34 -0
  90. package/src/runtime/colorMode.ts +23 -0
  91. package/src/runtime/components/BrowserFrame.tsx +233 -0
  92. package/src/runtime/components/Button.tsx +57 -0
  93. package/src/runtime/components/CodeBlock.tsx +210 -0
  94. package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
  95. package/src/runtime/components/ErrorBoundary.tsx +125 -0
  96. package/src/runtime/components/Keyboard.tsx +87 -0
  97. package/src/runtime/components/ListStyle.tsx +203 -0
  98. package/src/runtime/components/NavBar.tsx +223 -0
  99. package/src/runtime/components/NavBarButton.tsx +47 -0
  100. package/src/runtime/components/NavBarDivider.tsx +3 -0
  101. package/src/runtime/components/Notes.tsx +171 -0
  102. package/src/runtime/components/Reveal.tsx +82 -0
  103. package/src/runtime/components/RevealGroup.tsx +193 -0
  104. package/src/runtime/components/SPEC.md +263 -0
  105. package/src/runtime/components/SlideNumberBadge.tsx +11 -0
  106. package/src/runtime/components/TimelineSteps.tsx +115 -0
  107. package/src/runtime/components/index.ts +55 -0
  108. package/src/runtime/index.ts +42 -0
  109. package/src/runtime/inputOwnership.ts +68 -0
  110. package/src/runtime/keyboardTarget.ts +7 -0
  111. package/src/runtime/lastSlideRoute.ts +56 -0
  112. package/src/runtime/navigation.ts +211 -0
  113. package/src/runtime/router.ts +157 -0
  114. package/src/runtime/slideData.ts +137 -0
  115. package/src/runtime/sync.ts +267 -0
  116. package/src/runtime/types.ts +182 -0
  117. package/src/runtime/useKeyboardNav.ts +138 -0
  118. package/src/runtime/useSwipeNav.ts +257 -0
  119. package/src/runtime/views/DocsView.tsx +74 -0
  120. package/src/runtime/views/OverviewView.tsx +386 -0
  121. package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
  122. package/src/runtime/views/PresenterView.tsx +340 -0
  123. package/src/runtime/views/SPEC.md +152 -0
  124. package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
  125. package/src/runtime/views/docs/DocsHeader.tsx +101 -0
  126. package/src/runtime/views/docs/Intro.tsx +20 -0
  127. package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
  128. package/src/runtime/views/docs/ThemeTab.tsx +110 -0
  129. package/src/runtime/views/index.ts +7 -0
  130. package/src/runtime/views/overviewGrid.ts +106 -0
  131. package/src/runtime/views/presenterPreview.ts +27 -0
  132. package/src/runtime/virtual-modules.d.ts +98 -0
  133. package/src/theme/SPEC.md +179 -0
  134. package/src/theme/base.css +623 -0
  135. package/src/theme/bee.css +35 -0
  136. package/src/theme/clean.css +38 -0
  137. package/src/vite-plugin/SPEC.md +114 -0
  138. package/src/vite-plugin/component-doc-crawler.ts +350 -0
  139. package/src/vite-plugin/deck-loader.ts +148 -0
  140. package/src/vite-plugin/index.ts +373 -0
  141. package/src/vite-plugin/layout-demo-crawler.ts +802 -0
  142. package/src/vite-plugin/splitter.ts +353 -0
  143. package/src/vite-plugin/token-manifest.ts +163 -0
  144. package/src/vite-plugin/virtual-modules.ts +587 -0
@@ -0,0 +1,114 @@
1
+ # Honeydeck Deck Loading Specification
2
+
3
+ > Observable behavior for deck entry files, slide splitting, imports, assets, Markdown features, and frontmatter.
4
+
5
+ ## Slide Authoring
6
+
7
+ ### Entry Point
8
+
9
+ The CLI deck entry point defaults to `deck.mdx` in the current working directory. Users may pass `--deck <file.mdx>` to `honeydeck dev`, `honeydeck build`, or `honeydeck pdf` to use any `.mdx` file as the deck entry point.
10
+
11
+ Honeydeck starts relative to the selected deck file: the file's directory becomes the project root for local CSS, component, layout, and asset resolution, and that file is treated as the root document that defines deck-level settings and the top-level slide order.
12
+
13
+ ### Slide Separation
14
+
15
+ Slides are separated by a line that is exactly `---`. CRLF line endings are treated the same as LF when detecting separators and frontmatter markers. A `---` line inside a fenced code block is literal code text and must not create a slide boundary. Empty blocks between separators are ignored:
16
+
17
+ ```mdx
18
+ ---
19
+ title: My Deck
20
+ ---
21
+
22
+ # First Slide
23
+
24
+ Content here.
25
+
26
+ ---
27
+
28
+ # Second Slide
29
+
30
+ More content.
31
+ ```
32
+
33
+ ### Multiple MDX Files
34
+
35
+ Additional MDX files can be imported explicitly as slide groups:
36
+
37
+ ```mdx
38
+ import DemoSlides from './slides/demo.mdx'
39
+
40
+ # Intro
41
+
42
+ ---
43
+
44
+ <DemoSlides />
45
+
46
+ ---
47
+
48
+ # Conclusion
49
+ ```
50
+
51
+ Rules:
52
+
53
+ - Default imports from relative `.mdx` files are slide-structural imports
54
+ - Rendering an imported MDX component as a standalone usage (`<DemoSlides />`) expands that file's slides at that exact location in the parent deck
55
+ - Imported `.mdx` files may contain multiple slides separated by `---`; those separators become Honeydeck slide boundaries in the parent timeline
56
+ - Frontmatter in imported `.mdx` files is slide-level only and cannot define deck-level settings
57
+ - Components imported from `.tsx`/`.ts`/`.jsx`/`.js` or packages are normal inline components
58
+ - Single-line imports at the top of the first content block are extracted as shared imports and prepended to generated slide modules
59
+
60
+ ### Assets
61
+
62
+ Static files live in the project `public/` directory and are served from the web root:
63
+
64
+ ```mdx
65
+ <img src="/cover.jpg" />
66
+ ```
67
+
68
+ React components and built-in layouts may also import image assets through Vite (`webp`, `png`, `jpg`, `jpeg`, `svg`, `gif`). The built-in `Image` layout uses this for its bundled placeholder image.
69
+
70
+ ### Markdown Features
71
+
72
+ Slide MDX supports GitHub-flavored Markdown pipe tables. Pipe tables render as real HTML tables in slides. The base theme styles slide tables with compact, full-width, token-based horizontal rules, bold headers, and light horizontal cell spacing so table Markdown is presentation-ready without custom CSS.
73
+
74
+ Slide Markdown lists render with visible markers in the base theme: unordered lists show bullet markers and ordered lists show decimal markers, even when Tailwind preflight resets browser list styles.
75
+
76
+ Honeydeck does not include Mermaid as a built-in Markdown feature. Fenced `mermaid` blocks are treated like normal code blocks unless the deck imports and renders its own user-space component.
77
+
78
+ ---
79
+
80
+ ---
81
+
82
+ ## Frontmatter
83
+
84
+ All settings use **camelCase**. No separate config file exists. Frontmatter parsing is a limited flat YAML subset: `key: value` pairs with strings, booleans, numbers, and quoted strings. Nested objects, arrays, and multiline YAML are not supported.
85
+
86
+ ### Deck-Level (deck entry file, first block only)
87
+
88
+ | Property | Type | Default | Description |
89
+ |----------|------|---------|-------------|
90
+ | `title` | `string` | `""` | Deck title stored in deck config |
91
+ | `description` | `string` | `""` | Deck description stored in deck config |
92
+ | `aspectRatio` | `"n:n"` | `"16:9"` | Slide canvas aspect ratio |
93
+ | `colorMode` | `"system" \| "light" \| "dark"` | `"system"` | Browser color mode |
94
+ | `pdfColorMode` | `"light" \| "dark"` | unset | Optional explicit PDF color mode; when unset, PDF falls back to pinned deck `colorMode`, then `light` |
95
+ | `pdfSteps` | `"final" \| "all"` | `"final"` | Whether PDF includes all steps or final state |
96
+ | `transition` | `boolean` | `true` | Enable crossfade transition between slides |
97
+ | `layouts` | `string` | built-in `@honeydeck/honeydeck/layouts` | Layout map module path |
98
+ | `defaultLayout` | `string` | `"Default"` | Layout used when slide has no `layout:` |
99
+ | `showSlideNumbers` | `boolean` | `false` | Show the current slide number in the bottom-right corner of slides |
100
+
101
+ ### Slide-Level (per-slide, after `---`)
102
+
103
+ | Property | Type | Default | Description |
104
+ |----------|------|---------|-------------|
105
+ | `layout` | `string` | (uses `defaultLayout`) | Layout map key to use (PascalCase by convention, not validated) |
106
+ | ...layout-specific props | varies | — | Any additional props the layout accepts |
107
+
108
+ ### Root frontmatter semantics
109
+
110
+ The first frontmatter block in the deck entry file is parsed as deck config. Deck-level keys are not copied into slide frontmatter. If that block also contains `layout:` plus layout-specific keys, those non-deck keys are emitted as first-slide frontmatter.
111
+
112
+ Slide-level frontmatter is a frontmatter-only block after a slide separator and applies to the following slide. Imported MDX files are normal MDX modules and cannot set deck-level properties.
113
+
114
+ Invalid `aspectRatio`, `colorMode`, and `pdfSteps` values fall back to defaults. Invalid `pdfColorMode` is ignored as unset, allowing the pinned `colorMode` fallback. `showSlideNumbers` is enabled only by literal `true`; `transition` is enabled unless literal `false`.
@@ -0,0 +1,350 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, extname, resolve } from "node:path";
3
+ import ts from "typescript";
4
+ import type { ComponentPropDoc } from "../runtime/types.ts";
5
+
6
+ const SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
7
+ const COMPONENT_DOC_EXPORT_EXCLUSIONS = new Set([
8
+ "Button",
9
+ "COLOR_MODES",
10
+ "ColorModeCycleButton",
11
+ ]);
12
+
13
+ export type DiscoveredComponentDoc = {
14
+ componentName: string;
15
+ modulePath: string;
16
+ publicModuleSpecifier: string;
17
+ markdown: string;
18
+ props: ComponentPropDoc[];
19
+ };
20
+
21
+ export type ComponentDocCrawlResult = {
22
+ barrelPath: string;
23
+ docs: DiscoveredComponentDoc[];
24
+ watchedFiles: string[];
25
+ warnings: string[];
26
+ };
27
+
28
+ export type CrawlComponentDocsOptions = {
29
+ packageRoot: string;
30
+ };
31
+
32
+ export function crawlComponentDocs({
33
+ packageRoot,
34
+ }: CrawlComponentDocsOptions): ComponentDocCrawlResult {
35
+ const barrelPath = resolve(packageRoot, "src/runtime/components/index.ts");
36
+ const watchedFiles = new Set<string>([barrelPath]);
37
+ const warnings: string[] = [];
38
+
39
+ let sourceFile: ts.SourceFile;
40
+ try {
41
+ sourceFile = parseFile(barrelPath);
42
+ } catch (error) {
43
+ warnings.push(
44
+ `Could not read component barrel "${barrelPath}": ${String(error)}`,
45
+ );
46
+ return { barrelPath, docs: [], watchedFiles: [...watchedFiles], warnings };
47
+ }
48
+
49
+ const docs: DiscoveredComponentDoc[] = [];
50
+
51
+ for (const statement of sourceFile.statements) {
52
+ if (!ts.isExportDeclaration(statement)) continue;
53
+ if (statement.isTypeOnly) continue;
54
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause))
55
+ continue;
56
+ if (
57
+ !statement.moduleSpecifier ||
58
+ !ts.isStringLiteral(statement.moduleSpecifier)
59
+ )
60
+ continue;
61
+
62
+ const moduleSpecifier = statement.moduleSpecifier.text;
63
+ const modulePath = resolveSourceFile(
64
+ resolve(dirname(barrelPath), moduleSpecifier),
65
+ );
66
+ if (!modulePath) {
67
+ warnings.push(
68
+ `Could not resolve component module "${moduleSpecifier}" from "${barrelPath}".`,
69
+ );
70
+ continue;
71
+ }
72
+
73
+ watchedFiles.add(modulePath);
74
+
75
+ for (const element of statement.exportClause.elements) {
76
+ if (element.isTypeOnly) continue;
77
+ const exportedName = element.name.text;
78
+ if (!isPublicComponentName(exportedName)) continue;
79
+
80
+ try {
81
+ const doc = extractComponentDoc({
82
+ modulePath,
83
+ componentName: exportedName,
84
+ localName: element.propertyName?.text ?? exportedName,
85
+ });
86
+ docs.push({
87
+ componentName: exportedName,
88
+ modulePath,
89
+ publicModuleSpecifier: "@honeydeck/honeydeck/components",
90
+ ...doc,
91
+ });
92
+ } catch (error) {
93
+ warnings.push(
94
+ `Could not inspect docs for component "${exportedName}": ${String(error)}`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ return {
101
+ barrelPath,
102
+ docs,
103
+ watchedFiles: [...watchedFiles],
104
+ warnings,
105
+ };
106
+ }
107
+
108
+ function isPublicComponentName(name: string): boolean {
109
+ if (COMPONENT_DOC_EXPORT_EXCLUSIONS.has(name)) return false;
110
+ return /^[A-Z]/.test(name);
111
+ }
112
+
113
+ function parseFile(path: string): ts.SourceFile {
114
+ const source = readFileSync(path, "utf-8");
115
+ const kind =
116
+ path.endsWith(".tsx") || path.endsWith(".jsx")
117
+ ? ts.ScriptKind.TSX
118
+ : ts.ScriptKind.TS;
119
+ return ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, kind);
120
+ }
121
+
122
+ function resolveSourceFile(basePath: string): string | null {
123
+ if (existsSync(basePath) && extname(basePath)) return basePath;
124
+
125
+ for (const extension of SOURCE_EXTENSIONS) {
126
+ const candidate = `${basePath}${extension}`;
127
+ if (existsSync(candidate)) return candidate;
128
+ }
129
+
130
+ if (existsSync(basePath) && !extname(basePath)) {
131
+ for (const extension of SOURCE_EXTENSIONS) {
132
+ const candidate = resolve(basePath, `index${extension}`);
133
+ if (existsSync(candidate)) return candidate;
134
+ }
135
+ }
136
+
137
+ return existsSync(basePath) ? basePath : null;
138
+ }
139
+
140
+ function extractComponentDoc({
141
+ modulePath,
142
+ componentName,
143
+ localName,
144
+ }: {
145
+ modulePath: string;
146
+ componentName: string;
147
+ localName: string;
148
+ }): { markdown: string; props: ComponentPropDoc[] } {
149
+ const sourceFile = parseFile(modulePath);
150
+ const component = findComponentDeclaration(sourceFile, localName);
151
+ if (!component)
152
+ throw new Error(`exported component "${localName}" not found`);
153
+
154
+ const markdown =
155
+ getJSDocMarkdown(component) ||
156
+ `Public docs for \`${componentName}\` have not been written yet.`;
157
+ const propTypeName =
158
+ getComponentPropTypeName(component) ?? `${componentName}Props`;
159
+ const defaults = collectParameterDefaults(component);
160
+ const props = propTypeName
161
+ ? extractProps(sourceFile, propTypeName, defaults)
162
+ : [];
163
+
164
+ return { markdown, props };
165
+ }
166
+
167
+ type ComponentDeclaration =
168
+ | ts.FunctionDeclaration
169
+ | ts.VariableDeclaration
170
+ | ts.ArrowFunction
171
+ | ts.FunctionExpression;
172
+
173
+ function findComponentDeclaration(
174
+ sourceFile: ts.SourceFile,
175
+ name: string,
176
+ ): ComponentDeclaration | null {
177
+ for (const statement of sourceFile.statements) {
178
+ if (
179
+ ts.isFunctionDeclaration(statement) &&
180
+ statement.name?.text === name &&
181
+ hasExportModifier(statement)
182
+ ) {
183
+ return statement;
184
+ }
185
+
186
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement))
187
+ continue;
188
+
189
+ for (const declaration of statement.declarationList.declarations) {
190
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
191
+ continue;
192
+ const initializer = declaration.initializer;
193
+ if (
194
+ initializer &&
195
+ (ts.isArrowFunction(initializer) ||
196
+ ts.isFunctionExpression(initializer))
197
+ ) {
198
+ return initializer;
199
+ }
200
+ return declaration;
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ function hasExportModifier(node: ts.HasModifiers): boolean {
208
+ return !!ts
209
+ .getModifiers(node)
210
+ ?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
211
+ }
212
+
213
+ function getJSDocMarkdown(node: ts.Node): string {
214
+ const docs = ts
215
+ .getJSDocCommentsAndTags(node)
216
+ .filter((doc): doc is ts.JSDoc => ts.isJSDoc(doc));
217
+ const latest = docs.at(-1);
218
+ const comment = latest?.comment;
219
+ if (typeof comment === "string") return comment.trim();
220
+ return "";
221
+ }
222
+
223
+ function getComponentPropTypeName(
224
+ component: ComponentDeclaration,
225
+ ): string | null {
226
+ const parameters =
227
+ ts.isFunctionDeclaration(component) ||
228
+ ts.isArrowFunction(component) ||
229
+ ts.isFunctionExpression(component)
230
+ ? component.parameters
231
+ : [];
232
+ const firstParam = parameters[0];
233
+ const type = firstParam?.type;
234
+ if (type && ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
235
+ return type.typeName.text;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function collectParameterDefaults(
241
+ component: ComponentDeclaration,
242
+ ): Map<string, string> {
243
+ const defaults = new Map<string, string>();
244
+ const parameters =
245
+ ts.isFunctionDeclaration(component) ||
246
+ ts.isArrowFunction(component) ||
247
+ ts.isFunctionExpression(component)
248
+ ? component.parameters
249
+ : [];
250
+ const firstParam = parameters[0];
251
+ if (!firstParam || !ts.isObjectBindingPattern(firstParam.name))
252
+ return defaults;
253
+
254
+ for (const element of firstParam.name.elements) {
255
+ if (!element.initializer) continue;
256
+ const name = bindingElementName(element);
257
+ if (!name) continue;
258
+ defaults.set(name, element.initializer.getText());
259
+ }
260
+
261
+ return defaults;
262
+ }
263
+
264
+ function bindingElementName(element: ts.BindingElement): string | null {
265
+ if (element.propertyName) return propertyNameText(element.propertyName);
266
+ return ts.isIdentifier(element.name) ? element.name.text : null;
267
+ }
268
+
269
+ function extractProps(
270
+ sourceFile: ts.SourceFile,
271
+ typeName: string,
272
+ defaults: Map<string, string>,
273
+ ): ComponentPropDoc[] {
274
+ const declaration = findTypeDeclaration(sourceFile, typeName);
275
+ if (!declaration) return [];
276
+
277
+ const members = ts.isInterfaceDeclaration(declaration)
278
+ ? [...declaration.members]
279
+ : typeAliasMembers(sourceFile, declaration);
280
+
281
+ return members.flatMap((member) => {
282
+ if (!ts.isPropertySignature(member)) return [];
283
+ const name = propertyNameText(member.name);
284
+ if (!name) return [];
285
+
286
+ const doc: ComponentPropDoc = {
287
+ name,
288
+ type: member.type?.getText(sourceFile) ?? "unknown",
289
+ required: !member.questionToken,
290
+ description: getJSDocMarkdown(member),
291
+ };
292
+ const defaultValue = defaults.get(name);
293
+ if (defaultValue !== undefined) doc.defaultValue = defaultValue;
294
+ return [doc];
295
+ });
296
+ }
297
+
298
+ function typeAliasMembers(
299
+ sourceFile: ts.SourceFile,
300
+ declaration: ts.TypeAliasDeclaration,
301
+ ): ts.TypeElement[] {
302
+ if (ts.isTypeLiteralNode(declaration.type))
303
+ return [...declaration.type.members];
304
+ if (!ts.isIntersectionTypeNode(declaration.type)) return [];
305
+
306
+ return declaration.type.types.flatMap((type) => {
307
+ if (ts.isTypeLiteralNode(type)) return [...type.members];
308
+ if (!ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName))
309
+ return [];
310
+
311
+ const referencedDeclaration = findTypeDeclaration(
312
+ sourceFile,
313
+ type.typeName.text,
314
+ );
315
+ if (!referencedDeclaration) return [];
316
+ if (ts.isInterfaceDeclaration(referencedDeclaration)) {
317
+ return [...referencedDeclaration.members];
318
+ }
319
+ return typeAliasMembers(sourceFile, referencedDeclaration);
320
+ });
321
+ }
322
+
323
+ function findTypeDeclaration(
324
+ sourceFile: ts.SourceFile,
325
+ typeName: string,
326
+ ): ts.InterfaceDeclaration | ts.TypeAliasDeclaration | null {
327
+ for (const statement of sourceFile.statements) {
328
+ if (
329
+ ts.isInterfaceDeclaration(statement) &&
330
+ statement.name.text === typeName
331
+ )
332
+ return statement;
333
+ if (
334
+ ts.isTypeAliasDeclaration(statement) &&
335
+ statement.name.text === typeName
336
+ )
337
+ return statement;
338
+ }
339
+ return null;
340
+ }
341
+
342
+ function propertyNameText(name: ts.PropertyName): string | null {
343
+ if (
344
+ ts.isIdentifier(name) ||
345
+ ts.isStringLiteral(name) ||
346
+ ts.isNumericLiteral(name)
347
+ )
348
+ return name.text;
349
+ return null;
350
+ }
@@ -0,0 +1,148 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { type SplitResult, splitSlides } from "./splitter.ts";
4
+
5
+ export type LoadedDeck = SplitResult & {
6
+ watchedFiles: string[];
7
+ };
8
+
9
+ type ExpandContext = {
10
+ watchedFiles: Set<string>;
11
+ stack: string[];
12
+ };
13
+
14
+ type MdxImport = {
15
+ localName: string;
16
+ source: string;
17
+ };
18
+
19
+ const DEFAULT_MDX_IMPORT_RE =
20
+ /^import\s+([A-Za-z_$][\w$]*)\s+from\s+(['"])([^'"]+\.mdx)\2\s*;?\s*$/;
21
+
22
+ const RELATIVE_IMPORT_RE =
23
+ /^(?<prefix>\s*import\s+(?:(?:[^'"]+?)\s+from\s+)?)(?<quote>['"])(?<specifier>\.{1,2}\/[^'"]+)(?<suffix>\k<quote>\s*;?\s*)$/;
24
+
25
+ export function loadDeck(entryPath: string): LoadedDeck {
26
+ const watchedFiles = new Set<string>();
27
+ const source = expandMdxFile(resolve(entryPath), {
28
+ watchedFiles,
29
+ stack: [],
30
+ });
31
+ const result = splitSlides(source, { trimLeadingEmptyBlocks: false });
32
+
33
+ return {
34
+ ...result,
35
+ watchedFiles: Array.from(watchedFiles),
36
+ };
37
+ }
38
+
39
+ function expandMdxFile(filePath: string, context: ExpandContext): string {
40
+ const absolutePath = resolve(filePath);
41
+
42
+ if (context.stack.includes(absolutePath)) {
43
+ throw new Error(
44
+ `Honeydeck: circular MDX import detected: ${[...context.stack, absolutePath].join(" -> ")}`,
45
+ );
46
+ }
47
+
48
+ context.watchedFiles.add(absolutePath);
49
+
50
+ const rawSource = readFileSync(absolutePath, "utf-8");
51
+ const baseDir = dirname(absolutePath);
52
+ const imports = new Map<string, MdxImport>();
53
+ const nextContext = {
54
+ ...context,
55
+ stack: [...context.stack, absolutePath],
56
+ };
57
+
58
+ const withoutMdxImports = mapMdxLines(rawSource, (line) => {
59
+ const match = line.match(DEFAULT_MDX_IMPORT_RE);
60
+ if (!match) return line;
61
+
62
+ const [, localName, , importPath] = match;
63
+ if (!localName || !importPath) return line;
64
+
65
+ const importedPath = resolve(baseDir, importPath);
66
+ const importedSource = expandMdxFile(importedPath, nextContext);
67
+ const importedSlides = splitSlides(importedSource, {
68
+ frontmatterMode: "slide",
69
+ }).slides;
70
+
71
+ imports.set(localName, {
72
+ localName,
73
+ source: importedSlides.map((slide) => slide.rawMdx).join("\n\n---\n\n"),
74
+ });
75
+
76
+ return "";
77
+ });
78
+
79
+ const withFileAnchoredImports =
80
+ context.stack.length > 0
81
+ ? rewriteRelativeImports(withoutMdxImports, baseDir)
82
+ : withoutMdxImports;
83
+
84
+ return expandMdxUsages(withFileAnchoredImports, imports);
85
+ }
86
+
87
+ function expandMdxUsages(
88
+ source: string,
89
+ imports: Map<string, MdxImport>,
90
+ ): string {
91
+ return mapMdxLines(source, (line) => {
92
+ for (const imported of imports.values()) {
93
+ if (isStandaloneMdxUsage(line, imported.localName)) {
94
+ return ["---", imported.source.trim(), "---"].join("\n");
95
+ }
96
+ }
97
+
98
+ return line;
99
+ });
100
+ }
101
+
102
+ function isStandaloneMdxUsage(line: string, localName: string): boolean {
103
+ const escapedName = escapeRegExp(localName);
104
+ return (
105
+ new RegExp(`^\\s*<${escapedName}\\s*/>\\s*$`).test(line) ||
106
+ new RegExp(`^\\s*<${escapedName}\\s*>\\s*</${escapedName}>\\s*$`).test(line)
107
+ );
108
+ }
109
+
110
+ function rewriteRelativeImports(source: string, baseDir: string): string {
111
+ return mapMdxLines(source, (line) => {
112
+ const match = line.match(RELATIVE_IMPORT_RE);
113
+ const groups = match?.groups;
114
+ if (!groups) return line;
115
+
116
+ const specifier = groups.specifier;
117
+ if (!specifier || specifier.endsWith(".mdx")) return line;
118
+
119
+ return `${groups.prefix}${groups.quote}${toFsImportSpecifier(resolve(baseDir, specifier))}${groups.suffix}`;
120
+ });
121
+ }
122
+
123
+ function mapMdxLines(
124
+ source: string,
125
+ mapLine: (line: string) => string,
126
+ ): string {
127
+ let inFence = false;
128
+
129
+ return source
130
+ .split("\n")
131
+ .map((line) => {
132
+ if (/^\s*```/.test(line)) {
133
+ inFence = !inFence;
134
+ return line;
135
+ }
136
+
137
+ return inFence ? line : mapLine(line);
138
+ })
139
+ .join("\n");
140
+ }
141
+
142
+ function toFsImportSpecifier(path: string): string {
143
+ return `/@fs/${path.replace(/\\/g, "/")}`;
144
+ }
145
+
146
+ function escapeRegExp(value: string): string {
147
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ }