@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.
Files changed (124) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
  4. package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
  5. package/dist/{chunk-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
  6. package/dist/chunk-6ATE2PSM.js.map +1 -0
  7. package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
  8. package/dist/chunk-A4O7GIWE.js.map +1 -0
  9. package/dist/chunk-ET53IIEP.js +1 -0
  10. package/dist/chunk-ET53IIEP.js.map +1 -0
  11. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  12. package/dist/chunk-HWVFJAAH.js.map +1 -0
  13. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  14. package/dist/chunk-IMSCRLLV.js.map +1 -0
  15. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  16. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  17. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  18. package/dist/{chunk-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
  19. package/dist/chunk-LH7I6SH7.js.map +1 -0
  20. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  21. package/dist/chunk-T5UK6YOB.js.map +1 -0
  22. package/dist/{chunk-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
  23. package/dist/chunk-TEEEILMP.js.map +1 -0
  24. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  25. package/dist/chunk-TQWLI6S2.js.map +1 -0
  26. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  27. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  28. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  29. package/dist/doc/index.d.ts +4 -4
  30. package/dist/doc/index.js +16 -7
  31. package/dist/generate/index.d.ts +2 -80
  32. package/dist/generate/index.js +2 -7
  33. package/dist/imageEdit/index.d.ts +4 -4
  34. package/dist/index.d.ts +8 -8
  35. package/dist/index.js +32 -23
  36. package/dist/jsonForm/index.d.ts +42 -1
  37. package/dist/jsonForm/index.js +9 -1
  38. package/dist/markdown/index.d.ts +18 -2
  39. package/dist/markdown/index.js +5 -3
  40. package/dist/recommend/index.js +2 -2
  41. package/dist/schemas/index.d.ts +4 -4
  42. package/dist/schemas/index.js +3 -5
  43. package/dist/storage/index.d.ts +2 -2
  44. package/dist/storage/index.js +1 -1
  45. package/dist/story/index.d.ts +125 -11
  46. package/dist/story/index.js +16 -7
  47. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  48. package/dist/timing/index.js +3 -5
  49. package/dist/transform/index.d.ts +2 -2
  50. package/dist/transform/index.js +3 -3
  51. package/dist/versions/index.d.ts +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/contentExtractor.test.ts +60 -0
  54. package/src/__tests__/coverBlock.test.ts +46 -1
  55. package/src/__tests__/customTemplateSample.test.ts +63 -2
  56. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  57. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  58. package/src/__tests__/inputDescriptors.test.ts +162 -0
  59. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  60. package/src/__tests__/markdown.test.ts +64 -0
  61. package/src/__tests__/markdownSanitize.test.ts +15 -0
  62. package/src/__tests__/markdownToDoc.test.ts +168 -0
  63. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  64. package/src/__tests__/structuredData.test.ts +73 -3
  65. package/src/__tests__/templateMetadata.test.ts +11 -1
  66. package/src/__tests__/templates.test.ts +112 -0
  67. package/src/__tests__/validateDoc.test.ts +160 -0
  68. package/src/doc/annotationBlocks.ts +117 -0
  69. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  70. package/src/doc/customThemesFrontmatter.ts +6 -1
  71. package/src/doc/docToMarkdown.ts +31 -1
  72. package/src/doc/getLayers.ts +11 -2
  73. package/src/doc/index.ts +12 -0
  74. package/src/doc/markdownToDoc.ts +80 -3
  75. package/src/doc/mediaAnnotations.ts +15 -33
  76. package/src/doc/standaloneAnnotation.ts +58 -0
  77. package/src/doc/structuredData.ts +92 -21
  78. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  79. package/src/doc/templates/comparisonBar.ts +8 -1
  80. package/src/doc/templates/coverBlock.ts +29 -2
  81. package/src/doc/templates/customTemplate.ts +10 -4
  82. package/src/doc/templates/factCard.ts +4 -2
  83. package/src/doc/templates/imageWithCaption.ts +10 -8
  84. package/src/doc/templates/index.ts +12 -2
  85. package/src/doc/templates/inputDescriptors.ts +432 -0
  86. package/src/doc/templates/photoGrid.ts +16 -39
  87. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  88. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  89. package/src/doc/templates/videoWithCaption.ts +15 -14
  90. package/src/doc/utils/nearest.ts +59 -0
  91. package/src/doc/validate.ts +153 -54
  92. package/src/generate/contentExtractor.ts +148 -35
  93. package/src/generate/index.ts +0 -3
  94. package/src/jsonForm/index.ts +2 -0
  95. package/src/jsonForm/tokens.ts +78 -0
  96. package/src/markdown/annotationCoercion.ts +1 -1
  97. package/src/markdown/attrTokens.ts +19 -0
  98. package/src/markdown/index.ts +2 -1
  99. package/src/markdown/sanitize.ts +22 -2
  100. package/src/markdown/stringify.ts +12 -3
  101. package/src/markdown/utils.ts +62 -1
  102. package/src/schemas/CustomTemplates.ts +56 -4
  103. package/src/schemas/Doc.ts +39 -41
  104. package/src/schemas/themeCompile.ts +1 -1
  105. package/src/storage/ContentContainer.ts +6 -2
  106. package/src/transform/types.ts +1 -1
  107. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  108. package/dist/chunk-3E5F2XMR.js.map +0 -1
  109. package/dist/chunk-4AKUQPEE.js.map +0 -1
  110. package/dist/chunk-4X3JQXNM.js +0 -35
  111. package/dist/chunk-4X3JQXNM.js.map +0 -1
  112. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  113. package/dist/chunk-7MQGNR76.js.map +0 -1
  114. package/dist/chunk-FBKP5CAO.js +0 -145
  115. package/dist/chunk-FBKP5CAO.js.map +0 -1
  116. package/dist/chunk-FR2RBTKO.js.map +0 -1
  117. package/dist/chunk-K32VJONL.js.map +0 -1
  118. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  119. package/dist/chunk-OH43RPTP.js.map +0 -1
  120. package/dist/chunk-OKJOQXK4.js.map +0 -1
  121. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  122. package/src/generate/slideshowGenerator.ts +0 -280
  123. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  124. /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
+ });
@@ -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, layout } = context;
38
+ const { theme } = context;
39
39
 
40
- const captionFontSize = themedFontSize(36, context, false);
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 at bottom with gradient for readability. The scrim is
64
- // tinted from the theme background so the theme text color on top of it
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 scrimAngle = captionPosition === 'top' ? 180 : 0;
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(${scrimAngle}deg, ${withAlpha(bg, 0.8)} 0%, ${withAlpha(bg, 0.35)} 60%, ${withAlpha(bg, 0)} 100%)`,
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: captionPosition === 'top' ? 0 : '65%',
81
+ y: captionBgY,
80
82
  width: '100%',
81
- height: '35%',
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: layout.maxTextWidth,
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
+ }