@bendyline/squisq 1.5.1 → 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 (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-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-2OIBZYKP.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-TEEEILMP.js → chunk-66QO7XFB.js} +19 -292
  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-LH7I6SH7.js → chunk-GF3BVNLU.js} +6360 -5723
  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-6ATE2PSM.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-RUDYOTA6.js → chunk-TUWMK62B.js} +110 -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 +134 -34
  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 +253 -81
  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__/coverBlock.test.ts +33 -2
  97. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  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 +41 -0
  114. package/src/__tests__/markdownToDoc.test.ts +5 -2
  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 +49 -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 +126 -39
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +135 -1
  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/coverBlock.ts +49 -13
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/drawingBlock.ts +2 -2
  174. package/src/doc/templates/fallbackBlock.ts +1 -1
  175. package/src/doc/templates/index.ts +134 -279
  176. package/src/doc/templates/inputDescriptors.ts +20 -1
  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 +157 -7
  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/convert.ts +11 -1
  204. package/src/markdown/index.ts +1 -0
  205. package/src/markdown/stringify.ts +4 -9
  206. package/src/markdown/utils.ts +36 -14
  207. package/src/narration/align.ts +514 -0
  208. package/src/narration/features.ts +231 -0
  209. package/src/narration/index.ts +86 -0
  210. package/src/narration/nuclei.ts +118 -0
  211. package/src/narration/pacing.ts +211 -0
  212. package/src/narration/script.ts +239 -0
  213. package/src/narration/session.ts +100 -0
  214. package/src/narration/sidecar.ts +187 -0
  215. package/src/narration/syllables.ts +85 -0
  216. package/src/narration/trace.ts +50 -0
  217. package/src/narration/types.ts +299 -0
  218. package/src/narration/vad.ts +131 -0
  219. package/src/recommend/numberHighlight.ts +25 -0
  220. package/src/recommend/templates.ts +108 -12
  221. package/src/schemas/BlockTemplates.ts +156 -7
  222. package/src/schemas/Doc.ts +72 -9
  223. package/src/schemas/Media.ts +12 -2
  224. package/src/schemas/Theme.ts +45 -25
  225. package/src/schemas/Viewport.ts +2 -2
  226. package/src/schemas/fontStacks.ts +79 -0
  227. package/src/schemas/themeCompile.ts +15 -0
  228. package/src/schemas/themeConstants.ts +2 -0
  229. package/src/schemas/themeLibrary.ts +25 -19
  230. package/src/schemas/themeValidator.ts +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-2OIBZYKP.js.map +0 -1
  247. package/dist/chunk-6ATE2PSM.js.map +0 -1
  248. package/dist/chunk-A4O7GIWE.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-HWVFJAAH.js.map +0 -1
  251. package/dist/chunk-IMSCRLLV.js.map +0 -1
  252. package/dist/chunk-JELIRVDP.js +0 -24
  253. package/dist/chunk-JELIRVDP.js.map +0 -1
  254. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  255. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  256. package/dist/chunk-LH7I6SH7.js.map +0 -1
  257. package/dist/chunk-RUDYOTA6.js.map +0 -1
  258. package/dist/chunk-T5UK6YOB.js.map +0 -1
  259. package/dist/chunk-TEEEILMP.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 -1777
  266. package/dist/story/index.js +0 -273
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseMarkdown, stringifyMarkdown } from '../markdown/index';
3
+ import { markdownToDoc } from '../doc/markdownToDoc';
4
+ import { docToMarkdown } from '../doc/docToMarkdown';
5
+ import { materializeLayers } from './materializeTestUtils';
6
+ import { profileBlockContents, recommendTemplatesForBlock } from '../recommend/templates';
7
+ import type { Block, Layer } from '../schemas/Doc';
8
+ import type { TreeTemplateItem } from '../schemas/BlockTemplates';
9
+
10
+ const TREE_ART = ['src/', '├── index.ts', '├── components/', '│ └── App.tsx', '└── utils/'].join(
11
+ '\n',
12
+ );
13
+ const fenced = (art: string, lang = ''): string => '```' + lang + '\n' + art + '\n```';
14
+ const AUTO_DOC = ['# Project', '', '## Layout', '', fenced(TREE_ART), ''].join('\n');
15
+
16
+ function convert(md: string, options?: Parameters<typeof markdownToDoc>[1]) {
17
+ return markdownToDoc(parseMarkdown(md), { generateCoverBlock: false, ...options });
18
+ }
19
+ function findBlock(doc: { blocks: Block[] }, title: string): Block | undefined {
20
+ const walk = (blocks: Block[]): Block | undefined => {
21
+ for (const b of blocks) {
22
+ if (b.title === title) return b;
23
+ const inner = b.children ? walk(b.children) : undefined;
24
+ if (inner) return inner;
25
+ }
26
+ return undefined;
27
+ };
28
+ return walk(doc.blocks);
29
+ }
30
+
31
+ describe('auto-template conversion of tree fences', () => {
32
+ it('converts a heading with a lone tree fence into an ephemeral tree block', () => {
33
+ const block = findBlock(convert(AUTO_DOC), 'Layout');
34
+ expect(block?.template).toBe('tree');
35
+ expect(block?.autoTemplate).toBe(true);
36
+ const items = block?.templateData?.items as TreeTemplateItem[];
37
+ expect(items?.map((i) => i.label)).toEqual(['src/']);
38
+ expect(items?.[0].children.map((c) => c.label)).toEqual(['index.ts', 'components/', 'utils/']);
39
+ expect(block?.templateData?.title).toBe('Layout');
40
+ });
41
+
42
+ it('round-trips losslessly: fence byte-identical, no annotation injected', () => {
43
+ const output = stringifyMarkdown(docToMarkdown(convert(AUTO_DOC)));
44
+ expect(output).toContain(TREE_ART);
45
+ expect(output).not.toContain('{[tree');
46
+ });
47
+
48
+ it('respects the autoTemplates kill switch', () => {
49
+ expect(findBlock(convert(AUTO_DOC, { autoTemplates: false }), 'Layout')?.template).not.toBe(
50
+ 'tree',
51
+ );
52
+ });
53
+
54
+ it('respects the frontmatter kill switch', () => {
55
+ const md = `---\nsquisq-auto-templates: false\n---\n\n${AUTO_DOC}`;
56
+ expect(findBlock(convert(md), 'Layout')?.template).not.toBe('tree');
57
+ });
58
+
59
+ it('does not convert a real-language fence', () => {
60
+ const md = ['## Layout', '', fenced(TREE_ART, 'bash'), ''].join('\n');
61
+ expect(findBlock(convert(md), 'Layout')?.template).not.toBe('tree');
62
+ });
63
+
64
+ it('does not convert when a table competes with the fence', () => {
65
+ const md = [
66
+ '## Layout',
67
+ '',
68
+ '| a | b |',
69
+ '|---|---|',
70
+ '| 1 | 2 |',
71
+ '',
72
+ fenced(TREE_ART),
73
+ '',
74
+ ].join('\n');
75
+ expect(findBlock(convert(md), 'Layout')?.template).not.toBe('tree');
76
+ });
77
+
78
+ it('does not steal a box-diagram fence (mutual exclusion)', () => {
79
+ const diagram = [
80
+ '┌────────┐',
81
+ '│ Alpha │',
82
+ '└───┬────┘',
83
+ ' ▼',
84
+ '┌────────┐',
85
+ '│ Beta │',
86
+ '└────────┘',
87
+ ].join('\n');
88
+ const block = findBlock(convert(['## Arch', '', fenced(diagram), ''].join('\n')), 'Arch');
89
+ expect(block?.template).toBe('diagram');
90
+ });
91
+ });
92
+
93
+ describe('explicit `tree`-tagged fence (survives flatten → round-trip)', () => {
94
+ // A flattened tree loses its connector lines, so an untagged fence would no
95
+ // longer auto-detect. The `tree` fence LANGUAGE is the durable "block tag"
96
+ // (it round-trips through markdown ↔ Tiptap as `class="language-tree"`),
97
+ // and explicit-lang detection accepts the flat art.
98
+ const FLAT_ART = ['README.md', 'index.ts', 'package.json'].join('\n');
99
+ const FLAT_DOC = ['# Project', '', '## Files', '', fenced(FLAT_ART, 'tree'), ''].join('\n');
100
+
101
+ it('a bare flat fence does NOT become a tree', () => {
102
+ const md = ['## Files', '', fenced(FLAT_ART), ''].join('\n');
103
+ expect(findBlock(convert(md), 'Files')?.template).not.toBe('tree');
104
+ });
105
+
106
+ it('a `tree`-tagged flat fence DOES become a tree', () => {
107
+ const block = findBlock(convert(FLAT_DOC), 'Files');
108
+ expect(block?.template).toBe('tree');
109
+ expect(block?.autoTemplate).toBe(true);
110
+ const items = block?.templateData?.items as TreeTemplateItem[];
111
+ expect(items?.map((i) => i.label)).toEqual(['README.md', 'index.ts', 'package.json']);
112
+ });
113
+
114
+ it('round-trips losslessly: fence + `tree` lang preserved, no annotation injected', () => {
115
+ const output = stringifyMarkdown(docToMarkdown(convert(FLAT_DOC)));
116
+ expect(output).toContain('```tree');
117
+ expect(output).toContain(FLAT_ART);
118
+ expect(output).not.toContain('{[tree');
119
+ // Re-importing the output keeps it a tree — the identity is sticky.
120
+ expect(findBlock(convert(output), 'Files')?.template).toBe('tree');
121
+ });
122
+
123
+ it('profiles a `tree`-tagged flat fence as hasTree', () => {
124
+ const md = parseMarkdown(fenced(FLAT_ART, 'tree'));
125
+ const profile = profileBlockContents(md.children as Parameters<typeof profileBlockContents>[0]);
126
+ expect(profile.hasTree).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe('explicit {[tree]} annotation', () => {
131
+ it('derives items even with autoTemplates off', () => {
132
+ const md = ['## Files {[tree]}', '', fenced(TREE_ART), ''].join('\n');
133
+ const block = findBlock(convert(md, { autoTemplates: false }), 'Files');
134
+ expect(block?.template).toBe('tree');
135
+ expect((block?.templateData?.items as TreeTemplateItem[])?.length).toBe(1);
136
+ });
137
+
138
+ it('converts a nested markdown list under an explicit annotation', () => {
139
+ const md = ['## Files {[tree]}', '', '- src/', ' - index.ts', ' - utils/', ''].join('\n');
140
+ const block = findBlock(convert(md, { autoTemplates: false }), 'Files');
141
+ const items = block?.templateData?.items as TreeTemplateItem[];
142
+ expect(items?.[0].label).toBe('src/');
143
+ expect(items?.[0].children.map((c) => c.label)).toEqual(['index.ts', 'utils/']);
144
+ });
145
+
146
+ it('records a diagnostic when the body does not parse as a tree', () => {
147
+ const md = ['## Files {[tree]}', '', 'just prose, no hierarchy at all', ''].join('\n');
148
+ const doc = convert(md);
149
+ expect(doc.diagnostics?.some((d) => d.code === 'tree-parse')).toBe(true);
150
+ });
151
+ });
152
+
153
+ describe('rendering a derived tree through materializeBlockLayers', () => {
154
+ it('emits a single tree layer with the item hierarchy', () => {
155
+ const block = findBlock(convert(AUTO_DOC), 'Layout') as Block;
156
+ const layers = materializeLayers(block, {});
157
+ const treeLayer = layers.find((l: Layer) => l.type === 'tree');
158
+ expect(treeLayer).toBeDefined();
159
+ if (treeLayer?.type === 'tree') {
160
+ expect(treeLayer.content.items[0].label).toBe('src/');
161
+ expect(treeLayer.content.items[0].isDir).toBe(true);
162
+ }
163
+ });
164
+ });
165
+
166
+ describe('duration/caption hygiene', () => {
167
+ it('excludes the consumed tree fence from captions', () => {
168
+ const doc = convert(AUTO_DOC);
169
+ const captionText = (doc.captions?.phrases ?? []).map((p) => p.text).join(' ');
170
+ expect(captionText).not.toContain('├──');
171
+ expect(captionText).not.toContain('index.ts');
172
+ });
173
+ });
174
+
175
+ describe('template picker recommendation', () => {
176
+ it('surfaces tree for a tree-fence profile', () => {
177
+ const md = parseMarkdown(fenced(TREE_ART));
178
+ const profile = profileBlockContents(md.children as Parameters<typeof profileBlockContents>[0]);
179
+ expect(profile.hasTree).toBe(true);
180
+ const { recommended } = recommendTemplatesForBlock(profile, ['tree', 'list', 'title']);
181
+ expect(recommended).toContain('tree');
182
+ });
183
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ parseTree,
4
+ renderTree,
5
+ treeFromTemplateData,
6
+ treeToTemplateData,
7
+ type Tree,
8
+ } from '../doc/treeview/index.js';
9
+ import { TREE_FIXTURES } from './fixtures/treeviewFixtures.js';
10
+
11
+ function semantic(t: Tree): string[] {
12
+ const out: string[] = [];
13
+ const walk = (nodes: Tree['roots'], depth: number): void => {
14
+ for (const n of nodes) {
15
+ out.push(`${depth}:${n.label}${n.comment ? ` #${n.comment}` : ''}`);
16
+ walk(n.children, depth + 1);
17
+ }
18
+ };
19
+ walk(t.roots, 0);
20
+ return out;
21
+ }
22
+
23
+ describe('renderTree — output', () => {
24
+ it('renders a clean Unicode tree with connectors', () => {
25
+ const t = parseTree(['a/', '├── b', '└── c/', ' └── d'].join('\n'));
26
+ expect(renderTree(t)).toBe(['a/', '├── b', '└── c/', ' └── d'].join('\n'));
27
+ });
28
+
29
+ it('renders ASCII vocabulary for ascii-style trees', () => {
30
+ const t = parseTree(['a/', '|-- b', '`-- c'].join('\n'));
31
+ const out = renderTree(t);
32
+ expect(out).toContain('|-- b');
33
+ expect(out).toContain('`-- c');
34
+ expect(out).not.toMatch(/[├└│─]/u);
35
+ });
36
+
37
+ it('honours a style override', () => {
38
+ const t = parseTree(['a/', '├── b', '└── c'].join('\n'));
39
+ expect(renderTree(t, { style: 'ascii' })).toContain('|-- b');
40
+ });
41
+
42
+ it('renders trailing comments', () => {
43
+ const t = parseTree(['p/', '└── a.ts # note'].join('\n'));
44
+ expect(renderTree(t)).toContain('a.ts # note');
45
+ });
46
+
47
+ it('roots carry no connector; children do', () => {
48
+ const t = parseTree(['root/', '└── only'].join('\n'));
49
+ const lines = renderTree(t).split('\n');
50
+ expect(lines[0]).toBe('root/');
51
+ expect(lines[1]).toBe('└── only');
52
+ });
53
+ });
54
+
55
+ describe('renderTree ↔ parseTree fixpoint', () => {
56
+ for (const fx of TREE_FIXTURES) {
57
+ it(`fixpoint + byte-idempotence: ${fx.name}`, () => {
58
+ const d1 = parseTree(fx.art);
59
+ const t1 = renderTree(d1);
60
+ const d2 = parseTree(t1);
61
+ expect(semantic(d2)).toEqual(semantic(d1));
62
+ const t2 = renderTree(d2);
63
+ const t3 = renderTree(parseTree(t2));
64
+ expect(t2).toBe(t1);
65
+ expect(t3).toBe(t1);
66
+ });
67
+ }
68
+
69
+ it('anti-churn: a structural edit only changes locally, then is stable', () => {
70
+ const t = parseTree(['a/', '├── b', '└── c'].join('\n'));
71
+ // Rename one node (structure-only edit) via the template-data round-trip.
72
+ const { items } = treeToTemplateData(t);
73
+ items[0].children[0].label = 'renamed';
74
+ const edited = treeFromTemplateData(items);
75
+ const r1 = renderTree(edited);
76
+ expect(r1).toContain('renamed');
77
+ expect(renderTree(parseTree(r1))).toBe(r1);
78
+ });
79
+ });
80
+
81
+ describe('templateData mapping round-trip', () => {
82
+ for (const fx of TREE_FIXTURES) {
83
+ it(`grid → items → grid preserves semantics: ${fx.name}`, () => {
84
+ const t = parseTree(fx.art);
85
+ const back = treeFromTemplateData(treeToTemplateData(t).items, { style: t.style });
86
+ expect(semantic(back)).toEqual(semantic(t));
87
+ // And renders identically.
88
+ expect(renderTree(back)).toBe(renderTree(t));
89
+ });
90
+ }
91
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, beforeEach } from 'vitest';
2
2
  import { MemoryContentContainer } from '../storage/ContentContainer';
3
+ import { scopeContainer } from '../storage/ScopedContentContainer';
3
4
  import {
4
5
  DocumentVersionManager,
5
6
  VERSIONS_PREFIX,
@@ -111,6 +112,49 @@ describe('saveVersion', () => {
111
112
  expect(result.version?.path).toBe(`${VERSIONS_PREFIX}index.20260430T152030Z.md`);
112
113
  expect(result.version?.basename).toBe('index');
113
114
  expect(await readUtf8(container, result.version!.path)).toBe('# hello');
115
+ expect(await readUtf8(container, '.gitignore')).toBe('.versions/\n');
116
+ });
117
+
118
+ it('writes the ignore rule at the root of a scoped *_files sidecar', async () => {
119
+ const parent = new MemoryContentContainer();
120
+ const sidecar = scopeContainer(parent, 'notes_files');
121
+ const now = new Date(Date.UTC(2026, 3, 30, 15, 20, 30));
122
+
123
+ const result = await saveVersion(sidecar, {
124
+ basename: 'notes',
125
+ content: '# hello',
126
+ now,
127
+ });
128
+
129
+ expect(result.saved).toBe(true);
130
+ expect(await readUtf8(parent, 'notes_files/.gitignore')).toBe('.versions/\n');
131
+ expect(await parent.exists(`notes_files/${result.version!.path}`)).toBe(true);
132
+ });
133
+
134
+ it('preserves existing .gitignore rules and does not duplicate the versions rule', async () => {
135
+ await container.writeDocument('# hello', 'index.md');
136
+ await container.writeFile(
137
+ '.gitignore',
138
+ new TextEncoder().encode('generated/\r\n'),
139
+ 'text/plain',
140
+ );
141
+ await saveVersion(container, { now: new Date(Date.UTC(2026, 3, 30, 15, 20, 30)) });
142
+ await saveVersion(container, { now: new Date(Date.UTC(2026, 3, 30, 15, 20, 31)) });
143
+
144
+ expect(await readUtf8(container, '.gitignore')).toBe('generated/\r\n.versions/\r\n');
145
+ });
146
+
147
+ it('backfills .gitignore when an existing snapshot is unchanged', async () => {
148
+ await container.writeDocument('# hello', 'index.md');
149
+ await saveVersion(container, { now: new Date(Date.UTC(2026, 3, 30, 15, 20, 30)) });
150
+ await container.removeFile('.gitignore');
151
+
152
+ const result = await saveVersion(container, {
153
+ now: new Date(Date.UTC(2026, 3, 30, 15, 20, 31)),
154
+ });
155
+
156
+ expect(result.reason).toBe('unchanged');
157
+ expect(await readUtf8(container, '.gitignore')).toBe('.versions/\n');
114
158
  });
115
159
 
116
160
  it('returns unchanged on identical second save', async () => {
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Apply narration timing from a document-anchored take to a Doc's block
3
+ * timeline.
4
+ *
5
+ * When a doc carries a document-spanning narration clip (a preamble
6
+ * `{[audio src=… anchor=document]}` → `doc.documentMedia`) whose
7
+ * `.timing.json` sidecar has per-block ranges (v3, written by the
8
+ * teleprompter's aligner), the blocks are re-timed so playback and
9
+ * exports advance in sync with the recorded voice. Runs as step 0 of
10
+ * `resolveAudioMapping`, so every surface that resolves audio gets it.
11
+ *
12
+ * Precedence: author-pinned `duration=`/`startTime=` heading attrs win
13
+ * over narration ranges (a conflicting pin gets an `info` diagnostic);
14
+ * narration ranges win over per-block audio-segment mapping and
15
+ * reading-time estimates.
16
+ */
17
+
18
+ import type { Block, Doc, DocDiagnostic } from '../schemas/Doc.js';
19
+ import type { MediaClip } from '../schemas/Media.js';
20
+ import type { ContentContainer } from '../storage/ContentContainer.js';
21
+ import { estimateTimeFromText } from '../timing/narrationTiming.js';
22
+ import { buildNarrationScript } from '../narration/script.js';
23
+ import {
24
+ parseNarrationTimingJson,
25
+ type NarrationTimingBlock,
26
+ type NarrationTimingJsonV3,
27
+ } from '../narration/sidecar.js';
28
+ import { flattenRenderableBlocks, getBlockBodyText, getPinnedBlockMeta } from './markdownToDoc.js';
29
+ import { scoreTextSimilarity } from './audioMapping.js';
30
+
31
+ export interface NarrationResolution {
32
+ doc: Doc;
33
+ /** False → the doc is returned unchanged (no clip / no sidecar / no match). */
34
+ applied: boolean;
35
+ /** The narration media path, for callers that bundle assets. */
36
+ clipSrc?: string;
37
+ }
38
+
39
+ /** Minimum similarity for the fuzzy block-matching rung. */
40
+ const MIN_BLOCK_SIMILARITY = 0.5;
41
+ /** Minimum whole-script similarity for the v1 proportional fallback. */
42
+ const MIN_V1_SCRIPT_SIMILARITY = 0.8;
43
+ /** Pins that disagree with the narration range by more than this get a diagnostic. */
44
+ const PIN_CONFLICT_TOLERANCE_SEC = 1;
45
+
46
+ interface BlockRangeSec {
47
+ startSec: number;
48
+ endSec: number;
49
+ }
50
+
51
+ /** Spoken text of a block, mirroring the narration script builder. */
52
+ function blockSpokenText(block: Block): string {
53
+ const title = block.title ? block.title.trim() : '';
54
+ const body = getBlockBodyText(block);
55
+ if (title && body) return `${title}\n${body}`;
56
+ return title || body;
57
+ }
58
+
59
+ /**
60
+ * Match sidecar block ranges to doc blocks with a deterministic ladder:
61
+ * exact blockId → text similarity (greedy best-first, ≥ 0.5) →
62
+ * in-order zip when the leftover counts agree. Unmatchable sidecar
63
+ * blocks are dropped.
64
+ */
65
+ function matchSidecarBlocks(
66
+ flat: Block[],
67
+ timing: NarrationTimingJsonV3,
68
+ ): Map<string, BlockRangeSec> {
69
+ const ranges = new Map<string, BlockRangeSec>();
70
+ const remainingBlocks = new Set(flat.map((b) => b.id));
71
+ const remainingSidecar: NarrationTimingBlock[] = [];
72
+
73
+ const byId = new Map(flat.map((b) => [b.id, b]));
74
+ for (const entry of timing.blocks) {
75
+ if (entry.blockId && byId.has(entry.blockId) && remainingBlocks.has(entry.blockId)) {
76
+ ranges.set(entry.blockId, { startSec: entry.startSec, endSec: entry.endSec });
77
+ remainingBlocks.delete(entry.blockId);
78
+ } else {
79
+ remainingSidecar.push(entry);
80
+ }
81
+ }
82
+
83
+ // Fuzzy rung: greedy best pair first, each side used once.
84
+ if (remainingSidecar.length > 0 && remainingBlocks.size > 0) {
85
+ const candidates: Array<{ score: number; entry: NarrationTimingBlock; blockId: string }> = [];
86
+ for (const entry of remainingSidecar) {
87
+ const slice = timing.sourceText.slice(entry.charStart, entry.charEnd);
88
+ for (const block of flat) {
89
+ if (!remainingBlocks.has(block.id)) continue;
90
+ const score = scoreTextSimilarity(slice, blockSpokenText(block));
91
+ if (score >= MIN_BLOCK_SIMILARITY) candidates.push({ score, entry, blockId: block.id });
92
+ }
93
+ }
94
+ candidates.sort((a, b) => b.score - a.score || a.entry.blockIndex - b.entry.blockIndex);
95
+ const usedEntries = new Set<NarrationTimingBlock>();
96
+ for (const candidate of candidates) {
97
+ if (usedEntries.has(candidate.entry) || !remainingBlocks.has(candidate.blockId)) continue;
98
+ ranges.set(candidate.blockId, {
99
+ startSec: candidate.entry.startSec,
100
+ endSec: candidate.entry.endSec,
101
+ });
102
+ usedEntries.add(candidate.entry);
103
+ remainingBlocks.delete(candidate.blockId);
104
+ }
105
+ // Order rung: leftover counts equal → zip in document/sidecar order.
106
+ const leftoverEntries = remainingSidecar.filter((e) => !usedEntries.has(e));
107
+ if (leftoverEntries.length > 0 && leftoverEntries.length === remainingBlocks.size) {
108
+ const leftoverBlocks = flat.filter((b) => remainingBlocks.has(b.id));
109
+ leftoverEntries.sort((a, b) => a.blockIndex - b.blockIndex);
110
+ leftoverBlocks.forEach((block, i) => {
111
+ const entry = leftoverEntries[i];
112
+ ranges.set(block.id, { startSec: entry.startSec, endSec: entry.endSec });
113
+ });
114
+ }
115
+ }
116
+
117
+ return ranges;
118
+ }
119
+
120
+ /**
121
+ * v1 fallback: no block ranges in the sidecar. If the take's source
122
+ * text still narrates this doc (whole-script similarity ≥ 0.8), derive
123
+ * per-block ranges by spoken-word proportion.
124
+ */
125
+ function proportionalRanges(
126
+ doc: Doc,
127
+ timing: NarrationTimingJsonV3,
128
+ ): Map<string, BlockRangeSec> | null {
129
+ const script = buildNarrationScript(doc);
130
+ if (script.blocks.length === 0) return null;
131
+ if (scoreTextSimilarity(script.sourceText, timing.sourceText) < MIN_V1_SCRIPT_SIMILARITY) {
132
+ return null;
133
+ }
134
+ const ranges = new Map<string, BlockRangeSec>();
135
+ const starts = script.blocks.map((range) =>
136
+ estimateTimeFromText(script.sourceText, range.charStart, timing.duration),
137
+ );
138
+ script.blocks.forEach((range, i) => {
139
+ ranges.set(range.blockId, {
140
+ startSec: starts[i],
141
+ endSec: i + 1 < starts.length ? starts[i + 1] : timing.duration,
142
+ });
143
+ });
144
+ return ranges;
145
+ }
146
+
147
+ interface RetimeContext {
148
+ clipStart: number;
149
+ ranges: Map<string, BlockRangeSec>;
150
+ diagnostics: DocDiagnostic[];
151
+ /** Timeline cursor (doc seconds) for unmatched blocks. */
152
+ cursor: number;
153
+ }
154
+
155
+ /** Recursively clone blocks, applying narration ranges to matched, unpinned blocks. */
156
+ function retimeBlocks(blocks: Block[], ctx: RetimeContext): Block[] {
157
+ return blocks.map((block) => {
158
+ const range = ctx.ranges.get(block.id);
159
+ const pinned = getPinnedBlockMeta(block);
160
+ const next: Block = { ...block };
161
+
162
+ if (range && pinned.duration == null && pinned.startTime == null) {
163
+ next.startTime = ctx.clipStart + range.startSec;
164
+ next.duration = Math.max(0, range.endSec - range.startSec);
165
+ ctx.cursor = Math.max(ctx.cursor, next.startTime + next.duration);
166
+ } else if (range) {
167
+ // Author pins win; surface real disagreement as an info diagnostic.
168
+ const pinnedStart = pinned.startTime ?? block.startTime;
169
+ const pinnedDuration = pinned.duration ?? block.duration;
170
+ const narrStart = ctx.clipStart + range.startSec;
171
+ const narrDuration = Math.max(0, range.endSec - range.startSec);
172
+ if (
173
+ Math.abs(pinnedStart - narrStart) > PIN_CONFLICT_TOLERANCE_SEC ||
174
+ Math.abs(pinnedDuration - narrDuration) > PIN_CONFLICT_TOLERANCE_SEC
175
+ ) {
176
+ ctx.diagnostics.push({
177
+ severity: 'info',
178
+ code: 'narration-pin-conflict',
179
+ message:
180
+ `Block timing is pinned (duration=/startTime=) but the recorded narration says ` +
181
+ `~${narrDuration.toFixed(1)}s starting at ~${narrStart.toFixed(1)}s. The pin wins; ` +
182
+ `remove it to follow the narration.`,
183
+ blockId: block.id,
184
+ });
185
+ }
186
+ ctx.cursor = Math.max(ctx.cursor, pinnedStart + pinnedDuration);
187
+ } else {
188
+ // No narration range (block added after the take, or unmatched):
189
+ // keep its duration but place it at the running cursor so the
190
+ // timeline stays monotonic.
191
+ next.startTime = ctx.cursor;
192
+ ctx.cursor += next.duration;
193
+ }
194
+
195
+ if (block.children && block.children.length > 0) {
196
+ next.children = retimeBlocks(block.children, ctx);
197
+ }
198
+ return next;
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Find the doc's document-anchored narration clip, load its timing
204
+ * sidecar, and re-time the blocks from its ranges. Pure with respect to
205
+ * the input doc — returns a new Doc (or the original when nothing
206
+ * applies).
207
+ */
208
+ export async function applyNarrationTiming(
209
+ doc: Doc,
210
+ container: ContentContainer,
211
+ ): Promise<NarrationResolution> {
212
+ const clips: MediaClip[] = (doc.documentMedia ?? []).filter((c) => c.anchor === 'document');
213
+ for (const clip of clips) {
214
+ if (!clip.src) continue;
215
+ const sidecarData = await container.readFile(`${clip.src}.timing.json`);
216
+ if (!sidecarData) continue;
217
+ const timing = parseNarrationTimingJson(sidecarData);
218
+ if (!timing) continue;
219
+
220
+ const flat = flattenRenderableBlocks(doc.blocks);
221
+ const ranges =
222
+ timing.blocks.length > 0 ? matchSidecarBlocks(flat, timing) : proportionalRanges(doc, timing);
223
+ if (!ranges || ranges.size === 0) continue;
224
+
225
+ const ctx: RetimeContext = {
226
+ clipStart: clip.startAt,
227
+ ranges,
228
+ diagnostics: [],
229
+ cursor: 0,
230
+ };
231
+ const blocks = retimeBlocks(doc.blocks, ctx);
232
+ const duration = Math.max(clip.startAt + timing.duration, ctx.cursor);
233
+ const retimed: Doc = {
234
+ ...doc,
235
+ blocks,
236
+ duration,
237
+ ...(ctx.diagnostics.length > 0
238
+ ? { diagnostics: [...(doc.diagnostics ?? []), ...ctx.diagnostics] }
239
+ : {}),
240
+ };
241
+ return { doc: retimed, applied: true, clipSrc: clip.src };
242
+ }
243
+ return { doc, applied: false };
244
+ }