@bendyline/squisq 1.5.2 → 2.0.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 (268) 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-T5UK6YOB.js → chunk-2PWOO4ZE.js} +60 -17
  6. package/dist/chunk-2PWOO4ZE.js.map +1 -0
  7. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  8. package/dist/chunk-3E5F2XMR.js.map +1 -0
  9. package/dist/chunk-4X3JQXNM.js +35 -0
  10. package/dist/chunk-4X3JQXNM.js.map +1 -0
  11. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  12. package/dist/chunk-5DKBFXPY.js.map +1 -0
  13. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  14. package/dist/chunk-5J4HKRJF.js.map +1 -0
  15. package/dist/chunk-5UYI3RJB.js +2503 -0
  16. package/dist/chunk-5UYI3RJB.js.map +1 -0
  17. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  18. package/dist/chunk-66QO7XFB.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-CY2R2VPR.js +884 -0
  22. package/dist/chunk-CY2R2VPR.js.map +1 -0
  23. package/dist/chunk-FVJUUTEM.js +74 -0
  24. package/dist/chunk-FVJUUTEM.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-MMPBOUK7.js} +10 -73
  30. package/dist/chunk-MMPBOUK7.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-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  36. package/dist/chunk-R2GC6RLD.js.map +1 -0
  37. package/dist/chunk-R2YZTCLO.js +292 -0
  38. package/dist/chunk-R2YZTCLO.js.map +1 -0
  39. package/dist/chunk-S7A5D7ZJ.js +174 -0
  40. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  41. package/dist/chunk-TFJVXUHH.js +2358 -0
  42. package/dist/chunk-TFJVXUHH.js.map +1 -0
  43. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  44. package/dist/chunk-TUWMK62B.js.map +1 -0
  45. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  46. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  47. package/dist/{chunk-KKNUBQ6Y.js → chunk-W4O2TX5I.js} +157 -92
  48. package/dist/chunk-W4O2TX5I.js.map +1 -0
  49. package/dist/{chunk-5TYS5V3G.js → chunk-X6DUONGT.js} +4204 -3560
  50. package/dist/chunk-X6DUONGT.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__/customThemesFrontmatter.test.ts +1 -0
  98. package/src/__tests__/dataTable.test.ts +25 -15
  99. package/src/__tests__/diagramBlock.test.ts +3 -3
  100. package/src/__tests__/diagramText.test.ts +26 -0
  101. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  102. package/src/__tests__/drawingBlock.test.ts +3 -3
  103. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  104. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  105. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  106. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  107. package/src/__tests__/getLayers.test.ts +32 -18
  108. package/src/__tests__/getLayersFallback.test.ts +8 -8
  109. package/src/__tests__/imageEditVersions.test.ts +2 -0
  110. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  111. package/src/__tests__/layoutBlock.test.ts +3 -3
  112. package/src/__tests__/majorApiTypes.test.ts +18 -0
  113. package/src/__tests__/markdown.test.ts +54 -4
  114. package/src/__tests__/markdownSanitize.test.ts +7 -5
  115. package/src/__tests__/matchFontFamily.test.ts +114 -0
  116. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  117. package/src/__tests__/materializeTestUtils.ts +17 -0
  118. package/src/__tests__/narrationAlign.test.ts +150 -0
  119. package/src/__tests__/narrationFeatures.test.ts +71 -0
  120. package/src/__tests__/narrationNuclei.test.ts +45 -0
  121. package/src/__tests__/narrationPacing.test.ts +189 -0
  122. package/src/__tests__/narrationScript.test.ts +149 -0
  123. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  124. package/src/__tests__/narrationSidecar.test.ts +88 -0
  125. package/src/__tests__/narrationSyllables.test.ts +65 -0
  126. package/src/__tests__/narrationTestSignals.ts +167 -0
  127. package/src/__tests__/narrationVad.test.ts +66 -0
  128. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  129. package/src/__tests__/rootExports.test.ts +18 -0
  130. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  131. package/src/__tests__/shapeGeometry.test.ts +12 -0
  132. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  133. package/src/__tests__/templateInputs.test.ts +69 -0
  134. package/src/__tests__/templateMetadata.test.ts +2 -1
  135. package/src/__tests__/templates.test.ts +143 -17
  136. package/src/__tests__/themeCompile.test.ts +49 -22
  137. package/src/__tests__/transformProvenance.test.ts +160 -0
  138. package/src/__tests__/transformV2.test.ts +40 -14
  139. package/src/__tests__/treeviewBattle.test.ts +76 -0
  140. package/src/__tests__/treeviewDetect.test.ts +168 -0
  141. package/src/__tests__/treeviewParse.test.ts +118 -0
  142. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  143. package/src/__tests__/treeviewRender.test.ts +91 -0
  144. package/src/__tests__/versions.test.ts +44 -0
  145. package/src/doc/applyNarrationTiming.ts +244 -0
  146. package/src/doc/asciiDiagram/chars.ts +210 -0
  147. package/src/doc/asciiDiagram/detect.ts +175 -0
  148. package/src/doc/asciiDiagram/index.ts +38 -0
  149. package/src/doc/asciiDiagram/mapping.ts +225 -0
  150. package/src/doc/asciiDiagram/parse.ts +1095 -0
  151. package/src/doc/asciiDiagram/render.ts +963 -0
  152. package/src/doc/asciiDiagram/repair.ts +400 -0
  153. package/src/doc/asciiDiagram/types.ts +65 -0
  154. package/src/doc/asciiTimeline/detect.ts +138 -0
  155. package/src/doc/asciiTimeline/index.ts +26 -0
  156. package/src/doc/asciiTimeline/mapping.ts +43 -0
  157. package/src/doc/asciiTimeline/parse.ts +870 -0
  158. package/src/doc/asciiTimeline/render.ts +427 -0
  159. package/src/doc/asciiTimeline/types.ts +73 -0
  160. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  161. package/src/doc/audioMapping.ts +60 -46
  162. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  163. package/src/doc/docToMarkdown.ts +70 -2
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +138 -2
  166. package/src/doc/materializeBlockLayers.ts +304 -0
  167. package/src/doc/mediaAnnotations.ts +20 -0
  168. package/src/doc/resolveDocTheme.ts +8 -7
  169. package/src/doc/templateInputs.ts +185 -2
  170. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  171. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/fallbackBlock.ts +1 -1
  174. package/src/doc/templates/index.ts +134 -279
  175. package/src/doc/templates/inputDescriptors.ts +20 -1
  176. package/src/doc/templates/listBlock.ts +88 -30
  177. package/src/doc/templates/metadata.ts +10 -0
  178. package/src/doc/templates/persistentLayers.ts +12 -126
  179. package/src/doc/templates/registry.ts +88 -0
  180. package/src/doc/templates/templateNames.ts +22 -0
  181. package/src/doc/templates/timelineBlock.ts +613 -0
  182. package/src/doc/templates/treeBlock.ts +131 -0
  183. package/src/doc/treeview/chars.ts +72 -0
  184. package/src/doc/treeview/detect.ts +145 -0
  185. package/src/doc/treeview/index.ts +30 -0
  186. package/src/doc/treeview/mapping.ts +95 -0
  187. package/src/doc/treeview/parse.ts +201 -0
  188. package/src/doc/treeview/render.ts +41 -0
  189. package/src/doc/treeview/types.ts +43 -0
  190. package/src/doc/utils/applyRenderStyle.ts +2 -9
  191. package/src/doc/utils/diagramText.ts +78 -0
  192. package/src/doc/utils/shapeGeometry.ts +67 -5
  193. package/src/doc/validate.ts +1 -1
  194. package/src/generate/templateMapper.ts +7 -0
  195. package/src/imageEdit/export.ts +6 -5
  196. package/src/imageEdit/versionPaths.ts +13 -26
  197. package/src/imageEdit/versions.ts +21 -100
  198. package/src/index.ts +1 -0
  199. package/src/internal/immutable.ts +58 -0
  200. package/src/jsonForm/index.ts +9 -1
  201. package/src/jsonForm/pointer.ts +68 -23
  202. package/src/markdown/attrTokens.ts +7 -5
  203. package/src/markdown/index.ts +1 -0
  204. package/src/markdown/stringify.ts +4 -9
  205. package/src/markdown/utils.ts +36 -14
  206. package/src/narration/align.ts +514 -0
  207. package/src/narration/features.ts +231 -0
  208. package/src/narration/index.ts +86 -0
  209. package/src/narration/nuclei.ts +118 -0
  210. package/src/narration/pacing.ts +211 -0
  211. package/src/narration/script.ts +239 -0
  212. package/src/narration/session.ts +100 -0
  213. package/src/narration/sidecar.ts +187 -0
  214. package/src/narration/syllables.ts +85 -0
  215. package/src/narration/trace.ts +50 -0
  216. package/src/narration/types.ts +299 -0
  217. package/src/narration/vad.ts +131 -0
  218. package/src/recommend/numberHighlight.ts +25 -0
  219. package/src/recommend/templates.ts +108 -12
  220. package/src/schemas/BlockTemplates.ts +156 -7
  221. package/src/schemas/Doc.ts +72 -9
  222. package/src/schemas/Media.ts +12 -2
  223. package/src/schemas/Theme.ts +45 -25
  224. package/src/schemas/Viewport.ts +2 -2
  225. package/src/schemas/fontStacks.ts +79 -0
  226. package/src/schemas/themeCompile.ts +15 -0
  227. package/src/schemas/themeConstants.ts +2 -0
  228. package/src/schemas/themeLibrary.ts +25 -19
  229. package/src/schemas/themeValidator.ts +1 -1
  230. package/src/schemas/themes/gezellig.json +1 -1
  231. package/src/spatial/Geohash.ts +0 -3
  232. package/src/storage/ContentContainer.ts +17 -9
  233. package/src/storage/ScopedContentContainer.ts +45 -15
  234. package/src/transform/applyTransform.ts +5 -5
  235. package/src/transform/index.ts +3 -1
  236. package/src/transform/registry.ts +169 -49
  237. package/src/transform/templateSelector.ts +21 -2
  238. package/src/transform/timingAllocator.ts +152 -21
  239. package/src/transform/types.ts +14 -1
  240. package/src/versions/gitignore.ts +34 -0
  241. package/src/versions/index.ts +2 -0
  242. package/src/versions/operations.ts +18 -104
  243. package/src/versions/paths.ts +13 -28
  244. package/src/versions/snapshotStore.ts +202 -0
  245. package/src/versions/types.ts +3 -1
  246. package/dist/chunk-5TYS5V3G.js.map +0 -1
  247. package/dist/chunk-A4O7GIWE.js.map +0 -1
  248. package/dist/chunk-BEQODJWV.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-GPLTCSXO.js.map +0 -1
  251. package/dist/chunk-HWVFJAAH.js.map +0 -1
  252. package/dist/chunk-IMSCRLLV.js.map +0 -1
  253. package/dist/chunk-JELIRVDP.js +0 -24
  254. package/dist/chunk-JELIRVDP.js.map +0 -1
  255. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  256. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  257. package/dist/chunk-LTN6I7QW.js.map +0 -1
  258. package/dist/chunk-P3K7NLHF.js.map +0 -1
  259. package/dist/chunk-T5UK6YOB.js.map +0 -1
  260. package/dist/chunk-UY7KGQ5R.js +0 -192
  261. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  262. package/dist/chunk-VLJJHDAC.js +0 -218
  263. package/dist/chunk-VLJJHDAC.js.map +0 -1
  264. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  265. package/dist/story/index.d.ts +0 -1801
  266. package/dist/story/index.js +0 -279
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -194,6 +194,3 @@ export function geohashOverlapsBounds(
194
194
  hashWest > bounds.east
195
195
  );
196
196
  }
197
-
198
- // Re-export for backwards compatibility with existing imports.
199
- export { haversineDistance };
@@ -92,9 +92,8 @@ export function findDocumentPath(entries: ContentEntry[]): string | null {
92
92
  const rootFiles = entries.filter((e) => !e.path.includes('/'));
93
93
 
94
94
  for (const name of MARKDOWN_PRIORITY) {
95
- if (rootFiles.some((e) => e.path.toLowerCase() === name)) {
96
- return name;
97
- }
95
+ const match = rootFiles.find((e) => e.path.toLowerCase() === name);
96
+ if (match) return match.path;
98
97
  }
99
98
 
100
99
  // Fallback: first .md file at root
@@ -120,21 +119,30 @@ export class MemoryContentContainer implements ContentContainer {
120
119
  private files = new Map<string, MemoryFile>();
121
120
 
122
121
  async readFile(path: string): Promise<ArrayBuffer | null> {
123
- return this.files.get(path)?.data ?? null;
122
+ const stored = this.files.get(path)?.data;
123
+ // A container owns its bytes. Returning the backing buffer would let a
124
+ // caller mutate stored state without going through writeFile().
125
+ return stored ? stored.slice(0) : null;
124
126
  }
125
127
 
126
128
  async writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void> {
127
129
  // Copy to a standalone ArrayBuffer. Uint8Array/Buffer may share a larger
128
130
  // backing buffer (e.g., Node's buffer pool), so we must slice to the
129
131
  // exact byte range to avoid storing stale pool data.
130
- let buffer: ArrayBuffer;
131
- if (data instanceof ArrayBuffer) {
132
- buffer = data;
132
+ // ArrayBuffer.isView is cross-realm-safe (unlike `instanceof
133
+ // ArrayBuffer`, which fails for values created in jsdom/iframes).
134
+ let source: Uint8Array;
135
+ if (ArrayBuffer.isView(data)) {
136
+ source = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
133
137
  } else {
134
- buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
138
+ source = new Uint8Array(data);
135
139
  }
140
+ // Always materialize in this realm, including for iframe ArrayBuffers and
141
+ // views backed by SharedArrayBuffer.
142
+ const owned = new Uint8Array(source.byteLength);
143
+ owned.set(source);
136
144
  this.files.set(path, {
137
- data: buffer,
145
+ data: owned.buffer,
138
146
  mimeType: mimeType ?? guessMimeType(path),
139
147
  });
140
148
  }
@@ -16,20 +16,48 @@
16
16
 
17
17
  import type { ContentContainer, ContentEntry } from './ContentContainer.js';
18
18
 
19
- /** Normalize a prefix: strip leading/trailing slashes, ensure no `..` segments. */
20
- function normalizePrefix(prefix: string): string {
21
- let p = prefix.trim();
22
- while (p.startsWith('/')) p = p.slice(1);
23
- while (p.endsWith('/')) p = p.slice(0, -1);
24
- if (p.length === 0) {
25
- throw new Error('ScopedContentContainer: prefix must not be empty');
19
+ interface NormalizePathOptions {
20
+ allowEmpty?: boolean;
21
+ preserveTrailingSlash?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Normalize a container-relative path without ever resolving traversal.
26
+ * Backslashes are rejected rather than translated because a filesystem-backed
27
+ * parent may interpret them as separators even when an in-memory parent does
28
+ * not. Dot segments are similarly rejected instead of collapsed so an invalid
29
+ * caller path can never escape after a later platform-specific normalization.
30
+ */
31
+ function normalizeRelativePath(
32
+ path: string,
33
+ label: 'prefix' | 'path',
34
+ options: NormalizePathOptions = {},
35
+ ): string {
36
+ const original = path;
37
+ let normalized = label === 'prefix' ? path.trim() : path;
38
+ if (normalized.includes('\\')) {
39
+ throw new Error(`ScopedContentContainer: ${label} must use forward slashes: ${original}`);
26
40
  }
27
- if (p.split('/').some((seg) => seg === '..' || seg === '.')) {
41
+
42
+ const hadTrailingSlash = normalized.endsWith('/');
43
+ normalized = normalized.replace(/^\/+|\/+$/g, '');
44
+ const segments = normalized === '' ? [] : normalized.split('/').filter(Boolean);
45
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
28
46
  throw new Error(
29
- `ScopedContentContainer: prefix must not contain '.' or '..' segments: ${prefix}`,
47
+ `ScopedContentContainer: ${label} must not contain '.' or '..' segments: ${original}`,
30
48
  );
31
49
  }
32
- return p;
50
+ if (segments.length === 0 && !options.allowEmpty) {
51
+ throw new Error(`ScopedContentContainer: ${label} must not be empty`);
52
+ }
53
+
54
+ const result = segments.join('/');
55
+ return options.preserveTrailingSlash && hadTrailingSlash && result ? `${result}/` : result;
56
+ }
57
+
58
+ /** Normalize a prefix: strip leading/trailing slashes, ensure no `..` segments. */
59
+ function normalizePrefix(prefix: string): string {
60
+ return normalizeRelativePath(prefix, 'prefix');
33
61
  }
34
62
 
35
63
  /**
@@ -50,10 +78,9 @@ export class ScopedContentContainer implements ContentContainer {
50
78
  this.prefixSlash = `${this.prefix}/`;
51
79
  }
52
80
 
53
- private toParent(path: string): string {
54
- let p = path;
55
- while (p.startsWith('/')) p = p.slice(1);
56
- return this.prefixSlash + p;
81
+ private toParent(path: string, options: NormalizePathOptions = {}): string {
82
+ const normalized = normalizeRelativePath(path, 'path', options);
83
+ return this.prefixSlash + normalized;
57
84
  }
58
85
 
59
86
  /** Strip the prefix from a parent-space path. Returns null if the path is outside the scope. */
@@ -75,7 +102,10 @@ export class ScopedContentContainer implements ContentContainer {
75
102
  }
76
103
 
77
104
  async listFiles(prefix?: string): Promise<ContentEntry[]> {
78
- const parentPrefix = prefix ? this.toParent(prefix) : this.prefixSlash;
105
+ const parentPrefix =
106
+ prefix == null
107
+ ? this.prefixSlash
108
+ : this.toParent(prefix, { allowEmpty: true, preserveTrailingSlash: true });
79
109
  const entries = await this.parent.listFiles(parentPrefix);
80
110
  const out: ContentEntry[] = [];
81
111
  for (const e of entries) {
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { Doc } from '../schemas/Doc.js';
12
12
  import type { DocBlock, TitleBlockInput, SectionHeaderInput } from '../schemas/BlockTemplates.js';
13
- import type { TransformStyleId, TransformOptions, TransformResult } from './types.js';
13
+ import type { TransformStyleInput, TransformOptions, TransformResult } from './types.js';
14
14
  import { resolveTransformStyle } from './registry.js';
15
15
  import { analyzeBlocks, extractDocImages } from './blockAnalyzer.js';
16
16
  import { selectAndBuild } from './templateSelector.js';
@@ -22,16 +22,16 @@ import { hashString } from '../random/SeededRandom.js';
22
22
  * optimized for visual presentation.
23
23
  *
24
24
  * @param doc - The source Doc (not mutated).
25
- * @param styleId - Registered transform style id (e.g. 'documentary', 'magazine').
26
- * @param options - Optional seed, images, theme override.
25
+ * @param style - Built-in/registry id or a call-scoped declarative style.
26
+ * @param options - Optional seed, images, theme override, and explicit registry.
27
27
  * @returns TransformResult with the new Doc and stats.
28
28
  */
29
29
  export function applyTransform(
30
30
  doc: Doc,
31
- styleId: TransformStyleId,
31
+ style: TransformStyleInput,
32
32
  options?: TransformOptions,
33
33
  ): TransformResult {
34
- const baseConfig = resolveTransformStyle(styleId);
34
+ const baseConfig = resolveTransformStyle(style, options?.registry);
35
35
  const config = options?.overrides
36
36
  ? { ...baseConfig, ...options.overrides, id: baseConfig.id }
37
37
  : baseConfig;
@@ -1,5 +1,5 @@
1
1
  export { applyTransform } from './applyTransform.js';
2
- export { registerTransformStyle, unregisterTransformStyle } from './registry.js';
2
+ export { createTransformStyleRegistry } from './registry.js';
3
3
 
4
4
  export {
5
5
  resolveTransformStyle,
@@ -10,8 +10,10 @@ export {
10
10
 
11
11
  export type {
12
12
  TransformStyleId,
13
+ TransformStyleInput,
13
14
  TransformStyleSummary,
14
15
  TransformStyleConfig,
16
+ TransformStyleRegistry,
15
17
  TransformImage,
16
18
  TransformOptions,
17
19
  TransformResult,
@@ -1,78 +1,198 @@
1
1
  /**
2
2
  * Transform Style Registry
3
3
  *
4
- * Central registry of built-in transform styles plus a dynamic
5
- * registration API. Follows the same pattern as themeLibrary.ts /
6
- * `registerTheme` — registered styles take precedence over built-ins,
7
- * unknown ids fall back to the default.
4
+ * Immutable built-in lookup plus explicit caller-owned custom registries.
5
+ * There is deliberately no process-global mutable registry.
8
6
  */
9
7
 
10
- import type { TransformStyleConfig, TransformStyleSummary } from './types.js';
8
+ import type { TransformStyleConfig, TransformStyleInput, TransformStyleRegistry } from './types.js';
11
9
  import { documentaryStyle } from './styles/documentary.js';
12
10
  import { magazineStyle } from './styles/magazine.js';
13
11
  import { dataDrivenStyle } from './styles/dataDriven.js';
14
12
  import { narrativeStyle } from './styles/narrative.js';
15
13
  import { minimalStyle } from './styles/minimal.js';
14
+ import { cloneAndFreezeData } from '../internal/immutable.js';
16
15
 
17
16
  /** All built-in transform styles, keyed by id. */
18
- const TRANSFORM_STYLES: Record<string, TransformStyleConfig> = {
19
- [documentaryStyle.id]: documentaryStyle,
20
- [magazineStyle.id]: magazineStyle,
21
- [dataDrivenStyle.id]: dataDrivenStyle,
22
- [narrativeStyle.id]: narrativeStyle,
23
- [minimalStyle.id]: minimalStyle,
24
- };
25
-
26
- /**
27
- * Id aliases: the data-driven style's file/export are named `dataDriven`
28
- * but its registered id is hyphenated — accept both.
29
- */
30
- const STYLE_ALIASES: Record<string, string> = {
31
- dataDriven: 'data-driven',
32
- };
33
-
34
- /** Host-registered custom styles (registered ids win over built-ins). */
35
- const registeredStyles = new Map<string, TransformStyleConfig>();
17
+ const TRANSFORM_STYLES: Readonly<Record<string, TransformStyleConfig>> = Object.freeze({
18
+ [documentaryStyle.id]: cloneAndFreezeData(documentaryStyle),
19
+ [magazineStyle.id]: cloneAndFreezeData(magazineStyle),
20
+ [dataDrivenStyle.id]: cloneAndFreezeData(dataDrivenStyle),
21
+ [narrativeStyle.id]: cloneAndFreezeData(narrativeStyle),
22
+ [minimalStyle.id]: cloneAndFreezeData(minimalStyle),
23
+ });
36
24
 
37
25
  /** Default style used when no id is provided. */
38
26
  export const DEFAULT_TRANSFORM_STYLE_ID = 'documentary';
39
27
 
40
- /**
41
- * Register (or replace) a custom transform style at runtime. Hosts use
42
- * this the same way they use `registerTheme` — to add product-specific
43
- * styles without forking the registry.
44
- */
45
- export function registerTransformStyle(style: TransformStyleConfig): void {
46
- registeredStyles.set(style.id, style);
28
+ const EXTRACTION_TYPES = new Set([
29
+ 'stat',
30
+ 'date',
31
+ 'quote',
32
+ 'comparison',
33
+ 'fact',
34
+ 'impactLine',
35
+ 'list',
36
+ 'definition',
37
+ ]);
38
+ const TRANSITION_STYLES = new Set(['cut', 'fade', 'dissolve', 'mixed']);
39
+
40
+ function validateTransformStyle(style: TransformStyleConfig): void {
41
+ const candidate = style as unknown as Record<string, unknown>;
42
+ const fail = (message: string): never => {
43
+ throw new TypeError(`Invalid transform style: ${message}`);
44
+ };
45
+ for (const key of ['id', 'name', 'description']) {
46
+ if (typeof candidate[key] !== 'string' || !(candidate[key] as string).trim()) {
47
+ fail(`${key} must be a non-empty string`);
48
+ }
49
+ }
50
+ for (const key of ['minConfidence', 'transformRatio']) {
51
+ const value = candidate[key];
52
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
53
+ fail(`${key} must be a finite number between 0 and 1`);
54
+ }
55
+ }
56
+ if (
57
+ !Array.isArray(candidate.preferredTypes) ||
58
+ candidate.preferredTypes.some((type) => !EXTRACTION_TYPES.has(type))
59
+ ) {
60
+ fail('preferredTypes contains an unknown extraction type');
61
+ }
62
+ if (
63
+ !Array.isArray(candidate.colorSchemes) ||
64
+ candidate.colorSchemes.length === 0 ||
65
+ candidate.colorSchemes.some((scheme) => typeof scheme !== 'string' || !scheme)
66
+ ) {
67
+ fail('colorSchemes must contain at least one non-empty string');
68
+ }
69
+ for (const key of ['insertSectionHeaders', 'interleaveImages']) {
70
+ if (typeof candidate[key] !== 'boolean') fail(`${key} must be a boolean`);
71
+ }
72
+ const blocks = candidate.blocksPerSection as { max?: unknown } | undefined;
73
+ if (!blocks || !Number.isInteger(blocks.max) || (blocks.max as number) < 1) {
74
+ fail('blocksPerSection.max must be a positive integer');
75
+ }
76
+ if (
77
+ typeof candidate.transitionStyle !== 'string' ||
78
+ !TRANSITION_STYLES.has(candidate.transitionStyle)
79
+ ) {
80
+ fail('transitionStyle must be cut, fade, dissolve, or mixed');
81
+ }
82
+ const templateMap = candidate.templateMap;
83
+ if (templateMap !== undefined) {
84
+ if (!templateMap || typeof templateMap !== 'object' || Array.isArray(templateMap)) {
85
+ fail('templateMap must be an object');
86
+ }
87
+ for (const [type, template] of Object.entries(templateMap as Record<string, unknown>)) {
88
+ if (!EXTRACTION_TYPES.has(type) || typeof template !== 'string' || !template) {
89
+ fail('templateMap must map known extraction types to non-empty template names');
90
+ }
91
+ }
92
+ }
93
+ if (
94
+ candidate.suggestedThemeId !== undefined &&
95
+ (typeof candidate.suggestedThemeId !== 'string' || !candidate.suggestedThemeId)
96
+ ) {
97
+ fail('suggestedThemeId must be a non-empty string');
98
+ }
99
+ const pacingValue = candidate.pacing;
100
+ if (pacingValue !== undefined) {
101
+ if (!pacingValue || typeof pacingValue !== 'object' || Array.isArray(pacingValue)) {
102
+ fail('pacing must be an object');
103
+ }
104
+ const pacing = pacingValue as { intro?: unknown; outro?: unknown };
105
+ for (const key of ['intro', 'outro'] as const) {
106
+ if (pacing[key] !== undefined && typeof pacing[key] !== 'boolean') {
107
+ fail(`pacing.${key} must be a boolean`);
108
+ }
109
+ }
110
+ }
111
+ const budget = candidate.budget as { slidesPerMinute?: unknown } | undefined;
112
+ if (
113
+ candidate.budget !== undefined &&
114
+ (!budget || typeof budget !== 'object' || Array.isArray(budget))
115
+ ) {
116
+ fail('budget must be an object');
117
+ }
118
+ if (budget && budget.slidesPerMinute !== undefined) {
119
+ const value = budget.slidesPerMinute;
120
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
121
+ fail('budget.slidesPerMinute must be a positive finite number');
122
+ }
123
+ }
47
124
  }
48
125
 
49
- /** Remove a previously registered custom style. */
50
- export function unregisterTransformStyle(id: string): void {
51
- registeredStyles.delete(id);
126
+ function styleSnapshot(style: TransformStyleConfig): TransformStyleConfig {
127
+ validateTransformStyle(style);
128
+ return cloneAndFreezeData(style);
52
129
  }
53
130
 
54
- /**
55
- * Resolve a transform style by id. Registered styles win over built-ins;
56
- * aliases are honored; unknown ids fall back to the default style.
57
- */
58
- export function resolveTransformStyle(id: string): TransformStyleConfig {
59
- const canonical = STYLE_ALIASES[id] ?? id;
131
+ /** Stored documents emitted before the canonical kebab-case id. Not exported. */
132
+ function normalizeBuiltinStyleId(id: string): string {
133
+ return id === 'dataDriven' ? 'data-driven' : id;
134
+ }
135
+
136
+ function resolveBuiltinStyle(id: string): TransformStyleConfig {
60
137
  return (
61
- registeredStyles.get(canonical) ??
62
- TRANSFORM_STYLES[canonical] ??
63
- TRANSFORM_STYLES[DEFAULT_TRANSFORM_STYLE_ID]
138
+ TRANSFORM_STYLES[normalizeBuiltinStyleId(id)] ?? TRANSFORM_STYLES[DEFAULT_TRANSFORM_STYLE_ID]
64
139
  );
65
140
  }
66
141
 
67
- /** Get all known style ids (built-ins plus registered). */
68
- export function getTransformStyleIds(): string[] {
69
- return Array.from(new Set([...Object.keys(TRANSFORM_STYLES), ...registeredStyles.keys()]));
142
+ /**
143
+ * Create a caller-owned registry. Custom styles are validated, cloned, and
144
+ * deeply frozen at registration time. Built-in resolution remains in the
145
+ * top-level helpers, separate from the registry's caller-owned state.
146
+ */
147
+ export function createTransformStyleRegistry(
148
+ initialStyles: readonly TransformStyleConfig[] = [],
149
+ ): TransformStyleRegistry {
150
+ const registeredStyles = new Map<string, TransformStyleConfig>();
151
+ const registry: TransformStyleRegistry = {
152
+ register(style): void {
153
+ const snapshot = styleSnapshot(style);
154
+ registeredStyles.set(snapshot.id, snapshot);
155
+ },
156
+ unregister(id): boolean {
157
+ return registeredStyles.delete(id);
158
+ },
159
+ get(id): TransformStyleConfig | undefined {
160
+ return registeredStyles.get(id);
161
+ },
162
+ list(): TransformStyleConfig[] {
163
+ return Array.from(registeredStyles.values());
164
+ },
165
+ };
166
+ for (const style of initialStyles) registry.register(style);
167
+ return Object.freeze(registry);
168
+ }
169
+
170
+ /**
171
+ * Resolve a built-in id, explicit-registry id, or call-scoped style config.
172
+ * Unknown ids fall back to the default style.
173
+ */
174
+ export function resolveTransformStyle(
175
+ style: TransformStyleInput,
176
+ registry?: TransformStyleRegistry,
177
+ ): TransformStyleConfig {
178
+ if (typeof style !== 'string') return styleSnapshot(style);
179
+ return registry?.get(style) ?? resolveBuiltinStyle(style);
180
+ }
181
+
182
+ /** Get built-in ids plus ids from an optional caller-owned registry. */
183
+ export function getTransformStyleIds(registry?: TransformStyleRegistry): string[] {
184
+ return Array.from(
185
+ new Set([
186
+ ...Object.keys(TRANSFORM_STYLES),
187
+ ...(registry?.list().map((style) => style.id) ?? []),
188
+ ]),
189
+ );
70
190
  }
71
191
 
72
- /** Get summary info for all styles (for UI dropdowns). */
73
- export function getTransformStyleSummaries(): TransformStyleSummary[] {
74
- return getTransformStyleIds().map((id) => {
75
- const { name, description } = resolveTransformStyle(id);
192
+ /** Get summaries for built-ins plus an optional caller-owned registry. */
193
+ export function getTransformStyleSummaries(registry?: TransformStyleRegistry) {
194
+ return getTransformStyleIds(registry).map((id) => {
195
+ const { name, description } = resolveTransformStyle(id, registry);
76
196
  return { id, name, description };
77
197
  });
78
198
  }
@@ -22,6 +22,7 @@ import type { TransformStyleConfig, TransformImage } from './types.js';
22
22
  import type { AnalyzedBlock } from './blockAnalyzer.js';
23
23
  import { mapElementToBlock } from '../generate/templateMapper.js';
24
24
  import { SeededRandom } from '../random/SeededRandom.js';
25
+ import { estimateTimeFromText } from '../timing/narrationTiming.js';
25
26
 
26
27
  /** Accent positions to rotate through. */
27
28
  const ACCENT_POSITIONS: AccentPosition[] = [
@@ -219,6 +220,7 @@ function buildBlockSequence(
219
220
  audioSegment: ab.block.audioSegment,
220
221
  title: ab.block.title,
221
222
  colorScheme: config.colorSchemes[colorIndex % config.colorSchemes.length],
223
+ sourceBlockId: ab.block.id,
222
224
  };
223
225
  blocks.push(headerBlock);
224
226
  insertedCount++;
@@ -245,13 +247,27 @@ function buildBlockSequence(
245
247
  accentPositionIndex++;
246
248
  }
247
249
 
250
+ // `sourcePosition` is a CHAR OFFSET into the analyzed plain text;
251
+ // `sourceStartTime` is doc-timeline SECONDS. Convert via spoken-word
252
+ // proportion over the source block's timing — which, when the doc
253
+ // carries a narration take (applyNarrationTiming ran upstream), is
254
+ // the recorded voice: summarized slides land where the words are
255
+ // actually spoken.
256
+ const slideShare = ab.block.duration > 0 ? ab.block.duration / extractions.length : 6;
257
+ const offsetInBlock =
258
+ ab.block.duration > 0
259
+ ? estimateTimeFromText(ab.plainText, sel.element.sourcePosition, ab.block.duration)
260
+ : 0;
248
261
  let templateBlock = mapElementToBlock(sel.element, {
249
262
  id: `transform-${blockIdCounter++}`,
250
- duration: ab.block.duration > 0 ? ab.block.duration / extractions.length : 6,
263
+ duration: slideShare,
251
264
  audioSegment: ab.block.audioSegment,
252
265
  colorScheme,
253
266
  accentImage,
254
- sourceStartTime: sel.element.sourcePosition,
267
+ sourceStartTime: ab.block.startTime + offsetInBlock,
268
+ sourceDuration: slideShare,
269
+ sourceBlockId: ab.block.id,
270
+ sourceCharOffset: sel.element.sourcePosition,
255
271
  });
256
272
 
257
273
  // Style-level extraction→template remap (v2 contract). Only known
@@ -302,6 +318,7 @@ function buildBlockSequence(
302
318
  imageSrc: img.src,
303
319
  imageAlt: img.alt ?? '',
304
320
  ambientMotion: rng.pick(AMBIENT_MOTIONS) ?? 'zoomIn',
321
+ sourceBlockId: ab.block.id,
305
322
  };
306
323
  blocks.push(imageBlock);
307
324
  imageIndex++;
@@ -327,6 +344,8 @@ function translateTemplateBlock(block: TemplateBlock, target: string): TemplateB
327
344
  audioSegment: block.audioSegment,
328
345
  sourceStartTime: block.sourceStartTime,
329
346
  sourceDuration: block.sourceDuration,
347
+ sourceBlockId: block.sourceBlockId,
348
+ sourceCharOffset: block.sourceCharOffset,
330
349
  };
331
350
 
332
351
  if (block.template === 'quote') {