@bendyline/squisq 1.5.0 → 1.5.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/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-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
- package/dist/chunk-6ATE2PSM.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-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.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-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
- package/dist/chunk-LH7I6SH7.js.map +1 -0
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
- package/dist/chunk-TEEEILMP.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 +16 -7
- 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/index.d.ts +8 -8
- package/dist/index.js +32 -23
- 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 +125 -11
- package/dist/story/index.js +16 -7
- 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 +46 -1
- 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 +168 -0
- package/src/__tests__/mediaAnnotations.test.ts +27 -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 +31 -1
- 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 +29 -2
- package/src/doc/templates/customTemplate.ts +10 -4
- 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/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/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/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-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
|
+
}
|