@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.
- package/AGENTS.md +25 -0
- package/DEVELOPMENT.md +522 -0
- package/LICENSE +21 -0
- package/Readme.md +49 -0
- package/SPEC.md +88 -0
- package/docs/components.md +63 -0
- package/docs/configuration.md +91 -0
- package/docs/getting-started.md +116 -0
- package/docs/kit-authoring.md +207 -0
- package/docs/kits.md +387 -0
- package/docs/local-development.md +95 -0
- package/docs/mermaid.md +198 -0
- package/docs/mobile.md +108 -0
- package/docs/navigation.md +93 -0
- package/docs/next-steps.md +377 -0
- package/docs/pdf-export.md +91 -0
- package/docs/presenter-mode.md +104 -0
- package/docs/slides.md +130 -0
- package/docs/slidev-migration.md +42 -0
- package/docs/steps-and-reveals.md +171 -0
- package/package.json +134 -0
- package/skills/SPEC.md +21 -0
- package/skills/honeydeck/SKILL.md +65 -0
- package/skills/presentation-writing/SKILL.md +75 -0
- package/skills/slidev-migration/SKILL.md +153 -0
- package/src/SPEC.md +89 -0
- package/src/assets.d.ts +30 -0
- package/src/cli/SPEC.md +230 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/banner.ts +9 -0
- package/src/cli/bin.js +5 -0
- package/src/cli/build.ts +229 -0
- package/src/cli/deck-path.ts +32 -0
- package/src/cli/dev.ts +263 -0
- package/src/cli/index.ts +126 -0
- package/src/cli/init.ts +369 -0
- package/src/cli/pdf.ts +923 -0
- package/src/cli/skill.ts +75 -0
- package/src/cli/templates/SPEC.md +70 -0
- package/src/cli/templates/deck-mdx.ts +15 -0
- package/src/cli/templates/package-json.ts +36 -0
- package/src/cli/templates/sparkle-button.ts +15 -0
- package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
- package/src/cli/templates/starter/deck.mdx +153 -0
- package/src/cli/templates/starter/styles.css +14 -0
- package/src/cli/templates/styles-css.ts +14 -0
- package/src/defaults.ts +1 -0
- package/src/layouts/ColorModeImage.tsx +55 -0
- package/src/layouts/SPEC.md +393 -0
- package/src/layouts/SlideFrame.tsx +48 -0
- package/src/layouts/bee/Blank.tsx +12 -0
- package/src/layouts/bee/Cover.tsx +70 -0
- package/src/layouts/bee/Default.tsx +42 -0
- package/src/layouts/bee/Image/Image.tsx +151 -0
- package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
- package/src/layouts/bee/Image/placeholder.webp +0 -0
- package/src/layouts/bee/ImageLeft.tsx +27 -0
- package/src/layouts/bee/ImageRight.tsx +27 -0
- package/src/layouts/bee/ImageSide.tsx +107 -0
- package/src/layouts/bee/Section.tsx +40 -0
- package/src/layouts/bee/TwoCol.tsx +108 -0
- package/src/layouts/bee/index.ts +40 -0
- package/src/layouts/clean/Blank.tsx +12 -0
- package/src/layouts/clean/Cover.tsx +58 -0
- package/src/layouts/clean/Default.tsx +33 -0
- package/src/layouts/clean/Image/Image.tsx +103 -0
- package/src/layouts/clean/ImageLeft.tsx +27 -0
- package/src/layouts/clean/ImageRight.tsx +27 -0
- package/src/layouts/clean/ImageSide.tsx +113 -0
- package/src/layouts/clean/Section.tsx +35 -0
- package/src/layouts/clean/TwoCol.tsx +63 -0
- package/src/layouts/clean/index.ts +40 -0
- package/src/layouts/index.ts +60 -0
- package/src/layouts/placeholders.ts +9 -0
- package/src/layouts/utils.ts +13 -0
- package/src/remark/SPEC.md +49 -0
- package/src/remark/h1-extract.ts +124 -0
- package/src/remark/index.ts +4 -0
- package/src/remark/shiki-code-blocks.ts +325 -0
- package/src/remark/step-numbering.ts +412 -0
- package/src/runtime/Deck.tsx +533 -0
- package/src/runtime/SPEC.md +256 -0
- package/src/runtime/SlideCanvas.tsx +95 -0
- package/src/runtime/TimelineContext.tsx +122 -0
- package/src/runtime/app-shell/index.html +31 -0
- package/src/runtime/app-shell/main.tsx +42 -0
- package/src/runtime/aspectRatio.ts +34 -0
- package/src/runtime/colorMode.ts +23 -0
- package/src/runtime/components/BrowserFrame.tsx +233 -0
- package/src/runtime/components/Button.tsx +57 -0
- package/src/runtime/components/CodeBlock.tsx +210 -0
- package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
- package/src/runtime/components/ErrorBoundary.tsx +125 -0
- package/src/runtime/components/Keyboard.tsx +87 -0
- package/src/runtime/components/ListStyle.tsx +203 -0
- package/src/runtime/components/NavBar.tsx +223 -0
- package/src/runtime/components/NavBarButton.tsx +47 -0
- package/src/runtime/components/NavBarDivider.tsx +3 -0
- package/src/runtime/components/Notes.tsx +171 -0
- package/src/runtime/components/Reveal.tsx +82 -0
- package/src/runtime/components/RevealGroup.tsx +193 -0
- package/src/runtime/components/SPEC.md +263 -0
- package/src/runtime/components/SlideNumberBadge.tsx +11 -0
- package/src/runtime/components/TimelineSteps.tsx +115 -0
- package/src/runtime/components/index.ts +55 -0
- package/src/runtime/index.ts +42 -0
- package/src/runtime/inputOwnership.ts +68 -0
- package/src/runtime/keyboardTarget.ts +7 -0
- package/src/runtime/lastSlideRoute.ts +56 -0
- package/src/runtime/navigation.ts +211 -0
- package/src/runtime/router.ts +157 -0
- package/src/runtime/slideData.ts +137 -0
- package/src/runtime/sync.ts +267 -0
- package/src/runtime/types.ts +182 -0
- package/src/runtime/useKeyboardNav.ts +138 -0
- package/src/runtime/useSwipeNav.ts +257 -0
- package/src/runtime/views/DocsView.tsx +74 -0
- package/src/runtime/views/OverviewView.tsx +386 -0
- package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
- package/src/runtime/views/PresenterView.tsx +340 -0
- package/src/runtime/views/SPEC.md +152 -0
- package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
- package/src/runtime/views/docs/DocsHeader.tsx +101 -0
- package/src/runtime/views/docs/Intro.tsx +20 -0
- package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
- package/src/runtime/views/docs/ThemeTab.tsx +110 -0
- package/src/runtime/views/index.ts +7 -0
- package/src/runtime/views/overviewGrid.ts +106 -0
- package/src/runtime/views/presenterPreview.ts +27 -0
- package/src/runtime/virtual-modules.d.ts +98 -0
- package/src/theme/SPEC.md +179 -0
- package/src/theme/base.css +623 -0
- package/src/theme/bee.css +35 -0
- package/src/theme/clean.css +38 -0
- package/src/vite-plugin/SPEC.md +114 -0
- package/src/vite-plugin/component-doc-crawler.ts +350 -0
- package/src/vite-plugin/deck-loader.ts +148 -0
- package/src/vite-plugin/index.ts +373 -0
- package/src/vite-plugin/layout-demo-crawler.ts +802 -0
- package/src/vite-plugin/splitter.ts +353 -0
- package/src/vite-plugin/token-manifest.ts +163 -0
- 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
|
+
}
|