@bendyline/squisq 1.5.0 → 1.5.2
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/README.md +14 -1
- package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
- package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
- package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
- package/dist/{chunk-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
- package/dist/chunk-5TYS5V3G.js.map +1 -0
- package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
- package/dist/chunk-A4O7GIWE.js.map +1 -0
- package/dist/{chunk-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
- package/dist/chunk-BEQODJWV.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
- package/dist/chunk-GPLTCSXO.js.map +1 -0
- package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
- package/dist/chunk-HWVFJAAH.js.map +1 -0
- package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
- package/dist/chunk-IMSCRLLV.js.map +1 -0
- package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
- package/dist/{chunk-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
- package/dist/chunk-LTN6I7QW.js.map +1 -0
- package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
- package/dist/chunk-TQWLI6S2.js.map +1 -0
- package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +24 -9
- package/dist/generate/index.d.ts +2 -80
- package/dist/generate/index.js +2 -7
- package/dist/imageEdit/index.d.ts +4 -4
- package/dist/imageEdit/index.js +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +41 -26
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +18 -2
- package/dist/markdown/index.js +5 -3
- package/dist/recommend/index.js +2 -2
- package/dist/schemas/index.d.ts +4 -4
- package/dist/schemas/index.js +3 -5
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +151 -13
- package/dist/story/index.js +24 -9
- package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- package/dist/transform/index.d.ts +2 -2
- package/dist/transform/index.js +3 -3
- package/dist/versions/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +79 -3
- package/src/__tests__/customTemplateSample.test.ts +63 -2
- package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
- package/src/__tests__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/markdown.test.ts +64 -0
- package/src/__tests__/markdownSanitize.test.ts +15 -0
- package/src/__tests__/markdownToDoc.test.ts +173 -2
- package/src/__tests__/mediaAnnotations.test.ts +27 -0
- package/src/__tests__/shapeGeometry.test.ts +37 -0
- package/src/__tests__/structuredData.test.ts +73 -3
- package/src/__tests__/templateMetadata.test.ts +11 -1
- package/src/__tests__/templates.test.ts +112 -0
- package/src/__tests__/validateDoc.test.ts +160 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +7 -1
- package/src/doc/customThemesFrontmatter.ts +6 -1
- package/src/doc/docToMarkdown.ts +78 -29
- package/src/doc/getLayers.ts +11 -2
- package/src/doc/index.ts +12 -0
- package/src/doc/markdownToDoc.ts +80 -3
- package/src/doc/mediaAnnotations.ts +15 -33
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +92 -21
- package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
- package/src/doc/templates/comparisonBar.ts +8 -1
- package/src/doc/templates/coverBlock.ts +78 -15
- package/src/doc/templates/customTemplate.ts +10 -4
- package/src/doc/templates/diagramBlock.ts +2 -2
- package/src/doc/templates/drawingBlock.ts +2 -2
- package/src/doc/templates/factCard.ts +4 -2
- package/src/doc/templates/imageWithCaption.ts +10 -8
- package/src/doc/templates/index.ts +12 -2
- package/src/doc/templates/inputDescriptors.ts +432 -0
- package/src/doc/templates/photoGrid.ts +16 -39
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/videoWithCaption.ts +15 -14
- package/src/doc/utils/nearest.ts +59 -0
- package/src/doc/utils/shapeGeometry.ts +90 -2
- package/src/doc/validate.ts +153 -54
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- package/src/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +1 -1
- package/src/markdown/attrTokens.ts +19 -0
- package/src/markdown/convert.ts +11 -1
- package/src/markdown/index.ts +2 -1
- package/src/markdown/sanitize.ts +22 -2
- package/src/markdown/stringify.ts +12 -3
- package/src/markdown/utils.ts +62 -1
- package/src/schemas/CustomTemplates.ts +56 -4
- package/src/schemas/Doc.ts +39 -41
- package/src/schemas/themeCompile.ts +1 -1
- package/src/storage/ContentContainer.ts +6 -2
- package/src/transform/types.ts +1 -1
- package/dist/chunk-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-4AKUQPEE.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.js.map +0 -1
- package/dist/chunk-4ZFRZBU2.js.map +0 -1
- package/dist/chunk-7MQGNR76.js.map +0 -1
- package/dist/chunk-FBKP5CAO.js +0 -145
- package/dist/chunk-FBKP5CAO.js.map +0 -1
- package/dist/chunk-FR2RBTKO.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KZNXZ5SM.js.map +0 -1
- package/dist/chunk-OH43RPTP.js.map +0 -1
- package/dist/chunk-OKJOQXK4.js.map +0 -1
- package/dist/chunk-RUDYOTA6.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -280
- /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
- /package/dist/{chunk-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
- /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
|
-
import { resolveTokens } from '../resolveTokens';
|
|
10
|
+
import { resolveTokens, collectListItems } from '../resolveTokens';
|
|
11
11
|
import type { Block, TextLayer, ImageLayer } from '../../../../schemas/Doc.js';
|
|
12
|
+
import type { CustomTemplateLayer } from '../../../../schemas/CustomTemplates.js';
|
|
12
13
|
|
|
13
14
|
function textLayer(id: string, text: string): TextLayer {
|
|
14
15
|
return {
|
|
@@ -219,3 +220,268 @@ describe('resolveTokens — purity', () => {
|
|
|
219
220
|
expect(JSON.stringify(original)).toBe(before);
|
|
220
221
|
});
|
|
221
222
|
});
|
|
223
|
+
|
|
224
|
+
describe('resolveTokens — {attr:key}', () => {
|
|
225
|
+
it('reads from templateOverrides first, then templateData, then metadata', () => {
|
|
226
|
+
// key present in all three → overrides wins
|
|
227
|
+
const over = resolveTokens(
|
|
228
|
+
[textLayer('a', '{attr:role}')],
|
|
229
|
+
block({
|
|
230
|
+
templateOverrides: { role: 'from-overrides' },
|
|
231
|
+
templateData: { role: 'from-data' },
|
|
232
|
+
metadata: { role: 'from-metadata' },
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
expect((over[0] as TextLayer).content.text).toBe('from-overrides');
|
|
236
|
+
|
|
237
|
+
// absent from overrides → templateData wins over metadata
|
|
238
|
+
const data = resolveTokens(
|
|
239
|
+
[textLayer('a', '{attr:role}')],
|
|
240
|
+
block({ templateData: { role: 'from-data' }, metadata: { role: 'from-metadata' } }),
|
|
241
|
+
);
|
|
242
|
+
expect((data[0] as TextLayer).content.text).toBe('from-data');
|
|
243
|
+
|
|
244
|
+
// only metadata has it
|
|
245
|
+
const meta = resolveTokens(
|
|
246
|
+
[textLayer('a', '{attr:role}')],
|
|
247
|
+
block({ metadata: { role: 'from-metadata' } }),
|
|
248
|
+
);
|
|
249
|
+
expect((meta[0] as TextLayer).content.text).toBe('from-metadata');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('stringifies non-string templateData values', () => {
|
|
253
|
+
const layers = resolveTokens(
|
|
254
|
+
[textLayer('a', 'n={attr:count}')],
|
|
255
|
+
block({ templateData: { count: 42 } }),
|
|
256
|
+
);
|
|
257
|
+
expect((layers[0] as TextLayer).content.text).toBe('n=42');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('collapses a missing attr to empty string', () => {
|
|
261
|
+
const layers = resolveTokens([textLayer('a', '[{attr:missing}]')], block());
|
|
262
|
+
expect((layers[0] as TextLayer).content.text).toBe('[]');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('resolveTokens — pipe defaults', () => {
|
|
267
|
+
it('uses the default when {title} is empty, the value when present', () => {
|
|
268
|
+
const empty = resolveTokens([textLayer('a', '{title|Untitled}')], block({ title: undefined }));
|
|
269
|
+
expect((empty[0] as TextLayer).content.text).toBe('Untitled');
|
|
270
|
+
|
|
271
|
+
const present = resolveTokens([textLayer('a', '{title|Untitled}')], block({ title: 'Real' }));
|
|
272
|
+
expect((present[0] as TextLayer).content.text).toBe('Real');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('uses the default on a missing {attr:key|…}', () => {
|
|
276
|
+
const layers = resolveTokens(
|
|
277
|
+
[textLayer('a', '{attr:subtitle|Untitled}')],
|
|
278
|
+
block({ templateOverrides: {} }),
|
|
279
|
+
);
|
|
280
|
+
expect((layers[0] as TextLayer).content.text).toBe('Untitled');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('uses the image-src default (and keeps the layer) when the image index is out of range', () => {
|
|
284
|
+
const layers = resolveTokens([imageLayer('a', '{image:1|fallback.jpg}')], block());
|
|
285
|
+
expect(layers).toHaveLength(1);
|
|
286
|
+
expect((layers[0] as ImageLayer).content.src).toBe('fallback.jpg');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('still drops the image layer when {image:N} misses and no default is given', () => {
|
|
290
|
+
const layers = resolveTokens([imageLayer('a', '{image:1}')], block());
|
|
291
|
+
expect(layers).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('an empty default renders as empty (present token collapses to blank)', () => {
|
|
295
|
+
const layers = resolveTokens([textLayer('a', '[{attr:x|}]')], block());
|
|
296
|
+
expect((layers[0] as TextLayer).content.text).toBe('[]');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('resolveTokens — escape vs pipe defaults', () => {
|
|
301
|
+
it('leaves {{title|Untitled}} literal (escape shields the pipe default too)', () => {
|
|
302
|
+
const layers = resolveTokens(
|
|
303
|
+
[textLayer('a', 'Token: {{title|Untitled}}')],
|
|
304
|
+
block({ title: 'Real' }),
|
|
305
|
+
);
|
|
306
|
+
expect((layers[0] as TextLayer).content.text).toBe('Token: {title|Untitled}');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
function repeatText(
|
|
311
|
+
id: string,
|
|
312
|
+
text: string,
|
|
313
|
+
repeat: CustomTemplateLayer['repeat'],
|
|
314
|
+
): CustomTemplateLayer {
|
|
315
|
+
return {
|
|
316
|
+
id,
|
|
317
|
+
type: 'text',
|
|
318
|
+
position: { x: '0%', y: '10%', width: '30%', height: '20%' },
|
|
319
|
+
content: { text, style: { fontSize: 24, color: '#000' } },
|
|
320
|
+
repeat,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const imagesBlock = block({
|
|
325
|
+
contents: [
|
|
326
|
+
{
|
|
327
|
+
type: 'paragraph',
|
|
328
|
+
children: [
|
|
329
|
+
{ type: 'image', url: '/one.png', alt: 'One' },
|
|
330
|
+
{ type: 'image', url: '/two.png', alt: 'Two' },
|
|
331
|
+
{ type: 'image', url: '/three.png', alt: 'Three' },
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('resolveTokens — repeat', () => {
|
|
338
|
+
it('clones a layer once per image and resolves per-item tokens', () => {
|
|
339
|
+
const layers = resolveTokens(
|
|
340
|
+
[repeatText('cap', '{index}. {item} ({item:src})', { source: 'images' })],
|
|
341
|
+
imagesBlock,
|
|
342
|
+
);
|
|
343
|
+
expect(layers).toHaveLength(3);
|
|
344
|
+
expect((layers[0] as TextLayer).content.text).toBe('1. One (/one.png)');
|
|
345
|
+
expect((layers[1] as TextLayer).content.text).toBe('2. Two (/two.png)');
|
|
346
|
+
expect((layers[2] as TextLayer).content.text).toBe('3. Three (/three.png)');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('gives each clone a unique id and strips the repeat prop', () => {
|
|
350
|
+
const layers = resolveTokens([repeatText('cap', '{item}', { source: 'images' })], imagesBlock);
|
|
351
|
+
expect(layers.map((l) => l.id)).toEqual(['cap-0', 'cap-1', 'cap-2']);
|
|
352
|
+
expect(layers.every((l) => !('repeat' in l))).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('iterates children titles', () => {
|
|
356
|
+
const layers = resolveTokens(
|
|
357
|
+
[repeatText('c', '{index}:{item}', { source: 'children' })],
|
|
358
|
+
block({ children: [block({ id: 'a', title: 'Alpha' }), block({ id: 'b', title: 'Beta' })] }),
|
|
359
|
+
);
|
|
360
|
+
expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['1:Alpha', '2:Beta']);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('iterates list items', () => {
|
|
364
|
+
const layers = resolveTokens(
|
|
365
|
+
[repeatText('li', '- {item}', { source: 'listItems' })],
|
|
366
|
+
block({
|
|
367
|
+
contents: [
|
|
368
|
+
{
|
|
369
|
+
type: 'list',
|
|
370
|
+
ordered: false,
|
|
371
|
+
children: [
|
|
372
|
+
{
|
|
373
|
+
type: 'listItem',
|
|
374
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'First' }] }],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
type: 'listItem',
|
|
378
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Second' }] }],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['- First', '- Second']);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('caps the number of clones at max', () => {
|
|
389
|
+
const layers = resolveTokens(
|
|
390
|
+
[repeatText('cap', '{item}', { source: 'images', max: 2 })],
|
|
391
|
+
imagesBlock,
|
|
392
|
+
);
|
|
393
|
+
expect(layers).toHaveLength(2);
|
|
394
|
+
expect(layers.map((l) => (l as TextLayer).content.text)).toEqual(['One', 'Two']);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('offsets position.y in column direction by height + gap', () => {
|
|
398
|
+
const layers = resolveTokens(
|
|
399
|
+
[repeatText('cap', '{item}', { source: 'images', direction: 'column', gap: 4 })],
|
|
400
|
+
imagesBlock,
|
|
401
|
+
);
|
|
402
|
+
// base y = 10%, height = 20%, gap = 4 → cursor advances by 24 each item
|
|
403
|
+
expect(layers.map((l) => l.position.y)).toEqual(['10%', '34%', '58%']);
|
|
404
|
+
// x is untouched in column direction
|
|
405
|
+
expect(layers.every((l) => l.position.x === '0%')).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('offsets position.x in row direction by width + gap', () => {
|
|
409
|
+
const layers = resolveTokens(
|
|
410
|
+
[repeatText('cap', '{item}', { source: 'images', direction: 'row', gap: 10 })],
|
|
411
|
+
imagesBlock,
|
|
412
|
+
);
|
|
413
|
+
// base x = 0%, width = 30%, gap = 10 → cursor advances by 40 each item
|
|
414
|
+
expect(layers.map((l) => l.position.x)).toEqual(['0%', '40%', '80%']);
|
|
415
|
+
expect(layers.every((l) => l.position.y === '10%')).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('renders nothing when the source collection is empty', () => {
|
|
419
|
+
const layers = resolveTokens([repeatText('cap', '{item}', { source: 'images' })], block());
|
|
420
|
+
expect(layers).toHaveLength(0);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('resolves {item:src} in an image layer inside a repeat', () => {
|
|
424
|
+
const imgRepeat: CustomTemplateLayer = {
|
|
425
|
+
id: 'img',
|
|
426
|
+
type: 'image',
|
|
427
|
+
position: { x: '0%', y: '0%', width: '25%', height: '25%' },
|
|
428
|
+
content: { src: '{item:src}', alt: '{item:label}' },
|
|
429
|
+
repeat: { source: 'images', direction: 'row', gap: 0 },
|
|
430
|
+
};
|
|
431
|
+
const layers = resolveTokens([imgRepeat], imagesBlock);
|
|
432
|
+
expect(layers).toHaveLength(3);
|
|
433
|
+
expect((layers[0] as ImageLayer).content.src).toBe('/one.png');
|
|
434
|
+
expect((layers[0] as ImageLayer).content.alt).toBe('One');
|
|
435
|
+
expect((layers[2] as ImageLayer).content.src).toBe('/three.png');
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
describe('resolveTokens — {item}/{index} outside a repeat', () => {
|
|
440
|
+
it('leaves per-item tokens literal when there is no repeat context', () => {
|
|
441
|
+
const layers = resolveTokens([textLayer('a', '{item} {index}')], block());
|
|
442
|
+
expect((layers[0] as TextLayer).content.text).toBe('{item} {index}');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('collectListItems', () => {
|
|
447
|
+
it('returns top-level list-item text and skips nested double-counting', () => {
|
|
448
|
+
const b = block({
|
|
449
|
+
contents: [
|
|
450
|
+
{
|
|
451
|
+
type: 'list',
|
|
452
|
+
ordered: false,
|
|
453
|
+
children: [
|
|
454
|
+
{
|
|
455
|
+
type: 'listItem',
|
|
456
|
+
children: [
|
|
457
|
+
{ type: 'paragraph', children: [{ type: 'text', value: 'Parent' }] },
|
|
458
|
+
{
|
|
459
|
+
type: 'list',
|
|
460
|
+
ordered: false,
|
|
461
|
+
children: [
|
|
462
|
+
{
|
|
463
|
+
type: 'listItem',
|
|
464
|
+
children: [
|
|
465
|
+
{ type: 'paragraph', children: [{ type: 'text', value: 'Nested' }] },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
type: 'listItem',
|
|
474
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Sibling' }] }],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
});
|
|
480
|
+
// Only two top-level items; the nested item is folded into "Parent Nested".
|
|
481
|
+
expect(collectListItems(b)).toEqual(['Parent Nested', 'Sibling']);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('returns [] when the block has no list content', () => {
|
|
485
|
+
expect(collectListItems(block())).toEqual([]);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
Binary file
|
|
@@ -35,9 +35,9 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
|
|
|
35
35
|
videoLicense,
|
|
36
36
|
} = input;
|
|
37
37
|
const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
|
|
38
|
-
const { theme
|
|
38
|
+
const { theme } = context;
|
|
39
39
|
|
|
40
|
-
const captionFontSize = themedFontSize(
|
|
40
|
+
const captionFontSize = themedFontSize(30, context, false);
|
|
41
41
|
const creditFontSize = themedFontSize(16, context, false);
|
|
42
42
|
|
|
43
43
|
const layers: Layer[] = [
|
|
@@ -60,31 +60,30 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
|
|
|
60
60
|
},
|
|
61
61
|
];
|
|
62
62
|
|
|
63
|
-
// Caption text
|
|
64
|
-
//
|
|
65
|
-
// stays readable in light and dark themes alike; it fades toward the
|
|
66
|
-
// frame edge the caption sits on.
|
|
63
|
+
// Caption text with a compact translucent band. Long archival captions are
|
|
64
|
+
// clamped so they don't collide with player controls or cover the frame.
|
|
67
65
|
if (caption) {
|
|
68
66
|
const bg = theme.colors.background;
|
|
69
|
-
const
|
|
67
|
+
const captionBgY =
|
|
68
|
+
captionPosition === 'top' ? '7%' : captionPosition === 'center' ? '42%' : '68%';
|
|
69
|
+
const captionY =
|
|
70
|
+
captionPosition === 'top' ? '13%' : captionPosition === 'center' ? '48%' : '74%';
|
|
71
|
+
|
|
70
72
|
layers.push({
|
|
71
73
|
type: 'shape',
|
|
72
74
|
id: 'caption-gradient',
|
|
73
75
|
content: {
|
|
74
76
|
shape: 'rect',
|
|
75
|
-
fill: `linear-gradient(${
|
|
77
|
+
fill: `linear-gradient(90deg, ${withAlpha(bg, 0)}, ${withAlpha(bg, 0.78)} 16%, ${withAlpha(bg, 0.78)} 84%, ${withAlpha(bg, 0)})`,
|
|
76
78
|
},
|
|
77
79
|
position: {
|
|
78
80
|
x: 0,
|
|
79
|
-
y:
|
|
81
|
+
y: captionBgY,
|
|
80
82
|
width: '100%',
|
|
81
|
-
height: '
|
|
83
|
+
height: '16%',
|
|
82
84
|
},
|
|
83
85
|
});
|
|
84
86
|
|
|
85
|
-
const captionY =
|
|
86
|
-
captionPosition === 'top' ? '15%' : captionPosition === 'center' ? '50%' : '82%';
|
|
87
|
-
|
|
88
87
|
layers.push({
|
|
89
88
|
type: 'text',
|
|
90
89
|
id: 'caption',
|
|
@@ -96,13 +95,15 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
|
|
|
96
95
|
color: theme.colors.text,
|
|
97
96
|
textAlign: 'center',
|
|
98
97
|
shadow: shouldUseShadow(context),
|
|
98
|
+
lineHeight: 1.18,
|
|
99
|
+
maxLines: 2,
|
|
99
100
|
},
|
|
100
101
|
},
|
|
101
102
|
position: {
|
|
102
103
|
x: '50%',
|
|
103
104
|
y: captionY,
|
|
104
105
|
anchor: 'center',
|
|
105
|
-
width:
|
|
106
|
+
width: '78%',
|
|
106
107
|
},
|
|
107
108
|
animation: { type: 'fadeIn', duration: 1.5, delay: 0.5 },
|
|
108
109
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "did-you-mean" edit-distance helpers.
|
|
3
|
+
*
|
|
4
|
+
* A leaf module (no imports) so both `doc/validate.ts` and
|
|
5
|
+
* `doc/templates/inputDescriptors.ts` can share one implementation without
|
|
6
|
+
* risking an import cycle through `templates/index.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Levenshtein edit distance between two strings. */
|
|
10
|
+
export function levenshtein(a: string, b: string): number {
|
|
11
|
+
if (a === b) return 0;
|
|
12
|
+
const m = a.length;
|
|
13
|
+
const n = b.length;
|
|
14
|
+
if (m === 0) return n;
|
|
15
|
+
if (n === 0) return m;
|
|
16
|
+
let prev = new Array<number>(n + 1);
|
|
17
|
+
let curr = new Array<number>(n + 1);
|
|
18
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
19
|
+
for (let i = 1; i <= m; i++) {
|
|
20
|
+
curr[0] = i;
|
|
21
|
+
for (let j = 1; j <= n; j++) {
|
|
22
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
23
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
24
|
+
}
|
|
25
|
+
[prev, curr] = [curr, prev];
|
|
26
|
+
}
|
|
27
|
+
return prev[n];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Nearest candidate name by edit distance, or `undefined` when nothing is
|
|
32
|
+
* plausibly close. "Close" means distance ≤ `maxDistance` (default 2) OR
|
|
33
|
+
* ≤ 40% of the input length — the shared threshold both call sites use.
|
|
34
|
+
*
|
|
35
|
+
* @param options.map - Optional post-processor applied to the winning
|
|
36
|
+
* candidate (e.g. `resolveTemplateName` to canonicalize an alias hit).
|
|
37
|
+
* @param options.maxDistance - Absolute distance floor for "close" (default 2).
|
|
38
|
+
*/
|
|
39
|
+
export function nearestName(
|
|
40
|
+
input: string,
|
|
41
|
+
candidates: Iterable<string>,
|
|
42
|
+
options?: { map?: (best: string) => string; maxDistance?: number },
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const maxDistance = options?.maxDistance ?? 2;
|
|
45
|
+
let best: string | undefined;
|
|
46
|
+
let bestDist = Infinity;
|
|
47
|
+
const lower = input.toLowerCase();
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
const dist = levenshtein(lower, candidate.toLowerCase());
|
|
50
|
+
if (dist < bestDist) {
|
|
51
|
+
bestDist = dist;
|
|
52
|
+
best = candidate;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (best && (bestDist <= maxDistance || bestDist <= Math.ceil(input.length * 0.4))) {
|
|
56
|
+
return options?.map ? options.map(best) : best;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* - `markerPath(style, dir)` → the `<marker>` path for a line end-style, in the
|
|
10
10
|
* standard 0–10 marker viewBox (shared by the SSR `PathLayer` renderer and the
|
|
11
11
|
* editor's edge renderer).
|
|
12
|
-
* - `connectorPath(routing, start, end)` + `
|
|
12
|
+
* - `connectorPath(routing, start, end)` + `snapEndpoints(a, b)` → the line/edge
|
|
13
13
|
* geometry between two shapes (straight / orthogonal / curved), lifted from the
|
|
14
|
-
* diagram template so drawings and diagrams draw identical connectors.
|
|
14
|
+
* diagram template so drawings and diagrams draw identical connectors. The older
|
|
15
|
+
* `clipEndpoints(a, b)` helper remains for free-angle clipping, but rendered
|
|
16
|
+
* connectors use snapped ports so authored lines land on stable attachment points.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import type { MarkerStyle } from '../../schemas/Doc.js';
|
|
@@ -411,6 +413,22 @@ export interface ClipBox {
|
|
|
411
413
|
ry: number;
|
|
412
414
|
}
|
|
413
415
|
|
|
416
|
+
export type ConnectorPort =
|
|
417
|
+
| 'top'
|
|
418
|
+
| 'right'
|
|
419
|
+
| 'bottom'
|
|
420
|
+
| 'left'
|
|
421
|
+
| 'top-left'
|
|
422
|
+
| 'top-right'
|
|
423
|
+
| 'bottom-right'
|
|
424
|
+
| 'bottom-left';
|
|
425
|
+
|
|
426
|
+
export interface ConnectorSnapPoint {
|
|
427
|
+
port: ConnectorPort;
|
|
428
|
+
x: number;
|
|
429
|
+
y: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
414
432
|
/** Intersection of the center-to-center line with `from`'s bounding box. */
|
|
415
433
|
export function clipPoint(from: ClipBox, to: ClipBox): { x: number; y: number } {
|
|
416
434
|
const dx = to.cx - from.cx;
|
|
@@ -430,6 +448,76 @@ export function clipEndpoints(
|
|
|
430
448
|
return { start: clipPoint(a, b), end: clipPoint(b, a) };
|
|
431
449
|
}
|
|
432
450
|
|
|
451
|
+
/** The stable connector ports exposed around a shape/card box. */
|
|
452
|
+
export function snapPoints(box: ClipBox): ConnectorSnapPoint[] {
|
|
453
|
+
const left = box.cx - box.rx;
|
|
454
|
+
const right = box.cx + box.rx;
|
|
455
|
+
const top = box.cy - box.ry;
|
|
456
|
+
const bottom = box.cy + box.ry;
|
|
457
|
+
return [
|
|
458
|
+
{ port: 'top', x: box.cx, y: top },
|
|
459
|
+
{ port: 'right', x: right, y: box.cy },
|
|
460
|
+
{ port: 'bottom', x: box.cx, y: bottom },
|
|
461
|
+
{ port: 'left', x: left, y: box.cy },
|
|
462
|
+
{ port: 'top-left', x: left, y: top },
|
|
463
|
+
{ port: 'top-right', x: right, y: top },
|
|
464
|
+
{ port: 'bottom-right', x: right, y: bottom },
|
|
465
|
+
{ port: 'bottom-left', x: left, y: bottom },
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Nearest stable connector port on `box` to an arbitrary point. */
|
|
470
|
+
export function nearestSnapPoint(
|
|
471
|
+
box: ClipBox,
|
|
472
|
+
point: { x: number; y: number },
|
|
473
|
+
): ConnectorSnapPoint {
|
|
474
|
+
let best = snapPoints(box)[0];
|
|
475
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
476
|
+
for (const candidate of snapPoints(box)) {
|
|
477
|
+
const score = distanceSq(candidate, point) + portTieBreak(candidate.port);
|
|
478
|
+
if (score < bestScore) {
|
|
479
|
+
best = candidate;
|
|
480
|
+
bestScore = score;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return best;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Pick the pair of connector ports, one on each box, with the shortest
|
|
488
|
+
* distance between them. This gives stable attachments to side/corner handles
|
|
489
|
+
* instead of arbitrary center-ray intersections.
|
|
490
|
+
*/
|
|
491
|
+
export function snapEndpoints(
|
|
492
|
+
a: ClipBox,
|
|
493
|
+
b: ClipBox,
|
|
494
|
+
): { start: ConnectorSnapPoint; end: ConnectorSnapPoint } {
|
|
495
|
+
let bestStart = snapPoints(a)[0];
|
|
496
|
+
let bestEnd = snapPoints(b)[0];
|
|
497
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
498
|
+
for (const start of snapPoints(a)) {
|
|
499
|
+
for (const end of snapPoints(b)) {
|
|
500
|
+
const score = distanceSq(start, end) + portTieBreak(start.port) + portTieBreak(end.port);
|
|
501
|
+
if (score < bestScore) {
|
|
502
|
+
bestStart = start;
|
|
503
|
+
bestEnd = end;
|
|
504
|
+
bestScore = score;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { start: bestStart, end: bestEnd };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function distanceSq(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
|
512
|
+
const dx = a.x - b.x;
|
|
513
|
+
const dy = a.y - b.y;
|
|
514
|
+
return dx * dx + dy * dy;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function portTieBreak(port: ConnectorPort): number {
|
|
518
|
+
return port.includes('-') ? 0.02 : 0;
|
|
519
|
+
}
|
|
520
|
+
|
|
433
521
|
/** Build the SVG `d` for a connector between two clipped endpoints. */
|
|
434
522
|
export function connectorPath(
|
|
435
523
|
routing: ConnectorRouting,
|