@bendyline/squisq 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Types-sh2VRxfo.d.ts +15 -0
- package/dist/chunk-7UDSRZKG.js +103 -0
- package/dist/chunk-7UDSRZKG.js.map +1 -0
- package/dist/chunk-O5HCIUAD.js +577 -0
- package/dist/chunk-O5HCIUAD.js.map +1 -0
- package/dist/chunk-PJ7AODIM.js +3040 -0
- package/dist/chunk-PJ7AODIM.js.map +1 -0
- package/dist/chunk-QWVRN6I4.js +81 -0
- package/dist/chunk-QWVRN6I4.js.map +1 -0
- package/dist/chunk-S4F2RY37.js +92 -0
- package/dist/chunk-S4F2RY37.js.map +1 -0
- package/dist/chunk-URU6QMLY.js +888 -0
- package/dist/chunk-URU6QMLY.js.map +1 -0
- package/dist/chunk-VJN7UB2Z.js +145 -0
- package/dist/chunk-VJN7UB2Z.js.map +1 -0
- package/dist/doc/index.d.ts +3 -0
- package/dist/doc/index.js +126 -0
- package/dist/doc/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown/index.d.ts +237 -0
- package/dist/markdown/index.js +33 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +61 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/spatial/index.d.ts +111 -0
- package/dist/spatial/index.js +25 -0
- package/dist/spatial/index.js.map +1 -0
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/story/index.d.ts +691 -0
- package/dist/story/index.js +126 -0
- package/dist/story/index.js.map +1 -0
- package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
- package/dist/types-DHiv_Pnm.d.ts +496 -0
- package/package.json +91 -0
- package/src/__tests__/animationUtils.test.ts +77 -0
- package/src/__tests__/geohash.test.ts +109 -0
- package/src/__tests__/getLayers.test.ts +200 -0
- package/src/__tests__/haversine.test.ts +63 -0
- package/src/__tests__/localForageAdapter.test.ts +155 -0
- package/src/__tests__/markdown.test.ts +806 -0
- package/src/__tests__/markdownToDoc.test.ts +374 -0
- package/src/__tests__/storage.test.ts +60 -0
- package/src/__tests__/templates.test.ts +247 -0
- package/src/doc/docToMarkdown.ts +98 -0
- package/src/doc/getLayers.ts +152 -0
- package/src/doc/index.ts +8 -0
- package/src/doc/markdownToDoc.ts +277 -0
- package/src/doc/templates/accentImage.ts +338 -0
- package/src/doc/templates/captionUtils.ts +33 -0
- package/src/doc/templates/comparisonBar.ts +140 -0
- package/src/doc/templates/coverBlock.ts +156 -0
- package/src/doc/templates/dateEvent.ts +122 -0
- package/src/doc/templates/definitionCard.ts +129 -0
- package/src/doc/templates/factCard.ts +114 -0
- package/src/doc/templates/fullBleedQuote.ts +59 -0
- package/src/doc/templates/imageWithCaption.ts +167 -0
- package/src/doc/templates/index.ts +571 -0
- package/src/doc/templates/listBlock.ts +101 -0
- package/src/doc/templates/mapBlock.ts +132 -0
- package/src/doc/templates/persistentLayers.ts +522 -0
- package/src/doc/templates/photoGrid.ts +199 -0
- package/src/doc/templates/pullQuote.ts +119 -0
- package/src/doc/templates/quoteBlock.ts +112 -0
- package/src/doc/templates/sectionHeader.ts +105 -0
- package/src/doc/templates/statHighlight.ts +113 -0
- package/src/doc/templates/titleBlock.ts +100 -0
- package/src/doc/templates/twoColumn.ts +239 -0
- package/src/doc/templates/videoPullQuote.ts +128 -0
- package/src/doc/templates/videoWithCaption.ts +126 -0
- package/src/doc/utils/animationUtils.ts +135 -0
- package/src/doc/utils/themeUtils.ts +139 -0
- package/src/index.ts +5 -0
- package/src/markdown/convert.ts +897 -0
- package/src/markdown/htmlParse.ts +212 -0
- package/src/markdown/index.ts +110 -0
- package/src/markdown/parse.ts +103 -0
- package/src/markdown/stringify.ts +122 -0
- package/src/markdown/types.ts +605 -0
- package/src/markdown/utils.ts +165 -0
- package/src/ngeohash.d.ts +11 -0
- package/src/schemas/BlockTemplates.ts +706 -0
- package/src/schemas/Doc.ts +565 -0
- package/src/schemas/LayoutStrategy.ts +178 -0
- package/src/schemas/MediaProvider.ts +70 -0
- package/src/schemas/Theme.ts +235 -0
- package/src/schemas/Types.ts +15 -0
- package/src/schemas/Viewport.ts +91 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/themeLibrary.ts +492 -0
- package/src/spatial/Geohash.ts +168 -0
- package/src/spatial/Haversine.ts +59 -0
- package/src/spatial/index.ts +2 -0
- package/src/storage/LocalForageAdapter.ts +103 -0
- package/src/storage/LocalStorageAdapter.ts +57 -0
- package/src/storage/MemoryStorageAdapter.ts +37 -0
- package/src/storage/Storage.ts +28 -0
- package/src/storage/index.ts +5 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMarkdown, stringifyMarkdown } from '../markdown/index';
|
|
3
|
+
import type { MarkdownText } from '../markdown/types';
|
|
4
|
+
import { markdownToDoc, flattenBlocks, countBlocks, getBlockDepth } from '../doc/markdownToDoc';
|
|
5
|
+
import { docToMarkdown } from '../doc/docToMarkdown';
|
|
6
|
+
|
|
7
|
+
// Helper: strip positions from markdown nodes for cleaner assertions
|
|
8
|
+
function stripPositions(obj: unknown): unknown {
|
|
9
|
+
if (obj === null || obj === undefined) return obj;
|
|
10
|
+
if (typeof obj !== 'object') return obj;
|
|
11
|
+
if (Array.isArray(obj)) return obj.map(stripPositions);
|
|
12
|
+
const result: Record<string, unknown> = {};
|
|
13
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
14
|
+
if (key === 'position') continue;
|
|
15
|
+
result[key] = stripPositions(value);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('markdownToDoc', () => {
|
|
21
|
+
it('converts a simple heading + paragraph into a single block', () => {
|
|
22
|
+
const md = parseMarkdown('# Title\n\nHello world');
|
|
23
|
+
const doc = markdownToDoc(md);
|
|
24
|
+
|
|
25
|
+
expect(doc.blocks).toHaveLength(1);
|
|
26
|
+
expect(doc.blocks[0].id).toBe('title');
|
|
27
|
+
expect(doc.blocks[0].sourceHeading).toBeDefined();
|
|
28
|
+
expect(doc.blocks[0].sourceHeading!.depth).toBe(1);
|
|
29
|
+
expect(doc.blocks[0].contents).toHaveLength(1);
|
|
30
|
+
expect(doc.blocks[0].contents![0].type).toBe('paragraph');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('creates preamble block for content before first heading', () => {
|
|
34
|
+
const md = parseMarkdown('Some intro text\n\n# First Heading\n\nBody');
|
|
35
|
+
const doc = markdownToDoc(md);
|
|
36
|
+
|
|
37
|
+
expect(doc.blocks).toHaveLength(2);
|
|
38
|
+
// Preamble block
|
|
39
|
+
expect(doc.blocks[0].id).toBe('preamble');
|
|
40
|
+
expect(doc.blocks[0].sourceHeading).toBeUndefined();
|
|
41
|
+
expect(doc.blocks[0].contents).toHaveLength(1);
|
|
42
|
+
expect(doc.blocks[0].contents![0].type).toBe('paragraph');
|
|
43
|
+
// Heading block
|
|
44
|
+
expect(doc.blocks[1].sourceHeading).toBeDefined();
|
|
45
|
+
expect(doc.blocks[1].contents).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('nests H2 under H1 as children', () => {
|
|
49
|
+
const md = parseMarkdown(
|
|
50
|
+
'# Chapter\n\nIntro\n\n## Section A\n\nContent A\n\n## Section B\n\nContent B',
|
|
51
|
+
);
|
|
52
|
+
const doc = markdownToDoc(md);
|
|
53
|
+
|
|
54
|
+
expect(doc.blocks).toHaveLength(1); // One root block (H1)
|
|
55
|
+
const root = doc.blocks[0];
|
|
56
|
+
expect(root.sourceHeading!.depth).toBe(1);
|
|
57
|
+
expect(root.contents).toHaveLength(1); // "Intro" paragraph
|
|
58
|
+
|
|
59
|
+
expect(root.children).toHaveLength(2);
|
|
60
|
+
expect(root.children![0].sourceHeading!.depth).toBe(2);
|
|
61
|
+
expect(root.children![0].contents).toHaveLength(1); // "Content A"
|
|
62
|
+
expect(root.children![1].sourceHeading!.depth).toBe(2);
|
|
63
|
+
expect(root.children![1].contents).toHaveLength(1); // "Content B"
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('handles same-level headings as siblings', () => {
|
|
67
|
+
const md = parseMarkdown('## A\n\nText A\n\n## B\n\nText B\n\n## C\n\nText C');
|
|
68
|
+
const doc = markdownToDoc(md);
|
|
69
|
+
|
|
70
|
+
expect(doc.blocks).toHaveLength(3);
|
|
71
|
+
expect(doc.blocks[0].id).toBe('a');
|
|
72
|
+
expect(doc.blocks[1].id).toBe('b');
|
|
73
|
+
expect(doc.blocks[2].id).toBe('c');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles deep nesting H1→H2→H3→H4→H5→H6', () => {
|
|
77
|
+
const md = parseMarkdown(
|
|
78
|
+
'# L1\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n\nDeep content',
|
|
79
|
+
);
|
|
80
|
+
const doc = markdownToDoc(md);
|
|
81
|
+
|
|
82
|
+
expect(doc.blocks).toHaveLength(1); // One root
|
|
83
|
+
let block = doc.blocks[0];
|
|
84
|
+
for (let depth = 1; depth <= 5; depth++) {
|
|
85
|
+
expect(block.sourceHeading!.depth).toBe(depth);
|
|
86
|
+
expect(block.children).toHaveLength(1);
|
|
87
|
+
block = block.children![0];
|
|
88
|
+
}
|
|
89
|
+
expect(block.sourceHeading!.depth).toBe(6);
|
|
90
|
+
expect(block.contents).toHaveLength(1);
|
|
91
|
+
expect(block.children).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles mixed content between headings', () => {
|
|
95
|
+
const md = parseMarkdown(
|
|
96
|
+
'# Title\n\nParagraph\n\n- Item 1\n- Item 2\n\n```js\ncode\n```\n\n> Quote',
|
|
97
|
+
);
|
|
98
|
+
const doc = markdownToDoc(md);
|
|
99
|
+
|
|
100
|
+
expect(doc.blocks[0].contents).toHaveLength(4); // paragraph, list, code, blockquote
|
|
101
|
+
expect(doc.blocks[0].contents![0].type).toBe('paragraph');
|
|
102
|
+
expect(doc.blocks[0].contents![1].type).toBe('list');
|
|
103
|
+
expect(doc.blocks[0].contents![2].type).toBe('code');
|
|
104
|
+
expect(doc.blocks[0].contents![3].type).toBe('blockquote');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('deduplicates IDs for duplicate headings', () => {
|
|
108
|
+
const md = parseMarkdown('# Intro\n\n# Intro\n\n# Intro');
|
|
109
|
+
const doc = markdownToDoc(md);
|
|
110
|
+
|
|
111
|
+
expect(doc.blocks).toHaveLength(3);
|
|
112
|
+
expect(doc.blocks[0].id).toBe('intro');
|
|
113
|
+
expect(doc.blocks[1].id).toBe('intro-2');
|
|
114
|
+
expect(doc.blocks[2].id).toBe('intro-3');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('handles empty document', () => {
|
|
118
|
+
const md = parseMarkdown('');
|
|
119
|
+
const doc = markdownToDoc(md);
|
|
120
|
+
|
|
121
|
+
expect(doc.blocks).toHaveLength(0);
|
|
122
|
+
expect(doc.duration).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles document with only content (no headings)', () => {
|
|
126
|
+
const md = parseMarkdown('Just some text\n\nAnd more text');
|
|
127
|
+
const doc = markdownToDoc(md);
|
|
128
|
+
|
|
129
|
+
expect(doc.blocks).toHaveLength(1);
|
|
130
|
+
expect(doc.blocks[0].id).toBe('preamble');
|
|
131
|
+
expect(doc.blocks[0].sourceHeading).toBeUndefined();
|
|
132
|
+
expect(doc.blocks[0].contents).toHaveLength(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('sets default template on heading blocks', () => {
|
|
136
|
+
const md = parseMarkdown('# Hello');
|
|
137
|
+
const doc = markdownToDoc(md);
|
|
138
|
+
expect(doc.blocks[0].template).toBe('sectionHeader');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('respects custom options', () => {
|
|
142
|
+
const md = parseMarkdown('# Test');
|
|
143
|
+
const doc = markdownToDoc(md, {
|
|
144
|
+
articleId: 'custom-article',
|
|
145
|
+
defaultTemplate: 'titleBlock',
|
|
146
|
+
defaultDuration: 10,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(doc.articleId).toBe('custom-article');
|
|
150
|
+
expect(doc.blocks[0].template).toBe('titleBlock');
|
|
151
|
+
expect(doc.blocks[0].duration).toBe(10);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('respects custom ID generator', () => {
|
|
155
|
+
const md = parseMarkdown('# Hello\n\n## World');
|
|
156
|
+
const doc = markdownToDoc(md, {
|
|
157
|
+
generateId: (_heading, index) => `block-${index}`,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(doc.blocks[0].id).toBe('block-0');
|
|
161
|
+
expect(doc.blocks[0].children![0].id).toBe('block-1');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('calculates timing sequentially across all blocks', () => {
|
|
165
|
+
const md = parseMarkdown('# A\n\n## A1\n\n## A2\n\n# B');
|
|
166
|
+
const doc = markdownToDoc(md, { defaultDuration: 5 });
|
|
167
|
+
|
|
168
|
+
const flat = flattenBlocks(doc.blocks);
|
|
169
|
+
expect(flat).toHaveLength(4);
|
|
170
|
+
expect(flat[0].startTime).toBe(0); // # A
|
|
171
|
+
expect(flat[1].startTime).toBe(5); // ## A1
|
|
172
|
+
expect(flat[2].startTime).toBe(10); // ## A2
|
|
173
|
+
expect(flat[3].startTime).toBe(15); // # B
|
|
174
|
+
expect(doc.duration).toBe(20);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('handles heading depth going back up (H3 → H1)', () => {
|
|
178
|
+
const md = parseMarkdown('# Top\n\n### Deep\n\nDeep content\n\n# Back to top\n\nTop content');
|
|
179
|
+
const doc = markdownToDoc(md);
|
|
180
|
+
|
|
181
|
+
expect(doc.blocks).toHaveLength(2);
|
|
182
|
+
expect(doc.blocks[0].sourceHeading!.depth).toBe(1);
|
|
183
|
+
// H3 nests under H1 even though H2 was skipped
|
|
184
|
+
expect(doc.blocks[0].children).toHaveLength(1);
|
|
185
|
+
expect(doc.blocks[0].children![0].sourceHeading!.depth).toBe(3);
|
|
186
|
+
expect(doc.blocks[1].sourceHeading!.depth).toBe(1);
|
|
187
|
+
expect(doc.blocks[1].contents).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('flattenBlocks', () => {
|
|
192
|
+
it('flattens nested blocks depth-first', () => {
|
|
193
|
+
const md = parseMarkdown('# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B');
|
|
194
|
+
const doc = markdownToDoc(md);
|
|
195
|
+
const flat = flattenBlocks(doc.blocks);
|
|
196
|
+
|
|
197
|
+
expect(flat.map((b) => b.id)).toEqual(['a', 'a1', 'a1a', 'a2', 'b']);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('countBlocks', () => {
|
|
202
|
+
it('counts all blocks including nested children', () => {
|
|
203
|
+
const md = parseMarkdown('# A\n\n## A1\n\n### A1a\n\n## A2\n\n# B');
|
|
204
|
+
const doc = markdownToDoc(md);
|
|
205
|
+
|
|
206
|
+
expect(countBlocks(doc.blocks)).toBe(5);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('getBlockDepth', () => {
|
|
211
|
+
it('returns heading depth for heading blocks', () => {
|
|
212
|
+
const md = parseMarkdown('# Title');
|
|
213
|
+
const doc = markdownToDoc(md);
|
|
214
|
+
expect(getBlockDepth(doc.blocks[0])).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('returns 0 for preamble blocks', () => {
|
|
218
|
+
const md = parseMarkdown('Just text');
|
|
219
|
+
const doc = markdownToDoc(md);
|
|
220
|
+
expect(getBlockDepth(doc.blocks[0])).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('docToMarkdown (round-trip)', () => {
|
|
225
|
+
it('converts a doc back to equivalent markdown', () => {
|
|
226
|
+
const input = '# Hello\n\nWorld\n\n## Details\n\nMore content\n';
|
|
227
|
+
const md = parseMarkdown(input);
|
|
228
|
+
const doc = markdownToDoc(md);
|
|
229
|
+
const roundTripped = docToMarkdown(doc);
|
|
230
|
+
|
|
231
|
+
// The resulting MarkdownDocument should have the same structure
|
|
232
|
+
expect(stripPositions(roundTripped)).toEqual(stripPositions(md));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('round-trips through stringify', () => {
|
|
236
|
+
const input = '# Intro\n\nParagraph one.\n\n## Sub\n\nParagraph two.\n';
|
|
237
|
+
const md = parseMarkdown(input);
|
|
238
|
+
const doc = markdownToDoc(md);
|
|
239
|
+
const markdown2 = docToMarkdown(doc);
|
|
240
|
+
const output = stringifyMarkdown(markdown2);
|
|
241
|
+
// Parse again and compare ASTs
|
|
242
|
+
const md2 = parseMarkdown(output);
|
|
243
|
+
expect(stripPositions(md2)).toEqual(stripPositions(md));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('round-trips preamble content', () => {
|
|
247
|
+
const input = 'Preamble text\n\n# Heading\n\nBody\n';
|
|
248
|
+
const md = parseMarkdown(input);
|
|
249
|
+
const doc = markdownToDoc(md);
|
|
250
|
+
const roundTripped = docToMarkdown(doc);
|
|
251
|
+
expect(stripPositions(roundTripped)).toEqual(stripPositions(md));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('round-trips deeply nested headings', () => {
|
|
255
|
+
const input = '# L1\n\nT1\n\n## L2\n\nT2\n\n### L3\n\nT3\n\n#### L4\n\nT4\n';
|
|
256
|
+
const md = parseMarkdown(input);
|
|
257
|
+
const doc = markdownToDoc(md);
|
|
258
|
+
const roundTripped = docToMarkdown(doc);
|
|
259
|
+
expect(stripPositions(roundTripped)).toEqual(stripPositions(md));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('round-trips complex mixed content', () => {
|
|
263
|
+
const input = `# Title
|
|
264
|
+
|
|
265
|
+
Intro paragraph.
|
|
266
|
+
|
|
267
|
+
- List item 1
|
|
268
|
+
- List item 2
|
|
269
|
+
|
|
270
|
+
## Code Section
|
|
271
|
+
|
|
272
|
+
\`\`\`js
|
|
273
|
+
const x = 1;
|
|
274
|
+
\`\`\`
|
|
275
|
+
|
|
276
|
+
## Quote Section
|
|
277
|
+
|
|
278
|
+
> A blockquote
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
`;
|
|
282
|
+
const md = parseMarkdown(input);
|
|
283
|
+
const doc = markdownToDoc(md);
|
|
284
|
+
const roundTripped = docToMarkdown(doc);
|
|
285
|
+
expect(stripPositions(roundTripped)).toEqual(stripPositions(md));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('handles empty doc → empty markdown', () => {
|
|
289
|
+
const md = parseMarkdown('');
|
|
290
|
+
const doc = markdownToDoc(md);
|
|
291
|
+
const roundTripped = docToMarkdown(doc);
|
|
292
|
+
expect(roundTripped.children).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ============================================
|
|
297
|
+
// Template Annotation → Block mapping
|
|
298
|
+
// ============================================
|
|
299
|
+
|
|
300
|
+
describe('template annotation in markdownToDoc', () => {
|
|
301
|
+
it('sets block.template from heading annotation', () => {
|
|
302
|
+
const md = parseMarkdown('## Report {[chart]}\n\nSome data');
|
|
303
|
+
const doc = markdownToDoc(md);
|
|
304
|
+
|
|
305
|
+
expect(doc.blocks).toHaveLength(1);
|
|
306
|
+
expect(doc.blocks[0].template).toBe('chart');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('sets block.templateOverrides from key=value params', () => {
|
|
310
|
+
const md = parseMarkdown('## Stats {[statHighlight colorScheme=blue size=large]}\n\nContent');
|
|
311
|
+
const doc = markdownToDoc(md);
|
|
312
|
+
|
|
313
|
+
expect(doc.blocks[0].template).toBe('statHighlight');
|
|
314
|
+
expect(doc.blocks[0].templateOverrides).toEqual({
|
|
315
|
+
colorScheme: 'blue',
|
|
316
|
+
size: 'large',
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('uses defaultTemplate when no annotation is present', () => {
|
|
321
|
+
const md = parseMarkdown('## Plain Heading\n\nBody');
|
|
322
|
+
const doc = markdownToDoc(md);
|
|
323
|
+
|
|
324
|
+
expect(doc.blocks[0].template).toBe('sectionHeader');
|
|
325
|
+
expect(doc.blocks[0].templateOverrides).toBeUndefined();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('annotation stripping does not affect heading display text in sourceHeading', () => {
|
|
329
|
+
const md = parseMarkdown('## My Section {[chart]}\n\nBody');
|
|
330
|
+
const doc = markdownToDoc(md);
|
|
331
|
+
const heading = doc.blocks[0].sourceHeading!;
|
|
332
|
+
|
|
333
|
+
// The heading children should have the annotation stripped
|
|
334
|
+
expect(heading.templateAnnotation).toEqual({ template: 'chart' });
|
|
335
|
+
const textChild = heading.children.find((c) => c.type === 'text') as MarkdownText;
|
|
336
|
+
expect(textChild.value).toBe('My Section');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('round-trips annotation through markdownToDoc → docToMarkdown → stringify', () => {
|
|
340
|
+
const input = '## Data {[chart colorScheme=blue]}\n\nContent here';
|
|
341
|
+
const md = parseMarkdown(input);
|
|
342
|
+
const doc = markdownToDoc(md);
|
|
343
|
+
const roundTripped = docToMarkdown(doc);
|
|
344
|
+
const output = stringifyMarkdown(roundTripped);
|
|
345
|
+
|
|
346
|
+
expect(output).toContain('{[chart colorScheme=blue]}');
|
|
347
|
+
expect(output).toContain('Data');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('docToMarkdown injects annotation when block.template differs from default', () => {
|
|
351
|
+
// Create a doc programmatically with a non-default template
|
|
352
|
+
const md = parseMarkdown('## Section\n\nBody');
|
|
353
|
+
const doc = markdownToDoc(md);
|
|
354
|
+
|
|
355
|
+
// Programmatically set a custom template
|
|
356
|
+
doc.blocks[0].template = 'factCard';
|
|
357
|
+
doc.blocks[0].templateOverrides = { style: 'minimal' };
|
|
358
|
+
|
|
359
|
+
const roundTripped = docToMarkdown(doc);
|
|
360
|
+
const output = stringifyMarkdown(roundTripped);
|
|
361
|
+
|
|
362
|
+
expect(output).toContain('{[factCard style=minimal]}');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('nested headings preserve their own annotations', () => {
|
|
366
|
+
const input = '# Chapter {[titleBlock]}\n\nIntro\n\n## Section {[chart]}\n\nData';
|
|
367
|
+
const md = parseMarkdown(input);
|
|
368
|
+
const doc = markdownToDoc(md);
|
|
369
|
+
|
|
370
|
+
expect(doc.blocks[0].template).toBe('titleBlock');
|
|
371
|
+
expect(doc.blocks[0].children).toHaveLength(1);
|
|
372
|
+
expect(doc.blocks[0].children![0].template).toBe('chart');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryStorageAdapter } from '../storage/MemoryStorageAdapter';
|
|
3
|
+
|
|
4
|
+
describe('MemoryStorageAdapter', () => {
|
|
5
|
+
let storage: MemoryStorageAdapter;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
storage = new MemoryStorageAdapter();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('supports enumeration', () => {
|
|
12
|
+
expect(storage.supportsEnumeration).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('get returns null for missing key', async () => {
|
|
16
|
+
expect(await storage.get('nonexistent')).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('set and get round-trip', async () => {
|
|
20
|
+
await storage.set('key', { hello: 'world' });
|
|
21
|
+
expect(await storage.get('key')).toEqual({ hello: 'world' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('stores primitives', async () => {
|
|
25
|
+
await storage.set('str', 'hello');
|
|
26
|
+
await storage.set('num', 42);
|
|
27
|
+
await storage.set('bool', true);
|
|
28
|
+
expect(await storage.get('str')).toBe('hello');
|
|
29
|
+
expect(await storage.get('num')).toBe(42);
|
|
30
|
+
expect(await storage.get('bool')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('overwrites existing values', async () => {
|
|
34
|
+
await storage.set('key', 'first');
|
|
35
|
+
await storage.set('key', 'second');
|
|
36
|
+
expect(await storage.get('key')).toBe('second');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('remove deletes a key', async () => {
|
|
40
|
+
await storage.set('key', 'value');
|
|
41
|
+
await storage.remove('key');
|
|
42
|
+
expect(await storage.get('key')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clear removes all keys', async () => {
|
|
46
|
+
await storage.set('a', 1);
|
|
47
|
+
await storage.set('b', 2);
|
|
48
|
+
await storage.clear();
|
|
49
|
+
expect(await storage.get('a')).toBeNull();
|
|
50
|
+
expect(await storage.get('b')).toBeNull();
|
|
51
|
+
expect(await storage.keys()).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keys returns all stored keys', async () => {
|
|
55
|
+
await storage.set('alpha', 1);
|
|
56
|
+
await storage.set('beta', 2);
|
|
57
|
+
const keys = await storage.keys();
|
|
58
|
+
expect(keys.sort()).toEqual(['alpha', 'beta']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
expandTemplateBlock,
|
|
4
|
+
expandDocBlocks,
|
|
5
|
+
getAvailableTemplates,
|
|
6
|
+
hasTemplate,
|
|
7
|
+
templateRegistry,
|
|
8
|
+
createTemplateContext,
|
|
9
|
+
DEFAULT_THEME,
|
|
10
|
+
VIEWPORT_PRESETS,
|
|
11
|
+
} from '../doc/templates/index';
|
|
12
|
+
import type { TemplateBlock } from '../schemas/BlockTemplates';
|
|
13
|
+
|
|
14
|
+
describe('templateRegistry', () => {
|
|
15
|
+
it('contains all expected templates', () => {
|
|
16
|
+
const expected = [
|
|
17
|
+
'titleBlock',
|
|
18
|
+
'sectionHeader',
|
|
19
|
+
'statHighlight',
|
|
20
|
+
'quoteBlock',
|
|
21
|
+
'factCard',
|
|
22
|
+
'twoColumn',
|
|
23
|
+
'dateEvent',
|
|
24
|
+
'imageWithCaption',
|
|
25
|
+
'mapBlock',
|
|
26
|
+
'fullBleedQuote',
|
|
27
|
+
'listBlock',
|
|
28
|
+
'photoGrid',
|
|
29
|
+
'definitionCard',
|
|
30
|
+
'comparisonBar',
|
|
31
|
+
'pullQuote',
|
|
32
|
+
'videoWithCaption',
|
|
33
|
+
'videoPullQuote',
|
|
34
|
+
];
|
|
35
|
+
for (const name of expected) {
|
|
36
|
+
expect(templateRegistry).toHaveProperty(name);
|
|
37
|
+
const tpl = (templateRegistry as Record<string, unknown>)[name];
|
|
38
|
+
expect(typeof tpl).toBe('function');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('getAvailableTemplates', () => {
|
|
44
|
+
it('returns array of template names', () => {
|
|
45
|
+
const templates = getAvailableTemplates();
|
|
46
|
+
expect(templates).toContain('titleBlock');
|
|
47
|
+
expect(templates).toContain('sectionHeader');
|
|
48
|
+
expect(templates.length).toBeGreaterThanOrEqual(15);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('hasTemplate', () => {
|
|
53
|
+
it('returns true for existing templates', () => {
|
|
54
|
+
expect(hasTemplate('titleBlock')).toBe(true);
|
|
55
|
+
expect(hasTemplate('statHighlight')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns false for unknown templates', () => {
|
|
59
|
+
expect(hasTemplate('nonexistent')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('expandTemplateBlock', () => {
|
|
64
|
+
it('expands titleSlide template into layers', () => {
|
|
65
|
+
const block: TemplateBlock = {
|
|
66
|
+
template: 'titleBlock',
|
|
67
|
+
id: 'title-1',
|
|
68
|
+
duration: 10,
|
|
69
|
+
audioSegment: 0,
|
|
70
|
+
title: 'Test Title',
|
|
71
|
+
subtitle: 'Test Subtitle',
|
|
72
|
+
};
|
|
73
|
+
const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
|
|
74
|
+
const result = expandTemplateBlock(block, context);
|
|
75
|
+
|
|
76
|
+
expect(result.id).toBe('title-1');
|
|
77
|
+
expect(result.duration).toBe(10);
|
|
78
|
+
expect(result.layers ?? []).toBeInstanceOf(Array);
|
|
79
|
+
expect((result.layers ?? []).length).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('expands statHighlight template', () => {
|
|
83
|
+
const block: TemplateBlock = {
|
|
84
|
+
template: 'statHighlight',
|
|
85
|
+
id: 'stat-1',
|
|
86
|
+
duration: 8,
|
|
87
|
+
audioSegment: 0,
|
|
88
|
+
stat: '89%',
|
|
89
|
+
description: 'drop in salmon',
|
|
90
|
+
};
|
|
91
|
+
const context = createTemplateContext(DEFAULT_THEME, 0, 5, VIEWPORT_PRESETS.landscape);
|
|
92
|
+
const result = expandTemplateBlock(block, context);
|
|
93
|
+
|
|
94
|
+
expect((result.layers ?? []).length).toBeGreaterThan(0);
|
|
95
|
+
// Should have at least a shape background + text layers
|
|
96
|
+
const textLayers = (result.layers ?? []).filter((l) => l.type === 'text');
|
|
97
|
+
expect(textLayers.length).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns empty layers for unknown template', () => {
|
|
101
|
+
const block = {
|
|
102
|
+
template: 'nonexistent',
|
|
103
|
+
id: 'unknown-1',
|
|
104
|
+
duration: 5,
|
|
105
|
+
audioSegment: 0,
|
|
106
|
+
} as unknown as TemplateBlock;
|
|
107
|
+
const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
|
|
108
|
+
const result = expandTemplateBlock(block, context);
|
|
109
|
+
expect(result.layers ?? []).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('each registered template returns valid layers', () => {
|
|
113
|
+
const templates = getAvailableTemplates();
|
|
114
|
+
const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
|
|
115
|
+
|
|
116
|
+
for (const name of templates) {
|
|
117
|
+
// `getAvailableTemplates()` returns string[]; cast when creating a TemplateBlock
|
|
118
|
+
const block = {
|
|
119
|
+
template: name as TemplateBlock['template'],
|
|
120
|
+
id: `test-${name}`,
|
|
121
|
+
duration: 10,
|
|
122
|
+
audioSegment: 0,
|
|
123
|
+
// Provide common props that templates might need
|
|
124
|
+
title: 'Test',
|
|
125
|
+
subtitle: 'Subtitle',
|
|
126
|
+
stat: '42',
|
|
127
|
+
description: 'Description',
|
|
128
|
+
detail: 'Detail',
|
|
129
|
+
quote: 'Quote text',
|
|
130
|
+
attribution: 'Author',
|
|
131
|
+
fact: 'A fact',
|
|
132
|
+
explanation: 'Explanation',
|
|
133
|
+
text: 'Some text',
|
|
134
|
+
date: '1776',
|
|
135
|
+
left: { label: 'Left', sublabel: 'Sub' },
|
|
136
|
+
right: { label: 'Right', sublabel: 'Sub' },
|
|
137
|
+
items: ['one', 'two', 'three'],
|
|
138
|
+
images: [
|
|
139
|
+
{ src: 'test1.jpg', alt: 'Test 1' },
|
|
140
|
+
{ src: 'test2.jpg', alt: 'Test 2' },
|
|
141
|
+
],
|
|
142
|
+
imageSrc: 'test.jpg',
|
|
143
|
+
backgroundImage: { src: 'bg.jpg', alt: 'Background' },
|
|
144
|
+
backgroundVideo: { src: 'test.mp4', alt: 'Video', clipStart: 0, clipEnd: 5 },
|
|
145
|
+
videoSrc: 'test.mp4',
|
|
146
|
+
caption: 'Caption text',
|
|
147
|
+
term: 'Term',
|
|
148
|
+
definition: 'Definition',
|
|
149
|
+
leftLabel: 'A',
|
|
150
|
+
rightLabel: 'B',
|
|
151
|
+
leftValue: 50,
|
|
152
|
+
rightValue: 50,
|
|
153
|
+
} as unknown as TemplateBlock;
|
|
154
|
+
const result = expandTemplateBlock(block, context);
|
|
155
|
+
expect(result.id).toBe(`test-${name}`);
|
|
156
|
+
expect(result.layers ?? []).toBeInstanceOf(Array);
|
|
157
|
+
// Each template should produce at least 1 layer
|
|
158
|
+
expect((result.layers ?? []).length).toBeGreaterThanOrEqual(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('expandDocBlocks', () => {
|
|
164
|
+
it('expands array of template blocks with cumulative timing', () => {
|
|
165
|
+
const blocks: TemplateBlock[] = [
|
|
166
|
+
{ template: 'titleBlock', id: 'slide-1', duration: 5, audioSegment: 0, title: 'Hello' },
|
|
167
|
+
{
|
|
168
|
+
template: 'factCard',
|
|
169
|
+
id: 'slide-2',
|
|
170
|
+
duration: 8,
|
|
171
|
+
audioSegment: 0,
|
|
172
|
+
fact: 'A fact',
|
|
173
|
+
explanation: 'Why',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
template: 'statHighlight',
|
|
177
|
+
id: 'slide-3',
|
|
178
|
+
duration: 6,
|
|
179
|
+
audioSegment: 0,
|
|
180
|
+
stat: '99',
|
|
181
|
+
description: 'percent',
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const result = expandDocBlocks(blocks);
|
|
186
|
+
expect(result).toHaveLength(3);
|
|
187
|
+
expect(result[0].startTime).toBe(0);
|
|
188
|
+
expect(result[1].startTime).toBe(5);
|
|
189
|
+
expect(result[2].startTime).toBe(13);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('expands with audio segment timing', () => {
|
|
193
|
+
const blocks: TemplateBlock[] = [
|
|
194
|
+
{ template: 'sectionHeader', id: 'header-1', duration: 3, audioSegment: 0, title: 'Intro' },
|
|
195
|
+
{
|
|
196
|
+
template: 'factCard',
|
|
197
|
+
id: 'fact-1',
|
|
198
|
+
duration: 10,
|
|
199
|
+
audioSegment: 0,
|
|
200
|
+
fact: 'F',
|
|
201
|
+
explanation: 'E',
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const result = expandDocBlocks(blocks, {
|
|
206
|
+
audioSegments: [{ startTime: 0, duration: 30 }],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.length).toBeGreaterThan(0);
|
|
210
|
+
// First slide should start at 0
|
|
211
|
+
expect(result[0].startTime).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('supports landscape and portrait viewports', () => {
|
|
215
|
+
const blocks: TemplateBlock[] = [
|
|
216
|
+
{ template: 'titleBlock', id: 'title-1', duration: 5, audioSegment: 0, title: 'Test' },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const landscape = expandDocBlocks(blocks, { viewport: VIEWPORT_PRESETS.landscape });
|
|
220
|
+
const portrait = expandDocBlocks(blocks, { viewport: VIEWPORT_PRESETS.portrait });
|
|
221
|
+
|
|
222
|
+
expect(landscape).toHaveLength(1);
|
|
223
|
+
expect(portrait).toHaveLength(1);
|
|
224
|
+
// Both should produce valid blocks
|
|
225
|
+
expect((landscape[0].layers ?? []).length).toBeGreaterThan(0);
|
|
226
|
+
expect((portrait[0].layers ?? []).length).toBeGreaterThan(0);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('DEFAULT_THEME', () => {
|
|
231
|
+
it('has required color properties', () => {
|
|
232
|
+
expect(DEFAULT_THEME.colors).toHaveProperty('primary');
|
|
233
|
+
expect(DEFAULT_THEME.colors).toHaveProperty('background');
|
|
234
|
+
expect(DEFAULT_THEME.colors).toHaveProperty('text');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('VIEWPORT_PRESETS', () => {
|
|
239
|
+
it('has landscape and portrait presets', () => {
|
|
240
|
+
expect(VIEWPORT_PRESETS).toHaveProperty('landscape');
|
|
241
|
+
expect(VIEWPORT_PRESETS).toHaveProperty('portrait');
|
|
242
|
+
expect(VIEWPORT_PRESETS.landscape.width).toBe(1920);
|
|
243
|
+
expect(VIEWPORT_PRESETS.landscape.height).toBe(1080);
|
|
244
|
+
expect(VIEWPORT_PRESETS.portrait.width).toBe(1080);
|
|
245
|
+
expect(VIEWPORT_PRESETS.portrait.height).toBe(1920);
|
|
246
|
+
});
|
|
247
|
+
});
|