@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,353 @@
1
+ /**
2
+ * Text-level slide splitter.
3
+ *
4
+ * Splits a raw `deck.mdx` source string into individual slide segments.
5
+ * Pure function — no file I/O, no side effects.
6
+ *
7
+ * ### Slide-level frontmatter
8
+ * Supports Slidev-style per-slide frontmatter:
9
+ * ```mdx
10
+ * # Slide 1
11
+ * ---
12
+ * layout: Cover
13
+ * author: My Name
14
+ * ---
15
+ * # Cover Slide
16
+ * Opening slide body copy.
17
+ * ---
18
+ * # Normal Slide
19
+ * ```
20
+ * A block consisting entirely of `key: value` pairs (no markdown content)
21
+ * is treated as frontmatter for the NEXT content block, and merged into it
22
+ * as a proper `---\n...\n---` YAML block. This keeps the remark-frontmatter
23
+ * pipeline uniform for all slides.
24
+ */
25
+
26
+ export type SlideSegment = {
27
+ /** 0-based position in the deck */
28
+ index: number;
29
+ /** Complete MDX source for this slide, with shared imports prepended */
30
+ rawMdx: string;
31
+ };
32
+
33
+ export type SplitResult = {
34
+ slides: SlideSegment[];
35
+ /** Parsed deck-level frontmatter (first block only) */
36
+ deckFrontmatter: Record<string, unknown>;
37
+ /** Import lines found in the preamble, prepended to every slide */
38
+ sharedImports: string;
39
+ };
40
+
41
+ export type SplitSlidesOptions = {
42
+ /**
43
+ * Root decks treat the first leading frontmatter block as deck config.
44
+ * Imported MDX files treat every frontmatter block as slide-level metadata.
45
+ */
46
+ frontmatterMode?: "deck" | "slide";
47
+
48
+ /**
49
+ * Drop empty blocks before import extraction. Disable this for already-expanded
50
+ * root decks so imports from a leading imported slide do not become deck-wide
51
+ * shared imports and get duplicated on later slides.
52
+ */
53
+ trimLeadingEmptyBlocks?: boolean;
54
+ };
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Internal helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const DECK_FRONTMATTER_KEYS = new Set([
61
+ "title",
62
+ "description",
63
+ "aspectRatio",
64
+ "colorMode",
65
+ "pdfColorMode",
66
+ "pdfSteps",
67
+ "transition",
68
+ "layouts",
69
+ "defaultLayout",
70
+ "showSlideNumbers",
71
+ ]);
72
+
73
+ /**
74
+ * Parse simple "key: value" YAML (no nesting, no arrays).
75
+ * Coerces booleans and numbers; everything else stays a string.
76
+ *
77
+ * Exported so other parts of the pipeline (e.g. remark/h1-extract.ts) can
78
+ * reuse the same parsing logic without adding an external YAML dep.
79
+ */
80
+ export function parseFrontmatter(yaml: string): Record<string, unknown> {
81
+ const result: Record<string, unknown> = {};
82
+
83
+ for (const line of yaml.split("\n")) {
84
+ const colonIdx = line.indexOf(":");
85
+ if (colonIdx === -1) continue;
86
+
87
+ const key = line.slice(0, colonIdx).trim();
88
+ const raw = line.slice(colonIdx + 1).trim();
89
+
90
+ if (!key) continue;
91
+
92
+ if (raw === "true") {
93
+ result[key] = true;
94
+ } else if (raw === "false") {
95
+ result[key] = false;
96
+ } else if (raw !== "" && !Number.isNaN(Number(raw))) {
97
+ result[key] = Number(raw);
98
+ } else if (
99
+ (raw.startsWith('"') && raw.endsWith('"')) ||
100
+ (raw.startsWith("'") && raw.endsWith("'"))
101
+ ) {
102
+ result[key] = raw.slice(1, -1);
103
+ } else {
104
+ result[key] = raw;
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * The first YAML block is deck config, but authors naturally expect slide-level
113
+ * fields such as `layout: Cover` there to affect the opening slide too.
114
+ * Keep deck-only keys out of the slide frontmatter and pass everything else
115
+ * through as first-slide metadata.
116
+ */
117
+ function firstSlideFrontmatterFromDeckYaml(yamlLines: string[]): string[] {
118
+ const slideLines = yamlLines.filter((line) => {
119
+ const match = line.match(/^\s*([\w-]+)\s*:/);
120
+ const key = match?.[1];
121
+ if (!key) return false;
122
+ return !DECK_FRONTMATTER_KEYS.has(key);
123
+ });
124
+ return slideLines.some((line) => /^\s*layout\s*:/.test(line))
125
+ ? slideLines
126
+ : [];
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Fence tracking
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Return the opening fence marker for Markdown fenced code blocks.
135
+ * Supports backtick and tilde fences indented by up to three spaces.
136
+ */
137
+ function getOpeningCodeFenceMarker(line: string): string | null {
138
+ const match = line.match(/^ {0,3}(`{3,}|~{3,})/);
139
+ return match?.[1] ?? null;
140
+ }
141
+
142
+ function isClosingFence(line: string, openingFence: string): boolean {
143
+ const openingChar = openingFence[0];
144
+ if (!openingChar) return false;
145
+
146
+ const escapedChar = openingChar === "`" ? "`" : "~";
147
+ const pattern = new RegExp(
148
+ `^ {0,3}(${escapedChar}{${openingFence.length},})\\s*$`,
149
+ );
150
+ return pattern.test(line);
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Frontmatter-only block detection
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Returns true if every non-empty line in `lines` looks like a YAML key-value
159
+ * pair (`key: value`). Used to identify slide-level frontmatter blocks that
160
+ * appear between `---` separators and should be merged with the next slide.
161
+ */
162
+ function isFrontmatterOnlyBlock(lines: string[]): boolean {
163
+ const meaningful = lines.filter((l) => l.trim().length > 0);
164
+ if (meaningful.length === 0) return false; // empty block → not frontmatter
165
+ // Every meaningful line must look like `word: ...` (YAML key-value)
166
+ return meaningful.every((line) => /^\s*[\w-]+\s*:/.test(line));
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Public API
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Split a raw MDX deck source into individual slide segments.
175
+ *
176
+ * ### Logic
177
+ * 1. If the file starts with `---\n`, extract YAML frontmatter (between the
178
+ * first and second `---` lines) as `deckFrontmatter`.
179
+ * 2. The remaining content is split on separator lines (lines that are
180
+ * exactly `---`), except inside fenced code blocks.
181
+ * 3. The **first block** (before the first separator) is the preamble:
182
+ * - Lines matching `/^import\s/` become `sharedImports`.
183
+ * - All other non-empty lines become the first slide's body.
184
+ * 4. `sharedImports` is prepended to every slide's `rawMdx` so that
185
+ * components imported in the preamble are available everywhere.
186
+ * 5. Empty blocks (only whitespace) are skipped and produce no slide.
187
+ */
188
+ function normalizeLineEndings(source: string): string {
189
+ return source.replace(/\r\n?/g, "\n");
190
+ }
191
+
192
+ export function splitSlides(
193
+ source: string,
194
+ options: SplitSlidesOptions = {},
195
+ ): SplitResult {
196
+ const frontmatterMode = options.frontmatterMode ?? "deck";
197
+ const trimLeadingEmptyBlocks = options.trimLeadingEmptyBlocks ?? true;
198
+ const lines = normalizeLineEndings(source).split("\n");
199
+ let deckFrontmatter: Record<string, unknown> = {};
200
+ let firstSlideFrontmatterLines: string[] = [];
201
+ let startLine = 0;
202
+
203
+ // ── Step 1: extract frontmatter if present ──────────────────────────────
204
+ if (frontmatterMode === "deck" && lines[0] === "---") {
205
+ // Find the closing '---' line (first occurrence after line 0)
206
+ const fmEnd = lines.indexOf("---", 1);
207
+ if (fmEnd !== -1) {
208
+ const yamlLines = lines.slice(1, fmEnd);
209
+ deckFrontmatter = parseFrontmatter(yamlLines.join("\n"));
210
+ firstSlideFrontmatterLines = firstSlideFrontmatterFromDeckYaml(yamlLines);
211
+ startLine = fmEnd + 1;
212
+ }
213
+ }
214
+
215
+ // ── Step 2: split remaining lines into blocks on '---' separators ───────
216
+ const rawBlocks: string[][] = [[]];
217
+ let openCodeFence: string | null = null;
218
+
219
+ for (let i = startLine; i < lines.length; i++) {
220
+ const line = lines[i];
221
+ if (line === undefined) continue;
222
+
223
+ if (!openCodeFence && line === "---") {
224
+ rawBlocks.push([]);
225
+ continue;
226
+ }
227
+
228
+ rawBlocks[rawBlocks.length - 1]?.push(line);
229
+
230
+ const marker = getOpeningCodeFenceMarker(line);
231
+ if (!marker) continue;
232
+
233
+ if (openCodeFence) {
234
+ if (isClosingFence(line, openCodeFence)) {
235
+ openCodeFence = null;
236
+ }
237
+ } else {
238
+ openCodeFence = marker;
239
+ }
240
+ }
241
+
242
+ // ── Step 2b: merge frontmatter-only blocks with the following content ────
243
+ //
244
+ // A block consisting entirely of `key: value` pairs (no real markdown
245
+ // content) is treated as slide-level frontmatter for the NEXT block. It is
246
+ // re-emitted as a YAML frontmatter header (`---\n...\n---`) prepended to
247
+ // the next block's MDX source. This lets remark-frontmatter parse it.
248
+ const blocks: string[][] = [];
249
+ {
250
+ let bi = 0;
251
+ while (bi < rawBlocks.length) {
252
+ const block = rawBlocks[bi];
253
+ if (!block) break;
254
+ const nextBlock = rawBlocks[bi + 1];
255
+
256
+ if (nextBlock !== undefined && isFrontmatterOnlyBlock(block)) {
257
+ // Merge: prepend `---\n<yaml>\n---\n` to the next block
258
+ const yamlLines = block.filter((l) => l.trim().length > 0);
259
+ const merged = ["---", ...yamlLines, "---", "", ...nextBlock];
260
+ blocks.push(merged);
261
+ bi += 2; // skip the consumed next block
262
+ } else {
263
+ blocks.push(block);
264
+ bi++;
265
+ }
266
+ }
267
+ }
268
+
269
+ if (trimLeadingEmptyBlocks) {
270
+ while (blocks[0]?.every((line) => line.trim().length === 0)) {
271
+ blocks.shift();
272
+ }
273
+ }
274
+
275
+ // ── Step 3: extract shared imports from the first block ─────────────────
276
+ const firstBlock = blocks[0] ?? [];
277
+ const importLines: string[] = [];
278
+ const slideContentLines: string[] = [];
279
+
280
+ for (const line of firstBlock) {
281
+ if (/^import\s/.test(line)) {
282
+ importLines.push(line);
283
+ } else {
284
+ slideContentLines.push(line);
285
+ }
286
+ }
287
+
288
+ const sharedImports = importLines.join("\n").trim();
289
+ const firstSlideContentBody = slideContentLines.join("\n").trim();
290
+
291
+ // ── Step 4: build slide segments ────────────────────────────────────────
292
+ const slides: SlideSegment[] = [];
293
+ let pendingFirstSlideFrontmatterLines = firstSlideFrontmatterLines;
294
+
295
+ function applyPendingFirstSlideFrontmatter(content: string): string {
296
+ if (pendingFirstSlideFrontmatterLines.length === 0) return content;
297
+
298
+ const linesToApply = pendingFirstSlideFrontmatterLines;
299
+ pendingFirstSlideFrontmatterLines = [];
300
+
301
+ if (content.startsWith("---")) {
302
+ const lines = content.split("\n");
303
+ const closingIdx = lines.indexOf("---", 1);
304
+ if (closingIdx !== -1) {
305
+ return ["---", ...linesToApply, ...lines.slice(1)].join("\n");
306
+ }
307
+ }
308
+
309
+ return ["---", ...linesToApply, "---", "", content].join("\n");
310
+ }
311
+
312
+ function addSlide(content: string): void {
313
+ let trimmed = content.trim();
314
+ if (!trimmed) return; // skip empty / whitespace-only blocks
315
+
316
+ trimmed = applyPendingFirstSlideFrontmatter(trimmed);
317
+
318
+ // If the slide content begins with a YAML frontmatter block (`---`), the
319
+ // shared imports must be placed AFTER the closing `---` so that
320
+ // remark-frontmatter sees the `---` at the very start of the document.
321
+ let rawMdx: string;
322
+ if (sharedImports && trimmed.startsWith("---")) {
323
+ // Find the end of the frontmatter block (second `---` line)
324
+ const fmLines = trimmed.split("\n");
325
+ const closingIdx = fmLines.indexOf("---", 1);
326
+ if (closingIdx !== -1) {
327
+ const fmBlock = fmLines.slice(0, closingIdx + 1).join("\n"); // includes both `---`
328
+ const body = fmLines
329
+ .slice(closingIdx + 1)
330
+ .join("\n")
331
+ .trimStart();
332
+ rawMdx = body
333
+ ? `${fmBlock}\n\n${sharedImports}\n\n${body}`
334
+ : `${fmBlock}\n\n${sharedImports}`;
335
+ } else {
336
+ // Malformed frontmatter — fall back to prepending
337
+ rawMdx = `${sharedImports}\n\n${trimmed}`;
338
+ }
339
+ } else {
340
+ rawMdx = sharedImports ? `${sharedImports}\n\n${trimmed}` : trimmed;
341
+ }
342
+
343
+ slides.push({ index: slides.length, rawMdx });
344
+ }
345
+
346
+ addSlide(firstSlideContentBody);
347
+
348
+ for (let i = 1; i < blocks.length; i++) {
349
+ addSlide((blocks[i] ?? []).join("\n"));
350
+ }
351
+
352
+ return { slides, deckFrontmatter, sharedImports };
353
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Token Manifest Vite plugin for Honeydeck.
3
+ *
4
+ * Parses `src/theme/base.css` for `--honeydeck-*` CSS custom property declarations
5
+ * and their preceding single-line `/* ... *\/` comments (descriptions).
6
+ *
7
+ * Exposes the result as a virtual module:
8
+ *
9
+ * import { tokens } from 'virtual:honeydeck/token-manifest'
10
+ *
11
+ * Shape:
12
+ * export const tokens: TokenManifestEntry[]
13
+ *
14
+ * where `TokenManifestEntry = { name: string; description: string; defaultValue: string }`.
15
+ *
16
+ * ### Parsing rules
17
+ * - Tokens are extracted from anywhere in the file (`:root`, `[data-honeydeck-color-mode=...]`, `@theme`, etc.)
18
+ * - If the same token name appears multiple times the **first** occurrence wins (light mode
19
+ * declaration takes precedence over dark mode in the manifest).
20
+ * - A description is the text of the nearest single-line comment (`/* ... *\/`) on the
21
+ * line immediately above the declaration, ignoring blank lines.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import type { HmrContext, ModuleNode, Plugin } from "vite";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Paths
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+
35
+ /** Absolute path to the base CSS theme file. */
36
+ const BASE_CSS_PATH = resolve(__dirname, "../theme/base.css");
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Virtual module IDs
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const VIRTUAL_ID = "virtual:honeydeck/token-manifest";
43
+ const RESOLVED_ID = "\0virtual:honeydeck/token-manifest";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export type TokenManifestEntry = {
50
+ /** CSS custom property name, e.g. `--honeydeck-primary` */
51
+ name: string;
52
+ /** Description sourced from the preceding comment, or `""` */
53
+ description: string;
54
+ /** Raw default value from the CSS declaration, e.g. `oklch(50% 0.2 250)` */
55
+ defaultValue: string;
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Parser
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Extract all `--honeydeck-*` token entries from a CSS string.
64
+ *
65
+ * @param css - Raw CSS text (e.g. contents of base.css)
66
+ * @returns Array of token entries in document order (first occurrence wins for duplicates).
67
+ */
68
+ export function extractTokens(css: string): TokenManifestEntry[] {
69
+ const tokens: TokenManifestEntry[] = [];
70
+ const seen = new Set<string>();
71
+ const lines = css.split("\n");
72
+
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const line = lines[i]?.trim();
75
+
76
+ // Match a CSS custom property declaration:
77
+ // --honeydeck-something: <value>;
78
+ // Allow optional trailing inline comment.
79
+ const propMatch = line.match(
80
+ /^(--honeydeck-[\w-]+)\s*:\s*(.+?)\s*(?:;.*)?$/,
81
+ );
82
+ if (!propMatch) continue;
83
+
84
+ const [, name, value] = propMatch;
85
+ if (!name || !value) continue;
86
+ const rawValue = value
87
+ .replace(/;.*$/, "") // strip trailing semicolon + any trailing comment
88
+ .trim();
89
+
90
+ // First occurrence of this token name wins.
91
+ if (seen.has(name)) continue;
92
+ seen.add(name);
93
+
94
+ // Look backward (up to 3 lines) for the nearest preceding comment.
95
+ // Stop if we hit a blank line or another property declaration first.
96
+ let description = "";
97
+ for (let j = i - 1; j >= Math.max(0, i - 4); j--) {
98
+ const prev = lines[j]?.trim();
99
+ if (prev === "") continue; // skip blank lines
100
+ if (prev.startsWith("--") || prev.startsWith("}") || prev.startsWith("{"))
101
+ break;
102
+
103
+ // Single-line /* ... */ comment
104
+ const commentMatch = prev.match(/^\/\*\s*(.*?)\s*\*\/\s*$/);
105
+ if (commentMatch) {
106
+ description = commentMatch[1]?.trim();
107
+ break;
108
+ }
109
+ // If it's code (not a comment), stop.
110
+ break;
111
+ }
112
+
113
+ tokens.push({ name, description, defaultValue: rawValue });
114
+ }
115
+
116
+ return tokens;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Plugin
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Vite plugin that exposes `virtual:honeydeck/token-manifest`.
125
+ * Watches `src/theme/base.css` and invalidates the module on change.
126
+ */
127
+ export function tokenManifestPlugin(): Plugin {
128
+ return {
129
+ name: "honeydeck:token-manifest",
130
+
131
+ resolveId(id: string) {
132
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
133
+ return null;
134
+ },
135
+
136
+ load(id: string) {
137
+ if (id !== RESOLVED_ID) return null;
138
+
139
+ const css = readFileSync(BASE_CSS_PATH, "utf-8");
140
+ const tokens = extractTokens(css);
141
+
142
+ return [
143
+ `export const tokens = ${JSON.stringify(tokens, null, 2)};`,
144
+ `export default tokens;`,
145
+ ].join("\n");
146
+ },
147
+
148
+ // Ensure the CSS file is watched in dev mode.
149
+ configureServer(server) {
150
+ server.watcher.add(BASE_CSS_PATH);
151
+ },
152
+
153
+ // Invalidate the virtual module when base.css changes.
154
+ handleHotUpdate(ctx: HmrContext): ModuleNode[] | undefined {
155
+ if (ctx.file !== BASE_CSS_PATH) return;
156
+ const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID);
157
+ if (mod) {
158
+ ctx.server.moduleGraph.invalidateModule(mod);
159
+ return [mod];
160
+ }
161
+ },
162
+ };
163
+ }