@honeydeck/honeydeck 0.4.0 → 0.6.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 (71) hide show
  1. package/AGENTS.md +4 -4
  2. package/DEVELOPMENT.md +6 -4
  3. package/Readme.md +15 -15
  4. package/SPEC.md +5 -4
  5. package/docs/browser-frame.md +38 -0
  6. package/docs/components.md +16 -57
  7. package/docs/configuration.md +13 -0
  8. package/docs/customization.md +2 -0
  9. package/docs/deeper-dive.md +32 -7
  10. package/docs/getting-started.md +4 -2
  11. package/docs/index.json +258 -0
  12. package/docs/keyboard.md +35 -0
  13. package/docs/list-style.md +53 -0
  14. package/docs/local-development.md +3 -1
  15. package/docs/mermaid.md +2 -0
  16. package/docs/mobile.md +2 -0
  17. package/docs/navigation.md +3 -1
  18. package/docs/notes.md +40 -0
  19. package/docs/pdf-export.md +6 -2
  20. package/docs/presenter-mode.md +8 -3
  21. package/docs/reveal-group.md +60 -0
  22. package/docs/reveal-with.md +39 -0
  23. package/docs/reveal.md +35 -0
  24. package/docs/skills.md +5 -3
  25. package/docs/slides.md +2 -0
  26. package/docs/slidev-migration.md +5 -0
  27. package/docs/steps-and-reveals.md +145 -8
  28. package/docs/timeline-steps.md +50 -0
  29. package/package.json +6 -2
  30. package/skills/SPEC.md +6 -6
  31. package/skills/honeydeck/SKILL.md +9 -9
  32. package/skills/slidev-migration/SKILL.md +7 -6
  33. package/src/SPEC.md +8 -3
  34. package/src/cli/SPEC.md +3 -2
  35. package/src/cli/pdf.ts +11 -4
  36. package/src/remark/SPEC.md +102 -2
  37. package/src/remark/code-utils.ts +151 -0
  38. package/src/remark/shiki-code-blocks.ts +329 -136
  39. package/src/remark/step-numbering.ts +408 -103
  40. package/src/runtime/Deck.tsx +133 -116
  41. package/src/runtime/EffectiveColorModeContext.tsx +37 -0
  42. package/src/runtime/SPEC.md +21 -8
  43. package/src/runtime/SlideCanvas.tsx +19 -16
  44. package/src/runtime/SlideScaleContext.tsx +23 -0
  45. package/src/runtime/components/CodeBlock.tsx +19 -202
  46. package/src/runtime/components/CodeBlockCopyButton.tsx +64 -0
  47. package/src/runtime/components/CodeBlockShared.ts +17 -0
  48. package/src/runtime/components/Fade.tsx +51 -0
  49. package/src/runtime/components/FadeGroup.tsx +175 -0
  50. package/src/runtime/components/FadeWith.tsx +54 -0
  51. package/src/runtime/components/MagicCodeBlock.tsx +223 -0
  52. package/src/runtime/components/NavBar.tsx +1 -1
  53. package/src/runtime/components/NormalCodeBlock.tsx +128 -0
  54. package/src/runtime/components/Reveal.tsx +27 -27
  55. package/src/runtime/components/RevealGroup.tsx +143 -41
  56. package/src/runtime/components/RevealWith.tsx +63 -0
  57. package/src/runtime/components/SPEC.md +115 -10
  58. package/src/runtime/components/TimelineReveal.tsx +81 -0
  59. package/src/runtime/components/index.ts +13 -5
  60. package/src/runtime/components/timelineVisibility.ts +45 -0
  61. package/src/runtime/index.ts +9 -1
  62. package/src/runtime/navigation.ts +6 -4
  63. package/src/runtime/presentationApi.ts +449 -0
  64. package/src/runtime/views/PresenterCastButton.tsx +39 -0
  65. package/src/runtime/views/PresenterView.tsx +21 -4
  66. package/src/runtime/views/SPEC.md +7 -5
  67. package/src/theme/base.css +67 -2
  68. package/src/vite-plugin/SPEC.md +20 -2
  69. package/src/vite-plugin/index.ts +16 -2
  70. package/src/vite-plugin/splitter.ts +1 -0
  71. package/src/vite-plugin/virtual-modules.ts +16 -6
@@ -0,0 +1,151 @@
1
+ /** Shared helpers for Honeydeck fenced code and Magic Code remark transforms. */
2
+
3
+ import type { Code } from "mdast";
4
+
5
+ /** A single step-through group: either an array of 1-based line numbers or 'all'. */
6
+ export type StepGroup = number[] | "all";
7
+
8
+ export type ParsedCodeFence = {
9
+ lang: string;
10
+ meta: string | null;
11
+ value: string;
12
+ };
13
+
14
+ export type MagicCodeDurationResult =
15
+ | { ok: true; duration: number }
16
+ | { ok: false; message: string };
17
+
18
+ export const DEFAULT_MAGIC_CODE_DURATION = 800;
19
+
20
+ /** Parse a code fence meta string like `{2|4-5|all}` into step groups. */
21
+ export function parseStepMeta(meta: string | null | undefined): StepGroup[] {
22
+ if (!meta) return [];
23
+
24
+ const match = meta.match(/\{([^}]+)\}/);
25
+ if (!match?.[1]) return [];
26
+
27
+ const groupStrings = match[1].split("|").filter(Boolean);
28
+ if (groupStrings.length === 0) return [];
29
+
30
+ return groupStrings.map((group): StepGroup => {
31
+ const trimmed = group.trim();
32
+ if (trimmed === "all") return "all";
33
+
34
+ const lines: number[] = [];
35
+ for (const part of trimmed.split(",")) {
36
+ const dashIdx = part.indexOf("-");
37
+ if (dashIdx !== -1) {
38
+ const start = parseInt(part.slice(0, dashIdx).trim(), 10);
39
+ const end = parseInt(part.slice(dashIdx + 1).trim(), 10);
40
+ if (!Number.isNaN(start) && !Number.isNaN(end)) {
41
+ for (let i = start; i <= end; i++) lines.push(i);
42
+ }
43
+ } else {
44
+ const n = parseInt(part.trim(), 10);
45
+ if (!Number.isNaN(n)) lines.push(n);
46
+ }
47
+ }
48
+ return lines;
49
+ });
50
+ }
51
+
52
+ export function codeFenceGroups(meta: string | null | undefined): StepGroup[] {
53
+ const steps = parseStepMeta(meta);
54
+ return steps.length > 0 ? steps : ["all"];
55
+ }
56
+
57
+ export function countCodeFenceGroups(meta: string | null | undefined): number {
58
+ if (!meta) return 0;
59
+ const match = meta.match(/\{([^}]+)\}/);
60
+ if (!match?.[1]) return 0;
61
+ return match[1].split("|").filter(Boolean).length;
62
+ }
63
+
64
+ export function countCodeFenceSteps(meta: string | null | undefined): number {
65
+ return Math.max(0, countCodeFenceGroups(meta) - 1);
66
+ }
67
+
68
+ export function isMagicCodeFence(node: Code): boolean {
69
+ if (node.lang !== "md") return false;
70
+ const firstMetaToken = node.meta?.trim().split(/\s+/)[0];
71
+ return firstMetaToken === "magic-code" || firstMetaToken === "magic-move";
72
+ }
73
+
74
+ export function parseMagicCodeDuration(
75
+ meta: string | null | undefined,
76
+ deckDefault: unknown,
77
+ ): MagicCodeDurationResult {
78
+ const fallback =
79
+ typeof deckDefault === "number" &&
80
+ Number.isFinite(deckDefault) &&
81
+ deckDefault >= 0
82
+ ? deckDefault
83
+ : DEFAULT_MAGIC_CODE_DURATION;
84
+
85
+ const match = meta?.match(/\{\s*duration\s*:\s*([^}]+)\}/);
86
+ if (!match?.[1]) return { ok: true, duration: fallback };
87
+
88
+ const raw = match[1].trim();
89
+ const duration = Number(raw);
90
+ if (!Number.isFinite(duration) || duration < 0) {
91
+ return {
92
+ ok: false,
93
+ message: `Honeydeck Magic Code duration must be a non-negative number of milliseconds, got ${JSON.stringify(raw)}.`,
94
+ };
95
+ }
96
+
97
+ return { ok: true, duration };
98
+ }
99
+
100
+ function splitFenceInfo(info: string): { lang: string; meta: string | null } {
101
+ const trimmed = info.trim();
102
+ if (!trimmed) return { lang: "text", meta: null };
103
+
104
+ const firstSpace = trimmed.search(/\s/);
105
+ if (firstSpace === -1) return { lang: trimmed, meta: null };
106
+
107
+ return {
108
+ lang: trimmed.slice(0, firstSpace),
109
+ meta: trimmed.slice(firstSpace).trim() || null,
110
+ };
111
+ }
112
+
113
+ /** Parse fenced code blocks inside an outer Markdown code fence. */
114
+ export function parseInnerCodeFences(markdown: string): ParsedCodeFence[] {
115
+ const lines = markdown.split(/\r?\n/);
116
+ const fences: ParsedCodeFence[] = [];
117
+
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const opener = lines[i]?.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
120
+ if (!opener?.[1]) continue;
121
+
122
+ const marker = opener[1][0];
123
+ const length = opener[1].length;
124
+ const { lang, meta } = splitFenceInfo(opener[2] ?? "");
125
+ const body: string[] = [];
126
+ i++;
127
+
128
+ for (; i < lines.length; i++) {
129
+ const closer = lines[i]?.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
130
+ if (
131
+ closer?.[1] &&
132
+ closer[1][0] === marker &&
133
+ closer[1].length >= length
134
+ ) {
135
+ break;
136
+ }
137
+ body.push(lines[i] ?? "");
138
+ }
139
+
140
+ fences.push({ lang, meta, value: body.join("\n") });
141
+ }
142
+
143
+ return fences;
144
+ }
145
+
146
+ export function countMagicCodeStates(markdown: string): number {
147
+ return parseInnerCodeFences(markdown).reduce(
148
+ (total, fence) => total + codeFenceGroups(fence.meta).length,
149
+ 0,
150
+ );
151
+ }
@@ -18,8 +18,9 @@
18
18
  * (JSON-encoded step groups), and `startAt` (1-based timeline step where
19
19
  * the second group activates, from `node.data.honeydeckStartAt`), plus
20
20
  * `source` (the original fenced code text for clipboard copying).
21
- * 3. Injects a single `import { HoneydeckCodeBlock } from '@honeydeck/honeydeck/components/code-block'`
22
- * declaration into the MDAST root when at least one code block was transformed.
21
+ * 3. Injects `HoneydeckCodeBlock` from '@honeydeck/honeydeck/components/code-block/normal'
22
+ * when at least one normal code block was transformed, and `HoneydeckMagicCodeBlock`
23
+ * from '@honeydeck/honeydeck/components/code-block/magic' when needed.
23
24
  *
24
25
  * ### CSS variable activation
25
26
  * The highlighted HTML uses shiki's dual-theme variable names
@@ -31,10 +32,13 @@
31
32
  * - `{2}` — highlight line 2 immediately; consumes no timeline steps
32
33
  * - `{2|4-5|all}` — three step groups; first group is baseline, then later groups activate at `startAt`, `startAt+1`
33
34
  *
34
- * @see HoneydeckCodeBlock in `src/runtime/components/CodeBlock.tsx`
35
+ * @see HoneydeckCodeBlock in `src/runtime/components/NormalCodeBlock.tsx`
36
+ * @see HoneydeckMagicCodeBlock in `src/runtime/components/MagicCodeBlock.tsx`
35
37
  * @see remarkStepNumbering for the counter that provides `honeydeckStartAt`
36
38
  */
37
39
 
40
+ import type { KeyedTokensInfo } from "@shikijs/magic-move/core";
41
+ import { codeToKeyedTokens } from "@shikijs/magic-move/core";
38
42
  import type { Program } from "estree";
39
43
  import type { Code, Root } from "mdast";
40
44
  import type {
@@ -46,13 +50,40 @@ import type { MdxjsEsm } from "mdast-util-mdxjs-esm";
46
50
  import { getSingletonHighlighter } from "shiki";
47
51
  import type { Plugin } from "unified";
48
52
  import { visit } from "unist-util-visit";
53
+ import {
54
+ codeFenceGroups,
55
+ isMagicCodeFence,
56
+ type ParsedCodeFence,
57
+ parseInnerCodeFences,
58
+ parseMagicCodeDuration,
59
+ parseStepMeta,
60
+ type StepGroup,
61
+ } from "./code-utils.ts";
62
+
63
+ export type { StepGroup } from "./code-utils.ts";
64
+ export { parseStepMeta } from "./code-utils.ts";
49
65
 
50
66
  // ---------------------------------------------------------------------------
51
67
  // Types
52
68
  // ---------------------------------------------------------------------------
53
69
 
54
- /** A single step-through group: either an array of 1-based line numbers or 'all'. */
55
- export type StepGroup = number[] | "all";
70
+ export type RemarkShikiCodeBlocksOptions = {
71
+ /** Deck-level Magic Code default duration from root frontmatter. */
72
+ magicCodeDuration?: unknown;
73
+ };
74
+
75
+ type MagicTimelineState = {
76
+ fence: ParsedCodeFence;
77
+ group: StepGroup;
78
+ tokenStateIndex: number;
79
+ };
80
+
81
+ type MagicTokenPayload = {
82
+ lightTokenStates: KeyedTokensInfo[];
83
+ darkTokenStates: KeyedTokensInfo[];
84
+ tokenStateIndexes: number[];
85
+ sources: string[];
86
+ };
56
87
 
57
88
  // ---------------------------------------------------------------------------
58
89
  // Singleton shiki highlighter
@@ -88,55 +119,6 @@ async function loadLang(
88
119
  }
89
120
  }
90
121
 
91
- // ---------------------------------------------------------------------------
92
- // Step meta parsing
93
- // ---------------------------------------------------------------------------
94
-
95
- /**
96
- * Parse a code fence meta string like `{2|4-5|all}` into an array of step groups.
97
- *
98
- * Returns `[]` when there is no `{…}` block in the meta string.
99
- * Even a single group like `{2}` produces `[[2]]` — a baseline highlight that
100
- * consumes no timeline steps.
101
- *
102
- * @example
103
- * parseStepMeta('{2|4-5|all}') // → [[2], [4,5], 'all']
104
- * parseStepMeta('{2}') // → [[2]] (baseline highlight, no timeline step)
105
- * parseStepMeta(null) // → []
106
- */
107
- export function parseStepMeta(meta: string | null | undefined): StepGroup[] {
108
- if (!meta) return [];
109
-
110
- const match = meta.match(/\{([^}]+)\}/);
111
- if (!match?.[1]) return [];
112
-
113
- const groupStrings = match[1].split("|").filter(Boolean);
114
- if (groupStrings.length === 0) return [];
115
- // Note: single groups are valid — they represent a baseline highlight and
116
- // consume no timeline steps.
117
-
118
- return groupStrings.map((group): StepGroup => {
119
- const trimmed = group.trim();
120
- if (trimmed === "all") return "all";
121
-
122
- const lines: number[] = [];
123
- for (const part of trimmed.split(",")) {
124
- const dashIdx = part.indexOf("-");
125
- if (dashIdx !== -1) {
126
- const start = parseInt(part.slice(0, dashIdx).trim(), 10);
127
- const end = parseInt(part.slice(dashIdx + 1).trim(), 10);
128
- if (!Number.isNaN(start) && !Number.isNaN(end)) {
129
- for (let i = start; i <= end; i++) lines.push(i);
130
- }
131
- } else {
132
- const n = parseInt(part.trim(), 10);
133
- if (!Number.isNaN(n)) lines.push(n);
134
- }
135
- }
136
- return lines;
137
- });
138
- }
139
-
140
122
  // ---------------------------------------------------------------------------
141
123
  // AST helpers
142
124
  // ---------------------------------------------------------------------------
@@ -226,12 +208,196 @@ function injectImport(tree: Root, specifier: string, source: string): void {
226
208
  );
227
209
  }
228
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Highlight helpers
213
+ // ---------------------------------------------------------------------------
214
+
215
+ async function codeToHighlightedHtml(
216
+ highlighter: Awaited<ReturnType<typeof getSingletonHighlighter>>,
217
+ value: string,
218
+ rawLang: string,
219
+ ): Promise<string> {
220
+ const lang = await loadLang(highlighter, rawLang);
221
+
222
+ try {
223
+ return highlighter.codeToHtml(value, {
224
+ lang,
225
+ themes: { light: "github-light", dark: "github-dark" },
226
+ defaultColor: false,
227
+ transformers: [
228
+ {
229
+ line(lineNode, lineNumber) {
230
+ if (!lineNode.properties) lineNode.properties = {};
231
+ lineNode.properties["data-line"] = lineNumber;
232
+ },
233
+ },
234
+ ],
235
+ });
236
+ } catch {
237
+ const escaped = value
238
+ .replace(/&/g, "&amp;")
239
+ .replace(/</g, "&lt;")
240
+ .replace(/>/g, "&gt;");
241
+ return `<pre class="honeydeck-code-plain"><code>${escaped}</code></pre>`;
242
+ }
243
+ }
244
+
245
+ function getMagicTokenStateKey(fence: ParsedCodeFence): string {
246
+ return `${fence.lang}\0${fence.value}`;
247
+ }
248
+
249
+ function flattenMagicTimeline(fences: ParsedCodeFence[]): MagicTimelineState[] {
250
+ const tokenStateIndexes = new Map<string, number>();
251
+ return fences.flatMap((fence) => {
252
+ const key = getMagicTokenStateKey(fence);
253
+ let tokenStateIndex = tokenStateIndexes.get(key);
254
+ if (tokenStateIndex === undefined) {
255
+ tokenStateIndex = tokenStateIndexes.size;
256
+ tokenStateIndexes.set(key, tokenStateIndex);
257
+ }
258
+ return codeFenceGroups(fence.meta).map((group) => ({
259
+ fence,
260
+ group,
261
+ tokenStateIndex,
262
+ }));
263
+ });
264
+ }
265
+
266
+ function getUniqueMagicTokenStates(
267
+ states: MagicTimelineState[],
268
+ ): ParsedCodeFence[] {
269
+ const uniqueStates: ParsedCodeFence[] = [];
270
+ for (const state of states) {
271
+ if (!uniqueStates[state.tokenStateIndex]) {
272
+ uniqueStates[state.tokenStateIndex] = state.fence;
273
+ }
274
+ }
275
+ return uniqueStates;
276
+ }
277
+
278
+ async function codeToMagicTokens(
279
+ highlighter: Awaited<ReturnType<typeof getSingletonHighlighter>>,
280
+ state: MagicTimelineState,
281
+ theme: "github-light" | "github-dark",
282
+ ): Promise<KeyedTokensInfo> {
283
+ const lang = await loadLang(highlighter, state.fence.lang);
284
+
285
+ try {
286
+ return codeToKeyedTokens(
287
+ highlighter,
288
+ state.fence.value,
289
+ { lang, theme } as Parameters<typeof highlighter.codeToTokens>[1],
290
+ false,
291
+ );
292
+ } catch {
293
+ return codeToKeyedTokens(
294
+ highlighter,
295
+ state.fence.value,
296
+ { lang: "text", theme } as Parameters<typeof highlighter.codeToTokens>[1],
297
+ false,
298
+ );
299
+ }
300
+ }
301
+
302
+ async function compileMagicTokens(
303
+ highlighter: Awaited<ReturnType<typeof getSingletonHighlighter>>,
304
+ fences: ParsedCodeFence[],
305
+ theme: "github-light" | "github-dark",
306
+ ): Promise<KeyedTokensInfo[]> {
307
+ const tokens: KeyedTokensInfo[] = [];
308
+ for (const fence of fences) {
309
+ tokens.push(
310
+ await codeToMagicTokens(
311
+ highlighter,
312
+ { fence, group: "all", tokenStateIndex: 0 },
313
+ theme,
314
+ ),
315
+ );
316
+ }
317
+ return tokens;
318
+ }
319
+
320
+ async function compileMagicTokenPayload(
321
+ highlighter: Awaited<ReturnType<typeof getSingletonHighlighter>>,
322
+ states: MagicTimelineState[],
323
+ ): Promise<MagicTokenPayload> {
324
+ const tokenFences = getUniqueMagicTokenStates(states);
325
+ return {
326
+ lightTokenStates: await compileMagicTokens(
327
+ highlighter,
328
+ tokenFences,
329
+ "github-light",
330
+ ),
331
+ darkTokenStates: await compileMagicTokens(
332
+ highlighter,
333
+ tokenFences,
334
+ "github-dark",
335
+ ),
336
+ tokenStateIndexes: states.map((state) => state.tokenStateIndex),
337
+ sources: tokenFences.map((fence) => fence.value),
338
+ };
339
+ }
340
+
341
+ function makeCodeBlockNode(
342
+ html: string,
343
+ steps: StepGroup[],
344
+ startAt: number,
345
+ source: string,
346
+ ): MdxJsxFlowElement {
347
+ return {
348
+ type: "mdxJsxFlowElement",
349
+ name: CODE_BLOCK_COMPONENT_NAME,
350
+ attributes: [
351
+ makeStringAttr("html", html),
352
+ makeStringAttr("stepsJson", JSON.stringify(steps)),
353
+ makeNumericAttr("startAt", startAt),
354
+ makeStringAttr("source", source),
355
+ ],
356
+ children: [],
357
+ };
358
+ }
359
+
360
+ function makeMagicCodeNode(
361
+ payload: MagicTokenPayload,
362
+ stateGroups: StepGroup[],
363
+ startAt: number,
364
+ duration: number,
365
+ ): MdxJsxFlowElement {
366
+ return {
367
+ type: "mdxJsxFlowElement",
368
+ name: MAGIC_CODE_COMPONENT_NAME,
369
+ attributes: [
370
+ makeStringAttr(
371
+ "lightTokenStatesJson",
372
+ JSON.stringify(payload.lightTokenStates),
373
+ ),
374
+ makeStringAttr(
375
+ "darkTokenStatesJson",
376
+ JSON.stringify(payload.darkTokenStates),
377
+ ),
378
+ makeStringAttr(
379
+ "tokenStateIndexesJson",
380
+ JSON.stringify(payload.tokenStateIndexes),
381
+ ),
382
+ makeStringAttr("stepGroupsJson", JSON.stringify(stateGroups)),
383
+ makeStringAttr("sourcesJson", JSON.stringify(payload.sources)),
384
+ makeNumericAttr("startAt", startAt),
385
+ makeNumericAttr("duration", duration),
386
+ ],
387
+ children: [],
388
+ };
389
+ }
390
+
229
391
  // ---------------------------------------------------------------------------
230
392
  // Plugin
231
393
  // ---------------------------------------------------------------------------
232
394
 
233
- const COMPONENT_NAME = "HoneydeckCodeBlock";
234
- const IMPORT_SOURCE = "@honeydeck/honeydeck/components/code-block";
395
+ const CODE_BLOCK_COMPONENT_NAME = "HoneydeckCodeBlock";
396
+ const MAGIC_CODE_COMPONENT_NAME = "HoneydeckMagicCodeBlock";
397
+ const CODE_BLOCK_IMPORT_SOURCE =
398
+ "@honeydeck/honeydeck/components/code-block/normal";
399
+ const MAGIC_CODE_IMPORT_SOURCE =
400
+ "@honeydeck/honeydeck/components/code-block/magic";
235
401
 
236
402
  /**
237
403
  * Async remark plugin that transforms fenced code blocks to `<HoneydeckCodeBlock>`
@@ -239,87 +405,114 @@ const IMPORT_SOURCE = "@honeydeck/honeydeck/components/code-block";
239
405
  *
240
406
  * Plugin ordering: `[remarkFrontmatter, remarkH1Extract, remarkStepNumbering, remarkShikiCodeBlocks]`
241
407
  */
242
- export const remarkShikiCodeBlocks: Plugin<[], Root> = () => async (tree) => {
243
- // Collect code nodes before mutating the tree (avoids iterator invalidation).
244
- type CodeEntry = {
245
- node: Code;
246
- index: number;
247
- parent: { children: unknown[] };
248
- };
249
- const codeEntries: CodeEntry[] = [];
250
-
251
- visit(tree, "code", (node, index, parent) => {
252
- if (index !== null && index !== undefined && parent) {
253
- codeEntries.push({
254
- node: node as unknown as Code,
255
- index: index as number,
256
- parent: parent as unknown as { children: unknown[] },
257
- });
258
- }
259
- });
408
+ export const remarkShikiCodeBlocks: Plugin<
409
+ [RemarkShikiCodeBlocksOptions?],
410
+ Root
411
+ > =
412
+ (options = {}) =>
413
+ async (tree) => {
414
+ // Collect code nodes before mutating the tree.
415
+ type CodeEntry = {
416
+ node: Code;
417
+ parent: { children: unknown[] };
418
+ };
419
+ const codeEntries: CodeEntry[] = [];
420
+
421
+ visit(tree, "code", (node, index, parent) => {
422
+ if (index !== null && index !== undefined && parent) {
423
+ codeEntries.push({
424
+ node: node as unknown as Code,
425
+ parent: parent as unknown as { children: unknown[] },
426
+ });
427
+ }
428
+ });
260
429
 
261
- if (codeEntries.length === 0) return;
262
-
263
- const highlighter = await getHighlighter();
264
- let didTransform = false;
265
-
266
- for (const { node, index, parent } of codeEntries) {
267
- const rawLang = node.lang ?? "text";
268
- const meta = node.meta;
269
- const steps = parseStepMeta(meta);
270
- const startAt: number =
271
- ((node.data as Record<string, unknown> | undefined)
272
- ?.honeydeckStartAt as number) ?? 0;
273
-
274
- // Load language grammar (no-op if already cached)
275
- const lang = await loadLang(highlighter, rawLang);
276
-
277
- // Highlight with dual themes (CSS variable approach)
278
- let html: string;
279
- try {
280
- html = highlighter.codeToHtml(node.value, {
281
- lang,
282
- themes: { light: "github-light", dark: "github-dark" },
283
- defaultColor: false,
284
- transformers: [
285
- {
286
- line(lineNode, lineNumber) {
287
- // Ensure properties bag exists, then stamp data-line
288
- if (!lineNode.properties) lineNode.properties = {};
289
- lineNode.properties["data-line"] = lineNumber;
290
- },
291
- },
292
- ],
293
- });
294
- } catch {
295
- // Fallback: plain pre/code block without syntax colours
296
- const escaped = node.value
297
- .replace(/&/g, "&amp;")
298
- .replace(/</g, "&lt;")
299
- .replace(/>/g, "&gt;");
300
- html = `<pre class="honeydeck-code-plain"><code>${escaped}</code></pre>`;
301
- }
430
+ if (codeEntries.length === 0) return;
302
431
 
303
- // Build <HoneydeckCodeBlock html="..." stepsJson="..." startAt={N} source="..." />
304
- const jsxNode: MdxJsxFlowElement = {
305
- type: "mdxJsxFlowElement",
306
- name: COMPONENT_NAME,
307
- attributes: [
308
- makeStringAttr("html", html),
309
- makeStringAttr("stepsJson", JSON.stringify(steps)),
310
- makeNumericAttr("startAt", startAt),
311
- makeStringAttr("source", node.value),
312
- ],
313
- children: [],
314
- };
432
+ const highlighter = await getHighlighter();
433
+ let didTransformCodeBlock = false;
434
+ let didTransformMagicCode = false;
315
435
 
316
- // Replace code node in-place (1 → 1, so sibling indices stay valid)
317
- parent.children.splice(index, 1, jsxNode);
318
- didTransform = true;
319
- }
436
+ for (const { node, parent } of codeEntries) {
437
+ const currentIndex = parent.children.indexOf(node);
438
+ if (currentIndex === -1) continue;
320
439
 
321
- // Inject the import once for the whole slide
322
- if (didTransform) {
323
- injectImport(tree, COMPONENT_NAME, IMPORT_SOURCE);
324
- }
325
- };
440
+ const startAt: number =
441
+ ((node.data as Record<string, unknown> | undefined)
442
+ ?.honeydeckStartAt as number) ?? 0;
443
+
444
+ if (isMagicCodeFence(node)) {
445
+ const fences = parseInnerCodeFences(node.value);
446
+ const duration = parseMagicCodeDuration(
447
+ node.meta,
448
+ options.magicCodeDuration,
449
+ );
450
+ if (!duration.ok) throw new Error(duration.message);
451
+
452
+ if (fences.length === 0) {
453
+ parent.children.splice(currentIndex, 1);
454
+ continue;
455
+ }
456
+
457
+ if (fences.length === 1) {
458
+ const [fence] = fences;
459
+ if (!fence) continue;
460
+ const html = await codeToHighlightedHtml(
461
+ highlighter,
462
+ fence.value,
463
+ fence.lang,
464
+ );
465
+ parent.children.splice(
466
+ currentIndex,
467
+ 1,
468
+ makeCodeBlockNode(
469
+ html,
470
+ parseStepMeta(fence.meta),
471
+ startAt,
472
+ fence.value,
473
+ ),
474
+ );
475
+ didTransformCodeBlock = true;
476
+ continue;
477
+ }
478
+
479
+ const states = flattenMagicTimeline(fences);
480
+ const tokenPayload = await compileMagicTokenPayload(
481
+ highlighter,
482
+ states,
483
+ );
484
+ parent.children.splice(
485
+ currentIndex,
486
+ 1,
487
+ makeMagicCodeNode(
488
+ tokenPayload,
489
+ states.map((state) => state.group),
490
+ startAt,
491
+ duration.duration,
492
+ ),
493
+ );
494
+ didTransformMagicCode = true;
495
+ continue;
496
+ }
497
+
498
+ const rawLang = node.lang ?? "text";
499
+ const html = await codeToHighlightedHtml(
500
+ highlighter,
501
+ node.value,
502
+ rawLang,
503
+ );
504
+ parent.children.splice(
505
+ currentIndex,
506
+ 1,
507
+ makeCodeBlockNode(html, parseStepMeta(node.meta), startAt, node.value),
508
+ );
509
+ didTransformCodeBlock = true;
510
+ }
511
+
512
+ if (didTransformCodeBlock) {
513
+ injectImport(tree, CODE_BLOCK_COMPONENT_NAME, CODE_BLOCK_IMPORT_SOURCE);
514
+ }
515
+ if (didTransformMagicCode) {
516
+ injectImport(tree, MAGIC_CODE_COMPONENT_NAME, MAGIC_CODE_IMPORT_SOURCE);
517
+ }
518
+ };