@bendyline/squisq 0.1.1

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 (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Doc → Markdown Conversion
3
+ *
4
+ * Converts a Doc with a heading-driven Block hierarchy back into a
5
+ * MarkdownDocument. This is the reverse of markdownToDoc() and enables
6
+ * round-tripping: edit a Doc's block tree, then serialize back to markdown.
7
+ *
8
+ * **Algorithm:**
9
+ * Walk the block tree depth-first. For each block:
10
+ * 1. If it has a `sourceHeading`, emit that heading node
11
+ * 2. Emit all nodes in `contents`
12
+ * 3. Recurse into `children`
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { markdownToDoc, docToMarkdown } from '@bendyline/squisq/doc';
17
+ * import { parseMarkdown, stringifyMarkdown } from '@bendyline/squisq/markdown';
18
+ *
19
+ * const md = '# Hello\n\nWorld\n';
20
+ * const doc = markdownToDoc(parseMarkdown(md));
21
+ * const roundTripped = stringifyMarkdown(docToMarkdown(doc));
22
+ * ```
23
+ */
24
+
25
+ import type { Doc, Block } from '../schemas/Doc.js';
26
+ import type { MarkdownDocument, MarkdownBlockNode, MarkdownHeading } from '../markdown/types.js';
27
+
28
+ /**
29
+ * Convert a Doc with heading-driven blocks back to a MarkdownDocument.
30
+ *
31
+ * Walks the block tree depth-first, emitting heading nodes and contents
32
+ * in document order. Blocks without a `sourceHeading` (preamble blocks)
33
+ * emit only their contents.
34
+ *
35
+ * If a block has a `template` or `templateOverrides` that aren't already
36
+ * reflected in the heading's `templateAnnotation`, the annotation is
37
+ * injected so the round-trip preserves template assignments.
38
+ *
39
+ * @param doc - A Doc whose blocks may have `sourceHeading`, `contents`, and `children`
40
+ * @returns A MarkdownDocument that can be stringified back to markdown
41
+ */
42
+ export function docToMarkdown(doc: Doc): MarkdownDocument {
43
+ const children: MarkdownBlockNode[] = [];
44
+
45
+ function emitBlock(block: Block): void {
46
+ // Emit the heading node if present
47
+ if (block.sourceHeading) {
48
+ const heading = ensureAnnotation(block, block.sourceHeading);
49
+ children.push(heading);
50
+ }
51
+
52
+ // Emit body content
53
+ if (block.contents) {
54
+ children.push(...block.contents);
55
+ }
56
+
57
+ // Recurse into children (sub-headings)
58
+ if (block.children) {
59
+ for (const child of block.children) {
60
+ emitBlock(child);
61
+ }
62
+ }
63
+ }
64
+
65
+ for (const block of doc.blocks) {
66
+ emitBlock(block);
67
+ }
68
+
69
+ return {
70
+ type: 'document',
71
+ children,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Ensure the heading's `templateAnnotation` reflects the block's
77
+ * template and templateOverrides. Returns a (possibly cloned) heading.
78
+ */
79
+ function ensureAnnotation(block: Block, heading: MarkdownHeading): MarkdownHeading {
80
+ // If the heading already has an annotation, trust it (it came from parsing)
81
+ if (heading.templateAnnotation) return heading;
82
+
83
+ // If the block has a non-default template or overrides, inject an annotation
84
+ const hasExplicitTemplate = block.template && block.template !== 'sectionHeader';
85
+ const hasOverrides = block.templateOverrides && Object.keys(block.templateOverrides).length > 0;
86
+
87
+ if (!hasExplicitTemplate && !hasOverrides) return heading;
88
+
89
+ // Clone to avoid mutating the original
90
+ return {
91
+ ...heading,
92
+ children: [...heading.children],
93
+ templateAnnotation: {
94
+ template: block.template ?? 'sectionHeader',
95
+ ...(hasOverrides ? { params: block.templateOverrides } : {}),
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * getLayers — Compute visual layers for a block on demand.
3
+ *
4
+ * This is the preferred way to obtain renderable layers for a block.
5
+ * Instead of storing pre-computed layers on the Block object, call
6
+ * `getLayers(block, context)` to derive them from the block's template
7
+ * name, content, and the current render context (theme, viewport, etc.).
8
+ *
9
+ * For raw blocks that already carry a `layers` array, those layers are
10
+ * returned directly (with optional persistent layer injection).
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { getLayers } from '@bendyline/squisq/doc';
15
+ *
16
+ * const layers = getLayers(block, {
17
+ * theme: DEFAULT_THEME,
18
+ * viewport: VIEWPORT_PRESETS.landscape,
19
+ * blockIndex: 0,
20
+ * totalBlocks: 10,
21
+ * });
22
+ * ```
23
+ */
24
+
25
+ import type { Block, Layer } from '../schemas/Doc.js';
26
+ import type {
27
+ TemplateBlock,
28
+ TemplateContext,
29
+ PersistentLayerConfig,
30
+ DocBlock,
31
+ } from '../schemas/BlockTemplates.js';
32
+ import type { Theme } from '../schemas/Theme.js';
33
+ import type { ViewportConfig } from '../schemas/Viewport.js';
34
+ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
35
+ import { createTemplateContext, isTemplateBlock } from '../schemas/BlockTemplates.js';
36
+ import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
37
+ import { templateRegistry } from './templates/index.js';
38
+ import { expandPersistentLayers } from './templates/persistentLayers.js';
39
+
40
+ // ============================================
41
+ // RenderContext
42
+ // ============================================
43
+
44
+ /**
45
+ * Context needed to compute layers for a block.
46
+ *
47
+ * Captures the visual rendering parameters: theme colors, viewport
48
+ * configuration, persistent layers, and the block's position within
49
+ * the doc (for template functions that vary output by index).
50
+ */
51
+ export interface RenderContext {
52
+ /** Theme for template rendering. Defaults to DEFAULT_THEME (documentary). */
53
+ theme?: Theme;
54
+
55
+ /** Target viewport configuration. Defaults to 16:9 landscape. */
56
+ viewport?: ViewportConfig;
57
+
58
+ /** Persistent layers injected behind and/or on top of all block content. */
59
+ persistentLayers?: PersistentLayerConfig;
60
+
61
+ /** 0-based index of this block in the sequence. Defaults to 0. */
62
+ blockIndex?: number;
63
+
64
+ /** Total number of blocks in the doc. Defaults to 1. */
65
+ totalBlocks?: number;
66
+ }
67
+
68
+ // ============================================
69
+ // getLayers
70
+ // ============================================
71
+
72
+ /**
73
+ * Compute the visual layers for a block.
74
+ *
75
+ * Resolution order:
76
+ * 1. If the block already has a non-empty `layers` array, use it (raw block).
77
+ * 2. If the block has a `template` name that exists in the registry,
78
+ * call the template function to generate layers.
79
+ * 3. Otherwise return an empty array.
80
+ *
81
+ * Persistent layers (bottom/top) from the render context are injected
82
+ * around the result unless the block opts out via `useBottomLayer: false`
83
+ * or `useTopLayer: false`.
84
+ *
85
+ * @param block A Block or TemplateBlock to render.
86
+ * @param context Render context (theme, viewport, persistent layers, position).
87
+ * @returns The computed Layer[] for the block, ready for BlockRenderer.
88
+ */
89
+ export function getLayers(block: DocBlock, context: RenderContext = {}): Layer[] {
90
+ const theme = context.theme ?? DEFAULT_THEME;
91
+ const viewport = context.viewport ?? VIEWPORT_PRESETS.landscape;
92
+ const blockIndex = context.blockIndex ?? 0;
93
+ const totalBlocks = context.totalBlocks ?? 1;
94
+
95
+ // 1. Raw block path: block already has pre-computed layers
96
+ const existingLayers = (block as Block).layers;
97
+ if (existingLayers && existingLayers.length > 0 && !isTemplateBlock(block)) {
98
+ return injectPersistentLayers(existingLayers, block, context);
99
+ }
100
+
101
+ // 2. Template path: look up and call the template function
102
+ if (isTemplateBlock(block) && block.template in templateRegistry) {
103
+ const templateName = block.template as keyof typeof templateRegistry;
104
+ const templateCtx = createTemplateContext(theme, blockIndex, totalBlocks, viewport);
105
+ let layers: Layer[];
106
+ try {
107
+ // Each registry entry accepts its specific TemplateBlock variant; the
108
+ // discriminated union guarantees the shapes match at runtime.
109
+ const templateFn = templateRegistry[templateName] as (
110
+ input: TemplateBlock,
111
+ ctx: TemplateContext,
112
+ ) => Layer[];
113
+ layers = templateFn(block, templateCtx);
114
+ if (!Array.isArray(layers)) {
115
+ console.error(`Template ${templateName} did not return an array, got:`, typeof layers);
116
+ layers = [];
117
+ }
118
+ } catch (err: unknown) {
119
+ console.error(`Error expanding template ${templateName}:`, err);
120
+ layers = [];
121
+ }
122
+
123
+ return injectPersistentLayers(layers, block, context);
124
+ }
125
+
126
+ // 3. Fallback: no layers and no known template
127
+ return injectPersistentLayers([], block, context);
128
+ }
129
+
130
+ // ============================================
131
+ // Internal helpers
132
+ // ============================================
133
+
134
+ /**
135
+ * Inject persistent bottom/top layers around the block's own layers,
136
+ * respecting per-block opt-out flags.
137
+ */
138
+ function injectPersistentLayers(layers: Layer[], block: DocBlock, context: RenderContext): Layer[] {
139
+ const { persistentLayers } = context;
140
+ if (!persistentLayers) return layers;
141
+
142
+ const bottomLayers = expandPersistentLayers(persistentLayers.bottomLayers);
143
+ const topLayers = expandPersistentLayers(persistentLayers.topLayers);
144
+
145
+ if (bottomLayers.length === 0 && topLayers.length === 0) return layers;
146
+
147
+ const templateBlock = block as TemplateBlock;
148
+ const useBottom = templateBlock.useBottomLayer !== false;
149
+ const useTop = templateBlock.useTopLayer !== false;
150
+
151
+ return [...(useBottom ? bottomLayers : []), ...layers, ...(useTop ? topLayers : [])];
152
+ }
@@ -0,0 +1,8 @@
1
+ export * from './templates/index.js';
2
+ export * from './utils/animationUtils.js';
3
+ export * from './utils/themeUtils.js';
4
+ export { markdownToDoc, flattenBlocks, countBlocks, getBlockDepth } from './markdownToDoc.js';
5
+ export type { MarkdownToDocOptions } from './markdownToDoc.js';
6
+ export { docToMarkdown } from './docToMarkdown.js';
7
+ export { getLayers } from './getLayers.js';
8
+ export type { RenderContext } from './getLayers.js';
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Markdown → Doc Conversion
3
+ *
4
+ * Converts a MarkdownDocument into a hierarchical Doc whose Block tree
5
+ * mirrors the heading structure of the markdown. Every heading (H1–H6)
6
+ * becomes a Block; body content between headings populates `contents`;
7
+ * sub-headings nest as `children`.
8
+ *
9
+ * This enables using BlockTemplates to supply alternate visualizations
10
+ * for each section of a markdown document.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { parseMarkdown } from '@bendyline/squisq/markdown';
15
+ * import { markdownToDoc } from '@bendyline/squisq/doc';
16
+ *
17
+ * const md = parseMarkdown('# Intro\n\nHello world\n\n## Details\n\nMore text');
18
+ * const doc = markdownToDoc(md);
19
+ * // doc.blocks[0].sourceHeading.depth === 1 ("# Intro")
20
+ * // doc.blocks[0].contents → [paragraph("Hello world")]
21
+ * // doc.blocks[0].children[0].sourceHeading.depth === 2 ("## Details")
22
+ * ```
23
+ */
24
+
25
+ import type { Doc, Block } from '../schemas/Doc.js';
26
+ import type { MarkdownDocument, MarkdownBlockNode, MarkdownHeading } from '../markdown/types.js';
27
+ import { extractPlainText } from '../markdown/utils.js';
28
+
29
+ // ============================================
30
+ // Options
31
+ // ============================================
32
+
33
+ /**
34
+ * Options for markdownToDoc().
35
+ */
36
+ export interface MarkdownToDocOptions {
37
+ /** Article ID for the generated Doc. Default: 'markdown-doc' */
38
+ articleId?: string;
39
+
40
+ /** Default template name for heading blocks. Default: 'sectionHeader' */
41
+ defaultTemplate?: string;
42
+
43
+ /** Default duration per block in seconds. Default: 5 */
44
+ defaultDuration?: number;
45
+
46
+ /** Custom ID generator. Receives the heading node and its index. */
47
+ generateId?: (heading: MarkdownHeading, index: number) => string;
48
+ }
49
+
50
+ // ============================================
51
+ // ID Generation
52
+ // ============================================
53
+
54
+ /**
55
+ * Convert text to a URL-friendly slug.
56
+ * "Getting Started & More" → "getting-started-more"
57
+ */
58
+ const SLUG_NON_WORD_RE = /[^\w\s-]/g;
59
+ const SLUG_SPACES_RE = /[\s_]+/g;
60
+ const SLUG_MULTI_HYPHEN_RE = /-+/g;
61
+ const SLUG_TRIM_HYPHEN_RE = /^-|-$/g;
62
+
63
+ function slugify(text: string): string {
64
+ return (
65
+ text
66
+ .toLowerCase()
67
+ .replace(SLUG_NON_WORD_RE, '') // Remove non-word chars (except spaces and hyphens)
68
+ .replace(SLUG_SPACES_RE, '-') // Replace spaces/underscores with hyphens
69
+ .replace(SLUG_MULTI_HYPHEN_RE, '-') // Collapse multiple hyphens
70
+ .replace(SLUG_TRIM_HYPHEN_RE, '') || // Trim leading/trailing hyphens
71
+ 'block'
72
+ ); // Fallback for empty result
73
+ }
74
+
75
+ /**
76
+ * Creates an ID generator that produces unique slugified IDs.
77
+ * Appends -2, -3, etc. for duplicate headings.
78
+ */
79
+ function createIdGenerator() {
80
+ const used = new Map<string, number>();
81
+
82
+ return (heading: MarkdownHeading, _index: number): string => {
83
+ const text = extractPlainText(heading);
84
+ const base = slugify(text);
85
+ const count = used.get(base) ?? 0;
86
+ used.set(base, count + 1);
87
+
88
+ if (count === 0) return base;
89
+ return `${base}-${count + 1}`;
90
+ };
91
+ }
92
+
93
+ // ============================================
94
+ // Core Conversion
95
+ // ============================================
96
+
97
+ /**
98
+ * Convert a MarkdownDocument into a Doc with a heading-driven Block hierarchy.
99
+ *
100
+ * **Algorithm:**
101
+ * 1. Walk the document's top-level children sequentially
102
+ * 2. Each MarkdownHeading starts a new block at its depth level
103
+ * 3. Non-heading nodes accumulate as `contents` on the current block
104
+ * 4. Sub-headings (deeper depth) nest as `children` of the current block
105
+ * 5. Same-level or shallower headings close the current block and start a sibling/ancestor
106
+ * 6. Content before the first heading goes into a "preamble" block with no sourceHeading
107
+ *
108
+ * @param markdownDoc - A parsed MarkdownDocument
109
+ * @param options - Conversion options
110
+ * @returns A Doc whose blocks mirror the markdown heading structure
111
+ */
112
+ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
113
+ const articleId = options?.articleId ?? 'markdown-doc';
114
+ const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
115
+ const defaultDuration = options?.defaultDuration ?? 5;
116
+ const generateId = options?.generateId ?? createIdGenerator();
117
+
118
+ const rootBlocks: Block[] = [];
119
+ let headingIndex = 0;
120
+
121
+ // Stack tracks the nesting context: each entry is a block and its heading depth.
122
+ // We push when we go deeper, pop when we come back up.
123
+ const stack: Array<{ block: Block; depth: number }> = [];
124
+
125
+ // Accumulator for content nodes that appear before any heading (preamble)
126
+ // or between the current heading and the next heading/sub-heading.
127
+ let pendingContents: MarkdownBlockNode[] = [];
128
+ let currentBlock: Block | null = null;
129
+
130
+ function flushContents() {
131
+ if (currentBlock && pendingContents.length > 0) {
132
+ currentBlock.contents = (currentBlock.contents ?? []).concat(pendingContents);
133
+ pendingContents = [];
134
+ }
135
+ }
136
+
137
+ function makeBlock(heading: MarkdownHeading | null): Block {
138
+ const id = heading ? generateId(heading, headingIndex++) : 'preamble';
139
+
140
+ // Use template from annotation if present, otherwise fall back to default
141
+ const annotation = heading?.templateAnnotation;
142
+ const template = annotation?.template ?? (heading ? defaultTemplate : undefined);
143
+
144
+ const block: Block = {
145
+ id,
146
+ startTime: 0,
147
+ duration: defaultDuration,
148
+ audioSegment: 0,
149
+ template,
150
+ ...(heading ? { sourceHeading: heading } : {}),
151
+ };
152
+
153
+ // Propagate key-value params from annotation to templateOverrides
154
+ if (annotation?.params) {
155
+ block.templateOverrides = annotation.params;
156
+ }
157
+
158
+ return block;
159
+ }
160
+
161
+ for (const node of markdownDoc.children) {
162
+ if (node.type === 'heading') {
163
+ const heading = node as MarkdownHeading;
164
+ const depth = heading.depth;
165
+
166
+ // Flush any accumulated content to the current block
167
+ flushContents();
168
+
169
+ // Create the new block for this heading
170
+ const newBlock = makeBlock(heading);
171
+
172
+ if (stack.length === 0) {
173
+ // If there's a preamble block with no heading, push it first
174
+ if (currentBlock && !currentBlock.sourceHeading) {
175
+ rootBlocks.push(currentBlock);
176
+ }
177
+
178
+ // This is a root-level block (or the first heading)
179
+ rootBlocks.push(newBlock);
180
+ stack.push({ block: newBlock, depth });
181
+ } else {
182
+ // Find where this heading fits in the hierarchy
183
+ // Pop blocks from the stack until we find a parent with lower depth
184
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
185
+ stack.pop();
186
+ }
187
+
188
+ if (stack.length === 0) {
189
+ // Same level as root or shallower — new root block
190
+ rootBlocks.push(newBlock);
191
+ stack.push({ block: newBlock, depth });
192
+ } else {
193
+ // Deeper — child of the current stack top
194
+ const parent = stack[stack.length - 1].block;
195
+ if (!parent.children) parent.children = [];
196
+ parent.children.push(newBlock);
197
+ stack.push({ block: newBlock, depth });
198
+ }
199
+ }
200
+
201
+ currentBlock = newBlock;
202
+ } else {
203
+ // Non-heading block node — accumulate as content
204
+ if (!currentBlock) {
205
+ // Content before any heading → create preamble block
206
+ currentBlock = makeBlock(null);
207
+ }
208
+ pendingContents.push(node);
209
+ }
210
+ }
211
+
212
+ // Flush remaining content
213
+ flushContents();
214
+
215
+ // If we only had preamble content (no headings at all), push it
216
+ if (currentBlock && !currentBlock.sourceHeading && !rootBlocks.includes(currentBlock)) {
217
+ rootBlocks.push(currentBlock);
218
+ }
219
+
220
+ // Calculate basic timing
221
+ const allBlocks = flattenBlocks(rootBlocks);
222
+ let currentTime = 0;
223
+ for (const block of allBlocks) {
224
+ block.startTime = currentTime;
225
+ currentTime += block.duration;
226
+ }
227
+
228
+ return {
229
+ articleId,
230
+ duration: currentTime,
231
+ blocks: rootBlocks,
232
+ audio: {
233
+ segments: [],
234
+ },
235
+ ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
236
+ };
237
+ }
238
+
239
+ // ============================================
240
+ // Utilities
241
+ // ============================================
242
+
243
+ /**
244
+ * Flatten a nested block tree into a depth-first ordered array.
245
+ * Useful for calculating sequential timing or iterating all blocks.
246
+ */
247
+ export function flattenBlocks(blocks: Block[]): Block[] {
248
+ const result: Block[] = [];
249
+ for (const block of blocks) {
250
+ result.push(block);
251
+ if (block.children) {
252
+ result.push(...flattenBlocks(block.children));
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * Count the total number of blocks in a nested tree (including children at all levels).
260
+ */
261
+ export function countBlocks(blocks: Block[]): number {
262
+ let count = 0;
263
+ for (const block of blocks) {
264
+ count += 1;
265
+ if (block.children) {
266
+ count += countBlocks(block.children);
267
+ }
268
+ }
269
+ return count;
270
+ }
271
+
272
+ /**
273
+ * Get the heading depth for a block. Returns 0 for preamble blocks (no heading).
274
+ */
275
+ export function getBlockDepth(block: Block): number {
276
+ return block.sourceHeading?.depth ?? 0;
277
+ }