@bendyline/squisq 1.5.2 → 2.0.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 (263) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  6. package/dist/chunk-3E5F2XMR.js.map +1 -0
  7. package/dist/chunk-4X3JQXNM.js +35 -0
  8. package/dist/chunk-4X3JQXNM.js.map +1 -0
  9. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  10. package/dist/chunk-5DKBFXPY.js.map +1 -0
  11. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  12. package/dist/chunk-5J4HKRJF.js.map +1 -0
  13. package/dist/chunk-5UYI3RJB.js +2503 -0
  14. package/dist/chunk-5UYI3RJB.js.map +1 -0
  15. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  16. package/dist/chunk-66QO7XFB.js.map +1 -0
  17. package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
  18. package/dist/chunk-6PJRB2CF.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-FVJUUTEM.js +74 -0
  22. package/dist/chunk-FVJUUTEM.js.map +1 -0
  23. package/dist/{chunk-5TYS5V3G.js → chunk-GF3BVNLU.js} +4127 -3538
  24. package/dist/chunk-GF3BVNLU.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
  30. package/dist/chunk-LYNJEOAC.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
  36. package/dist/chunk-QTSJOY54.js.map +1 -0
  37. package/dist/chunk-QVANWDPH.js +2358 -0
  38. package/dist/chunk-QVANWDPH.js.map +1 -0
  39. package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  40. package/dist/chunk-R2GC6RLD.js.map +1 -0
  41. package/dist/chunk-R2YZTCLO.js +292 -0
  42. package/dist/chunk-R2YZTCLO.js.map +1 -0
  43. package/dist/chunk-S7A5D7ZJ.js +174 -0
  44. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  45. package/dist/chunk-T3TI7GSZ.js +884 -0
  46. package/dist/chunk-T3TI7GSZ.js.map +1 -0
  47. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  48. package/dist/chunk-TUWMK62B.js.map +1 -0
  49. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  50. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +127 -33
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +246 -80
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  97. package/src/__tests__/dataTable.test.ts +25 -15
  98. package/src/__tests__/diagramBlock.test.ts +3 -3
  99. package/src/__tests__/diagramText.test.ts +26 -0
  100. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  101. package/src/__tests__/drawingBlock.test.ts +3 -3
  102. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  103. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  104. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  105. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  106. package/src/__tests__/getLayers.test.ts +32 -18
  107. package/src/__tests__/getLayersFallback.test.ts +8 -8
  108. package/src/__tests__/imageEditVersions.test.ts +2 -0
  109. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  110. package/src/__tests__/layoutBlock.test.ts +3 -3
  111. package/src/__tests__/majorApiTypes.test.ts +18 -0
  112. package/src/__tests__/markdown.test.ts +41 -0
  113. package/src/__tests__/matchFontFamily.test.ts +114 -0
  114. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  115. package/src/__tests__/materializeTestUtils.ts +17 -0
  116. package/src/__tests__/narrationAlign.test.ts +150 -0
  117. package/src/__tests__/narrationFeatures.test.ts +71 -0
  118. package/src/__tests__/narrationNuclei.test.ts +45 -0
  119. package/src/__tests__/narrationPacing.test.ts +189 -0
  120. package/src/__tests__/narrationScript.test.ts +149 -0
  121. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  122. package/src/__tests__/narrationSidecar.test.ts +88 -0
  123. package/src/__tests__/narrationSyllables.test.ts +65 -0
  124. package/src/__tests__/narrationTestSignals.ts +167 -0
  125. package/src/__tests__/narrationVad.test.ts +66 -0
  126. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  127. package/src/__tests__/rootExports.test.ts +18 -0
  128. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  129. package/src/__tests__/shapeGeometry.test.ts +12 -0
  130. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  131. package/src/__tests__/templateInputs.test.ts +69 -0
  132. package/src/__tests__/templateMetadata.test.ts +2 -1
  133. package/src/__tests__/templates.test.ts +143 -17
  134. package/src/__tests__/themeCompile.test.ts +49 -22
  135. package/src/__tests__/transformProvenance.test.ts +160 -0
  136. package/src/__tests__/transformV2.test.ts +40 -14
  137. package/src/__tests__/treeviewBattle.test.ts +76 -0
  138. package/src/__tests__/treeviewDetect.test.ts +168 -0
  139. package/src/__tests__/treeviewParse.test.ts +118 -0
  140. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  141. package/src/__tests__/treeviewRender.test.ts +91 -0
  142. package/src/__tests__/versions.test.ts +44 -0
  143. package/src/doc/applyNarrationTiming.ts +244 -0
  144. package/src/doc/asciiDiagram/chars.ts +210 -0
  145. package/src/doc/asciiDiagram/detect.ts +175 -0
  146. package/src/doc/asciiDiagram/index.ts +38 -0
  147. package/src/doc/asciiDiagram/mapping.ts +225 -0
  148. package/src/doc/asciiDiagram/parse.ts +1095 -0
  149. package/src/doc/asciiDiagram/render.ts +963 -0
  150. package/src/doc/asciiDiagram/repair.ts +400 -0
  151. package/src/doc/asciiDiagram/types.ts +65 -0
  152. package/src/doc/asciiTimeline/detect.ts +138 -0
  153. package/src/doc/asciiTimeline/index.ts +26 -0
  154. package/src/doc/asciiTimeline/mapping.ts +43 -0
  155. package/src/doc/asciiTimeline/parse.ts +870 -0
  156. package/src/doc/asciiTimeline/render.ts +427 -0
  157. package/src/doc/asciiTimeline/types.ts +73 -0
  158. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  159. package/src/doc/audioMapping.ts +60 -46
  160. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  161. package/src/doc/docToMarkdown.ts +70 -2
  162. package/src/doc/index.ts +76 -4
  163. package/src/doc/markdownToDoc.ts +135 -1
  164. package/src/doc/materializeBlockLayers.ts +304 -0
  165. package/src/doc/mediaAnnotations.ts +20 -0
  166. package/src/doc/resolveDocTheme.ts +8 -7
  167. package/src/doc/templateInputs.ts +185 -2
  168. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  169. package/src/doc/templates/diagramBlock.ts +237 -38
  170. package/src/doc/templates/fallbackBlock.ts +1 -1
  171. package/src/doc/templates/index.ts +134 -279
  172. package/src/doc/templates/inputDescriptors.ts +20 -1
  173. package/src/doc/templates/metadata.ts +10 -0
  174. package/src/doc/templates/persistentLayers.ts +12 -126
  175. package/src/doc/templates/registry.ts +88 -0
  176. package/src/doc/templates/templateNames.ts +22 -0
  177. package/src/doc/templates/timelineBlock.ts +613 -0
  178. package/src/doc/templates/treeBlock.ts +131 -0
  179. package/src/doc/treeview/chars.ts +72 -0
  180. package/src/doc/treeview/detect.ts +145 -0
  181. package/src/doc/treeview/index.ts +30 -0
  182. package/src/doc/treeview/mapping.ts +95 -0
  183. package/src/doc/treeview/parse.ts +201 -0
  184. package/src/doc/treeview/render.ts +41 -0
  185. package/src/doc/treeview/types.ts +43 -0
  186. package/src/doc/utils/applyRenderStyle.ts +2 -9
  187. package/src/doc/utils/diagramText.ts +78 -0
  188. package/src/doc/utils/shapeGeometry.ts +67 -5
  189. package/src/doc/validate.ts +1 -1
  190. package/src/generate/templateMapper.ts +7 -0
  191. package/src/imageEdit/export.ts +6 -5
  192. package/src/imageEdit/versionPaths.ts +13 -26
  193. package/src/imageEdit/versions.ts +21 -100
  194. package/src/index.ts +1 -0
  195. package/src/internal/immutable.ts +58 -0
  196. package/src/jsonForm/index.ts +9 -1
  197. package/src/jsonForm/pointer.ts +68 -23
  198. package/src/markdown/attrTokens.ts +7 -5
  199. package/src/markdown/index.ts +1 -0
  200. package/src/markdown/stringify.ts +4 -9
  201. package/src/markdown/utils.ts +36 -14
  202. package/src/narration/align.ts +514 -0
  203. package/src/narration/features.ts +231 -0
  204. package/src/narration/index.ts +86 -0
  205. package/src/narration/nuclei.ts +118 -0
  206. package/src/narration/pacing.ts +211 -0
  207. package/src/narration/script.ts +239 -0
  208. package/src/narration/session.ts +100 -0
  209. package/src/narration/sidecar.ts +187 -0
  210. package/src/narration/syllables.ts +85 -0
  211. package/src/narration/trace.ts +50 -0
  212. package/src/narration/types.ts +299 -0
  213. package/src/narration/vad.ts +131 -0
  214. package/src/recommend/numberHighlight.ts +25 -0
  215. package/src/recommend/templates.ts +108 -12
  216. package/src/schemas/BlockTemplates.ts +156 -7
  217. package/src/schemas/Doc.ts +72 -9
  218. package/src/schemas/Media.ts +12 -2
  219. package/src/schemas/Theme.ts +45 -25
  220. package/src/schemas/Viewport.ts +2 -2
  221. package/src/schemas/fontStacks.ts +79 -0
  222. package/src/schemas/themeCompile.ts +15 -0
  223. package/src/schemas/themeConstants.ts +2 -0
  224. package/src/schemas/themeLibrary.ts +25 -19
  225. package/src/schemas/themeValidator.ts +1 -1
  226. package/src/spatial/Geohash.ts +0 -3
  227. package/src/storage/ContentContainer.ts +17 -9
  228. package/src/storage/ScopedContentContainer.ts +45 -15
  229. package/src/transform/applyTransform.ts +5 -5
  230. package/src/transform/index.ts +3 -1
  231. package/src/transform/registry.ts +169 -49
  232. package/src/transform/templateSelector.ts +21 -2
  233. package/src/transform/timingAllocator.ts +152 -21
  234. package/src/transform/types.ts +14 -1
  235. package/src/versions/gitignore.ts +34 -0
  236. package/src/versions/index.ts +2 -0
  237. package/src/versions/operations.ts +18 -104
  238. package/src/versions/paths.ts +13 -28
  239. package/src/versions/snapshotStore.ts +202 -0
  240. package/src/versions/types.ts +3 -1
  241. package/dist/chunk-5TYS5V3G.js.map +0 -1
  242. package/dist/chunk-A4O7GIWE.js.map +0 -1
  243. package/dist/chunk-BEQODJWV.js.map +0 -1
  244. package/dist/chunk-DLXZMS5K.js.map +0 -1
  245. package/dist/chunk-GPLTCSXO.js.map +0 -1
  246. package/dist/chunk-HWVFJAAH.js.map +0 -1
  247. package/dist/chunk-IMSCRLLV.js.map +0 -1
  248. package/dist/chunk-JELIRVDP.js +0 -24
  249. package/dist/chunk-JELIRVDP.js.map +0 -1
  250. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  251. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  252. package/dist/chunk-LTN6I7QW.js.map +0 -1
  253. package/dist/chunk-P3K7NLHF.js.map +0 -1
  254. package/dist/chunk-T5UK6YOB.js.map +0 -1
  255. package/dist/chunk-UY7KGQ5R.js +0 -192
  256. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  257. package/dist/chunk-VLJJHDAC.js +0 -218
  258. package/dist/chunk-VLJJHDAC.js.map +0 -1
  259. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  260. package/dist/story/index.d.ts +0 -1801
  261. package/dist/story/index.js +0 -279
  262. package/src/doc/getLayers.ts +0 -195
  263. /package/dist/{story → narration}/index.js.map +0 -0
@@ -33,6 +33,24 @@ export function allocateTiming(
33
33
  ): Block[] {
34
34
  if (blocks.length === 0) return [];
35
35
 
36
+ // Template blocks carrying a numeric `sourceStartTime` are ANCHORED to
37
+ // the doc timeline — when the source doc is narration-timed
38
+ // (`applyNarrationTiming`), those anchors are where the words are
39
+ // actually spoken, and rescaling would tear the slides away from the
40
+ // voice. Anchored blocks keep their positions; floating blocks
41
+ // (intro/outro bookends, section headers, interleaved images) fill the
42
+ // gaps between anchors. With no anchors at all, behavior is the
43
+ // historical rescale-to-fit.
44
+ const hasAnchors = blocks.some(
45
+ (b) => isTemplateBlock(b) && typeof b.sourceStartTime === 'number',
46
+ );
47
+ return hasAnchors
48
+ ? allocateAnchored(blocks, totalDuration)
49
+ : allocateRescaled(blocks, totalDuration);
50
+ }
51
+
52
+ /** Historical behavior: scale every duration to fill the total, clamp 3–20 s. */
53
+ function allocateRescaled(blocks: Array<Block | TemplateBlock>, totalDuration: number): Block[] {
36
54
  const effectiveDuration = totalDuration > 0 ? totalDuration : estimateDefaultDuration(blocks);
37
55
 
38
56
  // First pass: assign raw durations
@@ -59,37 +77,150 @@ export function allocateTiming(
59
77
  // Second pass: assign startTimes and build output Blocks
60
78
  let currentTime = 0;
61
79
  const result: Block[] = [];
62
-
63
80
  for (let i = 0; i < blocks.length; i++) {
64
- const block = blocks[i];
65
- const duration = durations[i];
81
+ result.push(toOutputBlock(blocks[i], currentTime, durations[i]));
82
+ currentTime += durations[i];
83
+ }
84
+ return result;
85
+ }
86
+
87
+ /** Minimum duration for a floating block squeezed between anchors. */
88
+ const MIN_FLOATING_DURATION = 1;
66
89
 
90
+ /**
91
+ * Anchor-aware allocation: anchored entries (template blocks with a
92
+ * numeric `sourceStartTime`, and untransformed source Blocks with their
93
+ * own real timing) are PLACED at their timeline positions; runs of
94
+ * floating blocks (bookends, headers, images) distribute across the gap
95
+ * before the next anchor. Durations then derive from consecutive starts
96
+ * so the timeline stays CONTIGUOUS — a slide summarizing unpromoted
97
+ * source content simply holds until the next slide begins, which is
98
+ * what keeps every narration second covered by some block.
99
+ */
100
+ function allocateAnchored(blocks: Array<Block | TemplateBlock>, totalDuration: number): Block[] {
101
+ interface Entry {
102
+ block: Block | TemplateBlock;
103
+ anchorStart: number | null;
104
+ rawDuration: number;
105
+ }
106
+ const entries: Entry[] = blocks.map((block) => {
67
107
  if (isTemplateBlock(block)) {
68
- // Convert TemplateBlock to Block shell, preserving all template-specific
69
- // fields (stat, quote, description, etc.) so they survive through to
70
- // the player's template expansion step.
71
- const outputBlock = {
72
- ...block,
73
- startTime: currentTime,
74
- duration,
75
- title: getTemplateTitle(block),
76
- } as unknown as Block;
77
- result.push(outputBlock);
78
- } else {
79
- // Existing block — update timing
80
- result.push({
81
- ...block,
82
- startTime: currentTime,
83
- duration,
84
- });
108
+ return {
109
+ block,
110
+ anchorStart: typeof block.sourceStartTime === 'number' ? block.sourceStartTime : null,
111
+ rawDuration: block.duration > 0 ? block.duration : DEFAULT_BLOCK_DURATION,
112
+ };
113
+ }
114
+ // Untransformed source blocks carry real (possibly narration) timing.
115
+ return {
116
+ block,
117
+ anchorStart: block.startTime,
118
+ rawDuration: block.duration > 0 ? block.duration : DEFAULT_BLOCK_DURATION,
119
+ };
120
+ });
121
+
122
+ // Pass 1: starts. Anchors pin their positions (the cursor then jumps
123
+ // past the anchor's authored span, so following floats land AFTER it).
124
+ // Float runs place differently by position: LEADING floats (before any
125
+ // anchor — intro bookends) fill the opening gap from t=0; MID floats
126
+ // (section headers, images) end-align just before the next anchor —
127
+ // a header belongs to the section it introduces; TRAILING floats
128
+ // (outro) run sequentially after the last anchor's span.
129
+ const starts = new Array<number>(entries.length);
130
+ let cursor = 0;
131
+ let seenAnchor = false;
132
+ let i = 0;
133
+ while (i < entries.length) {
134
+ const entry = entries[i];
135
+ if (entry.anchorStart !== null) {
136
+ const start = Math.max(cursor, entry.anchorStart);
137
+ starts[i] = start;
138
+ const hint = isTemplateBlock(entry.block)
139
+ ? (entry.block.sourceDuration ?? entry.rawDuration)
140
+ : entry.rawDuration;
141
+ cursor = start + Math.max(MIN_FLOATING_DURATION, hint);
142
+ seenAnchor = true;
143
+ i++;
144
+ continue;
85
145
  }
146
+ const runStart = i;
147
+ while (i < entries.length && entries[i].anchorStart === null) i++;
148
+ const run = entries.slice(runStart, i);
149
+ const rawSum = run.reduce((sum, e) => sum + e.rawDuration, 0);
150
+ const nextAnchorStart =
151
+ i < entries.length ? Math.max(entries[i].anchorStart ?? cursor, cursor) : null;
86
152
 
87
- currentTime += duration;
153
+ if (nextAnchorStart === null) {
154
+ // Trailing floats: natural sizes after the last anchor's span.
155
+ let acc = cursor;
156
+ for (let k = 0; k < run.length; k++) {
157
+ starts[runStart + k] = acc;
158
+ acc += run[k].rawDuration;
159
+ }
160
+ cursor = acc;
161
+ } else if (!seenAnchor) {
162
+ // Leading floats: fill [cursor, nextAnchorStart) proportionally.
163
+ const gap = nextAnchorStart - cursor;
164
+ let acc = cursor;
165
+ for (let k = 0; k < run.length; k++) {
166
+ starts[runStart + k] = acc;
167
+ acc += rawSum > 0 ? (run[k].rawDuration / rawSum) * gap : gap / run.length;
168
+ }
169
+ cursor = nextAnchorStart;
170
+ } else {
171
+ // Mid floats: end-aligned at natural size, capped by the gap.
172
+ const gap = Math.max(0, nextAnchorStart - cursor);
173
+ const scale = rawSum > 0 ? Math.min(1, gap / rawSum) : 0;
174
+ let acc = Math.max(cursor, nextAnchorStart - rawSum * scale);
175
+ for (let k = 0; k < run.length; k++) {
176
+ starts[runStart + k] = acc;
177
+ acc += run[k].rawDuration * scale;
178
+ }
179
+ cursor = nextAnchorStart;
180
+ }
88
181
  }
89
182
 
183
+ // Pass 2: strictly increasing starts, then contiguous durations.
184
+ for (let k = 0; k < starts.length; k++) {
185
+ const floor = k > 0 ? starts[k - 1] + MIN_FLOATING_DURATION : 0;
186
+ if (starts[k] < floor) starts[k] = floor;
187
+ }
188
+ const result: Block[] = [];
189
+ for (let k = 0; k < entries.length; k++) {
190
+ const entry = entries[k];
191
+ let duration: number;
192
+ if (k + 1 < entries.length) {
193
+ duration = Math.max(MIN_FLOATING_DURATION, starts[k + 1] - starts[k]);
194
+ } else {
195
+ const authored = isTemplateBlock(entry.block)
196
+ ? (entry.block.sourceDuration ?? entry.rawDuration)
197
+ : entry.rawDuration;
198
+ // The final block covers through the end of the doc/narration when
199
+ // the caller knows it runs longer.
200
+ duration = Math.max(MIN_FLOATING_DURATION, authored, totalDuration - starts[k]);
201
+ }
202
+ result.push(toOutputBlock(entry.block, starts[k], duration));
203
+ }
90
204
  return result;
91
205
  }
92
206
 
207
+ /**
208
+ * Convert an entry to an output Block shell. Template blocks preserve all
209
+ * template-specific fields (stat, quote, description, …) so they survive
210
+ * through to the player's template expansion step.
211
+ */
212
+ function toOutputBlock(block: Block | TemplateBlock, startTime: number, duration: number): Block {
213
+ if (isTemplateBlock(block)) {
214
+ return {
215
+ ...block,
216
+ startTime,
217
+ duration,
218
+ title: getTemplateTitle(block),
219
+ } as unknown as Block;
220
+ }
221
+ return { ...block, startTime, duration };
222
+ }
223
+
93
224
  /** Clamp a duration to the min/max range. */
94
225
  function clampDuration(d: number): number {
95
226
  return Math.max(MIN_BLOCK_DURATION, Math.min(MAX_BLOCK_DURATION, d));
@@ -12,7 +12,7 @@ import type { ColorScheme } from '../schemas/BlockTemplates.js';
12
12
 
13
13
  // ── Style identification ─────────────────────────────────────────
14
14
 
15
- /** Identifier for a registered transform style. */
15
+ /** Identifier for a built-in or caller-registered transform style. */
16
16
  export type TransformStyleId = string;
17
17
 
18
18
  /** Summary info for UI dropdowns (mirrors getThemeSummaries pattern). */
@@ -97,6 +97,17 @@ export interface TransformStyleConfig {
97
97
  };
98
98
  }
99
99
 
100
+ /** Built-in id or a call-scoped declarative style definition. */
101
+ export type TransformStyleInput = TransformStyleId | TransformStyleConfig;
102
+
103
+ /** Explicit caller-owned registry for custom transform styles. */
104
+ export interface TransformStyleRegistry {
105
+ register(style: TransformStyleConfig): void;
106
+ unregister(id: string): boolean;
107
+ get(id: string): TransformStyleConfig | undefined;
108
+ list(): TransformStyleConfig[];
109
+ }
110
+
100
111
  // ── Transform options ────────────────────────────────────────────
101
112
 
102
113
  /** Image metadata for image interleaving. */
@@ -121,6 +132,8 @@ export interface TransformOptions {
121
132
  themeId?: string;
122
133
  /** Override specific style config values. */
123
134
  overrides?: Partial<TransformStyleConfig>;
135
+ /** Caller-owned registry used when `style` is an id. */
136
+ registry?: TransformStyleRegistry;
124
137
  }
125
138
 
126
139
  // ── Transform result ─────────────────────────────────────────────
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Keep version snapshots out of Git when a ContentContainer is persisted
3
+ * as a real folder (typically a `<basename>_files/` sidecar).
4
+ */
5
+
6
+ import type { ContentContainer } from '../storage/ContentContainer.js';
7
+
8
+ const decoder = new TextDecoder();
9
+ const encoder = new TextEncoder();
10
+
11
+ const GITIGNORE_PATH = '.gitignore';
12
+ const VERSIONS_IGNORE_RULE = '.versions/';
13
+ const EQUIVALENT_VERSIONS_RULE = /^(!?)\/?\.versions\/?$/;
14
+
15
+ /**
16
+ * Ensure the container-root `.gitignore` ignores its `.versions/` folder.
17
+ * Existing rules and newline style are preserved.
18
+ */
19
+ export async function ensureVersionsGitIgnored(container: ContentContainer): Promise<void> {
20
+ const data = await container.readFile(GITIGNORE_PATH);
21
+ const existing = data ? decoder.decode(data) : '';
22
+
23
+ let alreadyIgnored = false;
24
+ for (const line of existing.split(/\r?\n/)) {
25
+ const match = EQUIVALENT_VERSIONS_RULE.exec(line.trim());
26
+ if (match) alreadyIgnored = match[1] !== '!';
27
+ }
28
+ if (alreadyIgnored) return;
29
+
30
+ const newline = existing.includes('\r\n') ? '\r\n' : '\n';
31
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? newline : '';
32
+ const next = `${existing}${separator}${VERSIONS_IGNORE_RULE}${newline}`;
33
+ await container.writeFile(GITIGNORE_PATH, encoder.encode(next), 'text/plain');
34
+ }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Snapshots of the primary markdown document live inside the same
5
5
  * {@link ContentContainer} at `.versions/<basename>.<timestamp>.md`.
6
+ * When a snapshot is retained, the container root also gets a `.gitignore`
7
+ * rule for `.versions/`, preserving any existing ignore rules.
6
8
  * Hosts call {@link DocumentVersionManager.saveVersion} (or the
7
9
  * underlying {@link saveVersion}) when they want to stamp a new version;
8
10
  * the editor's auto-save effect calls it on idle. Either way, snapshots
@@ -6,7 +6,14 @@
6
6
  */
7
7
 
8
8
  import type { ContentContainer } from '../storage/ContentContainer.js';
9
- import { buildVersionPath, getDocBasename, parseVersionPath, VERSIONS_PREFIX } from './paths.js';
9
+ import { DOCUMENT_VERSION_PATHS, getDocBasename } from './paths.js';
10
+ import {
11
+ coalesceSnapshots,
12
+ listSnapshots,
13
+ pruneSnapshots,
14
+ readTextSnapshot,
15
+ saveTextSnapshot,
16
+ } from './snapshotStore.js';
10
17
  import type {
11
18
  CoalesceOptions,
12
19
  PrunePolicy,
@@ -16,9 +23,6 @@ import type {
16
23
  Version,
17
24
  } from './types.js';
18
25
 
19
- const decoder = new TextDecoder();
20
- const encoder = new TextEncoder();
21
-
22
26
  async function resolveBasename(
23
27
  container: ContentContainer,
24
28
  override: string | undefined,
@@ -46,30 +50,7 @@ export async function listVersions(
46
50
  container: ContentContainer,
47
51
  basename?: string,
48
52
  ): Promise<Version[]> {
49
- const entries = await container.listFiles(VERSIONS_PREFIX);
50
- const versions: Version[] = [];
51
- for (const entry of entries) {
52
- const parsed = parseVersionPath(entry.path);
53
- if (!parsed) continue;
54
- if (basename !== undefined && parsed.basename !== basename) continue;
55
- versions.push({
56
- path: entry.path,
57
- basename: parsed.basename,
58
- timestamp: parsed.timestamp,
59
- size: entry.size,
60
- collision: parsed.collision,
61
- });
62
- }
63
- // Newest first. Tie-break on collision (higher = newer). Compare the
64
- // numeric collision rather than the path string: lexicographically a path
65
- // with no suffix sorts *after* one with `-N`, because `.` (0x2E) is
66
- // greater than `-` (0x2D), which would invert the order.
67
- versions.sort((a, b) => {
68
- const dt = b.timestamp.getTime() - a.timestamp.getTime();
69
- if (dt !== 0) return dt;
70
- return b.collision - a.collision;
71
- });
72
- return versions;
53
+ return listSnapshots(container, DOCUMENT_VERSION_PATHS, basename);
73
54
  }
74
55
 
75
56
  /**
@@ -80,10 +61,7 @@ export async function readVersion(
80
61
  container: ContentContainer,
81
62
  version: Version | string,
82
63
  ): Promise<string | null> {
83
- const path = typeof version === 'string' ? version : version.path;
84
- const data = await container.readFile(path);
85
- if (!data) return null;
86
- return decoder.decode(data);
64
+ return readTextSnapshot(container, version);
87
65
  }
88
66
 
89
67
  /**
@@ -107,34 +85,13 @@ export async function saveVersion(
107
85
  return { saved: false, version: null, reason: 'no-document' };
108
86
  }
109
87
 
110
- const versions = await listVersions(container, basename);
111
- if (!options.force && versions.length > 0) {
112
- const latest = versions[0]!;
113
- const existing = await readVersion(container, latest);
114
- if (existing === content) {
115
- return { saved: false, version: null, reason: 'unchanged' };
116
- }
117
- }
118
-
119
- const now = options.now ?? new Date();
120
- let collision = 0;
121
- let path = buildVersionPath(basename, now, collision);
122
- while (await container.exists(path)) {
123
- collision += 1;
124
- path = buildVersionPath(basename, now, collision);
125
- }
126
-
127
- const data = encoder.encode(content);
128
- await container.writeFile(path, data, 'text/markdown');
129
-
130
- const version: Version = {
131
- path,
88
+ return saveTextSnapshot(container, DOCUMENT_VERSION_PATHS, {
89
+ content,
132
90
  basename,
133
- timestamp: now,
134
- size: data.byteLength,
135
- collision,
136
- };
137
- return { saved: true, version, reason: 'saved' };
91
+ mimeType: 'text/markdown',
92
+ now: options.now,
93
+ force: options.force,
94
+ });
138
95
  }
139
96
 
140
97
  /**
@@ -171,27 +128,7 @@ export async function pruneVersions(
171
128
  policy: PrunePolicy,
172
129
  basename?: string,
173
130
  ): Promise<Version[]> {
174
- const versions = await listVersions(container, basename);
175
- const toDelete: Version[] = [];
176
-
177
- if (policy.type === 'keep-last-n') {
178
- const n = Math.max(0, Math.floor(policy.n));
179
- toDelete.push(...versions.slice(n));
180
- } else if (policy.type === 'older-than') {
181
- const cutoff = policy.date.getTime();
182
- for (const v of versions) {
183
- if (v.timestamp.getTime() < cutoff) toDelete.push(v);
184
- }
185
- } else {
186
- for (const v of versions) {
187
- if (!policy.keep(v, versions)) toDelete.push(v);
188
- }
189
- }
190
-
191
- for (const v of toDelete) {
192
- await container.removeFile(v.path);
193
- }
194
- return toDelete;
131
+ return pruneSnapshots(container, DOCUMENT_VERSION_PATHS, policy, basename);
195
132
  }
196
133
 
197
134
  /**
@@ -203,28 +140,5 @@ export async function coalesceVersions(
203
140
  options: CoalesceOptions = {},
204
141
  basename?: string,
205
142
  ): Promise<Version[]> {
206
- const windowMs = options.windowMs ?? 60_000;
207
- const versions = await listVersions(container, basename);
208
- const toDelete: Version[] = [];
209
- // Walk newest -> oldest, anchored to the last *kept* snapshot. Any
210
- // candidate within `windowMs` of the anchor is dropped; the first one
211
- // outside the window becomes the new anchor. Comparing each pair against
212
- // its previous neighbor instead would chain through deleted snapshots
213
- // and over-prune any tightly-clustered run (e.g. 30s apart with a 60s
214
- // window collapses to a single survivor).
215
- if (versions.length > 0) {
216
- let anchor = versions[0]!;
217
- for (let i = 1; i < versions.length; i++) {
218
- const candidate = versions[i]!;
219
- if (anchor.timestamp.getTime() - candidate.timestamp.getTime() <= windowMs) {
220
- toDelete.push(candidate);
221
- } else {
222
- anchor = candidate;
223
- }
224
- }
225
- }
226
- for (const v of toDelete) {
227
- await container.removeFile(v.path);
228
- }
229
- return toDelete;
143
+ return coalesceSnapshots(container, DOCUMENT_VERSION_PATHS, options, basename);
230
144
  }
@@ -6,11 +6,21 @@
6
6
  * snapshots collide on the same UTC second.
7
7
  */
8
8
 
9
- import { formatVersionTimestamp, parseVersionTimestamp } from './timestamp.js';
9
+ import {
10
+ buildSnapshotPath,
11
+ parseSnapshotPath,
12
+ type SnapshotPathStrategy,
13
+ } from './snapshotStore.js';
10
14
 
11
15
  /** Subfolder (inside the ContentContainer) that holds version snapshots. */
12
16
  export const VERSIONS_PREFIX = '.versions/';
13
17
 
18
+ /** Internal strategy shared with the generic snapshot store. */
19
+ export const DOCUMENT_VERSION_PATHS: SnapshotPathStrategy = Object.freeze({
20
+ prefix: VERSIONS_PREFIX,
21
+ extension: 'md',
22
+ });
23
+
14
24
  /**
15
25
  * Strip the directory and extension from a document path to produce the
16
26
  * basename used in version filenames. `subdir/index.md` -> `index`.
@@ -27,9 +37,7 @@ export function getDocBasename(documentPath: string): string {
27
37
  * (e.g. `2`, `3`) when two saves land on the same UTC second.
28
38
  */
29
39
  export function buildVersionPath(basename: string, date: Date, collision = 0): string {
30
- const stamp = formatVersionTimestamp(date);
31
- const suffix = collision > 0 ? `-${collision + 1}` : '';
32
- return `${VERSIONS_PREFIX}${basename}.${stamp}${suffix}.md`;
40
+ return buildSnapshotPath(DOCUMENT_VERSION_PATHS, basename, date, collision);
33
41
  }
34
42
 
35
43
  /**
@@ -39,28 +47,5 @@ export function buildVersionPath(basename: string, date: Date, collision = 0): s
39
47
  export function parseVersionPath(
40
48
  path: string,
41
49
  ): { basename: string; timestamp: Date; collision: number } | null {
42
- if (!path.startsWith(VERSIONS_PREFIX)) return null;
43
- const rest = path.slice(VERSIONS_PREFIX.length);
44
- if (!rest.endsWith('.md')) return null;
45
- const stem = rest.slice(0, -'.md'.length);
46
- // basename can contain dots; the timestamp is always the *last* dot-segment
47
- // (optionally followed by a `-N` collision suffix).
48
- const lastDot = stem.lastIndexOf('.');
49
- if (lastDot <= 0) return null;
50
- const basename = stem.slice(0, lastDot);
51
- const tail = stem.slice(lastDot + 1);
52
- const dash = tail.indexOf('-');
53
- let stamp: string;
54
- let collision = 0;
55
- if (dash >= 0) {
56
- stamp = tail.slice(0, dash);
57
- const n = Number(tail.slice(dash + 1));
58
- if (!Number.isInteger(n) || n < 2) return null;
59
- collision = n - 1;
60
- } else {
61
- stamp = tail;
62
- }
63
- const timestamp = parseVersionTimestamp(stamp);
64
- if (!timestamp) return null;
65
- return { basename, timestamp, collision };
50
+ return parseSnapshotPath(DOCUMENT_VERSION_PATHS, path);
66
51
  }