@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.
Files changed (135) 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-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
  6. package/dist/chunk-5TYS5V3G.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-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
  10. package/dist/chunk-BEQODJWV.js.map +1 -0
  11. package/dist/chunk-ET53IIEP.js +1 -0
  12. package/dist/chunk-ET53IIEP.js.map +1 -0
  13. package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
  14. package/dist/chunk-GPLTCSXO.js.map +1 -0
  15. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  16. package/dist/chunk-HWVFJAAH.js.map +1 -0
  17. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  18. package/dist/chunk-IMSCRLLV.js.map +1 -0
  19. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  20. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  21. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  22. package/dist/{chunk-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
  23. package/dist/chunk-LTN6I7QW.js.map +1 -0
  24. package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
  25. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  26. package/dist/chunk-T5UK6YOB.js.map +1 -0
  27. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  28. package/dist/chunk-TQWLI6S2.js.map +1 -0
  29. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  30. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  31. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  32. package/dist/doc/index.d.ts +4 -4
  33. package/dist/doc/index.js +24 -9
  34. package/dist/generate/index.d.ts +2 -80
  35. package/dist/generate/index.js +2 -7
  36. package/dist/imageEdit/index.d.ts +4 -4
  37. package/dist/imageEdit/index.js +2 -2
  38. package/dist/index.d.ts +8 -8
  39. package/dist/index.js +41 -26
  40. package/dist/jsonForm/index.d.ts +42 -1
  41. package/dist/jsonForm/index.js +9 -1
  42. package/dist/markdown/index.d.ts +18 -2
  43. package/dist/markdown/index.js +5 -3
  44. package/dist/recommend/index.js +2 -2
  45. package/dist/schemas/index.d.ts +4 -4
  46. package/dist/schemas/index.js +3 -5
  47. package/dist/storage/index.d.ts +2 -2
  48. package/dist/storage/index.js +1 -1
  49. package/dist/story/index.d.ts +151 -13
  50. package/dist/story/index.js +24 -9
  51. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  52. package/dist/timing/index.js +3 -5
  53. package/dist/transform/index.d.ts +2 -2
  54. package/dist/transform/index.js +3 -3
  55. package/dist/versions/index.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/contentExtractor.test.ts +60 -0
  58. package/src/__tests__/coverBlock.test.ts +79 -3
  59. package/src/__tests__/customTemplateSample.test.ts +63 -2
  60. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  61. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  62. package/src/__tests__/inputDescriptors.test.ts +162 -0
  63. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  64. package/src/__tests__/markdown.test.ts +64 -0
  65. package/src/__tests__/markdownSanitize.test.ts +15 -0
  66. package/src/__tests__/markdownToDoc.test.ts +173 -2
  67. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  68. package/src/__tests__/shapeGeometry.test.ts +37 -0
  69. package/src/__tests__/structuredData.test.ts +73 -3
  70. package/src/__tests__/templateMetadata.test.ts +11 -1
  71. package/src/__tests__/templates.test.ts +112 -0
  72. package/src/__tests__/validateDoc.test.ts +160 -0
  73. package/src/doc/annotationBlocks.ts +117 -0
  74. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  75. package/src/doc/customThemesFrontmatter.ts +6 -1
  76. package/src/doc/docToMarkdown.ts +78 -29
  77. package/src/doc/getLayers.ts +11 -2
  78. package/src/doc/index.ts +12 -0
  79. package/src/doc/markdownToDoc.ts +80 -3
  80. package/src/doc/mediaAnnotations.ts +15 -33
  81. package/src/doc/standaloneAnnotation.ts +58 -0
  82. package/src/doc/structuredData.ts +92 -21
  83. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  84. package/src/doc/templates/comparisonBar.ts +8 -1
  85. package/src/doc/templates/coverBlock.ts +78 -15
  86. package/src/doc/templates/customTemplate.ts +10 -4
  87. package/src/doc/templates/diagramBlock.ts +2 -2
  88. package/src/doc/templates/drawingBlock.ts +2 -2
  89. package/src/doc/templates/factCard.ts +4 -2
  90. package/src/doc/templates/imageWithCaption.ts +10 -8
  91. package/src/doc/templates/index.ts +12 -2
  92. package/src/doc/templates/inputDescriptors.ts +432 -0
  93. package/src/doc/templates/photoGrid.ts +16 -39
  94. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  95. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  96. package/src/doc/templates/videoWithCaption.ts +15 -14
  97. package/src/doc/utils/nearest.ts +59 -0
  98. package/src/doc/utils/shapeGeometry.ts +90 -2
  99. package/src/doc/validate.ts +153 -54
  100. package/src/generate/contentExtractor.ts +148 -35
  101. package/src/generate/index.ts +0 -3
  102. package/src/jsonForm/index.ts +2 -0
  103. package/src/jsonForm/tokens.ts +78 -0
  104. package/src/markdown/annotationCoercion.ts +1 -1
  105. package/src/markdown/attrTokens.ts +19 -0
  106. package/src/markdown/convert.ts +11 -1
  107. package/src/markdown/index.ts +2 -1
  108. package/src/markdown/sanitize.ts +22 -2
  109. package/src/markdown/stringify.ts +12 -3
  110. package/src/markdown/utils.ts +62 -1
  111. package/src/schemas/CustomTemplates.ts +56 -4
  112. package/src/schemas/Doc.ts +39 -41
  113. package/src/schemas/themeCompile.ts +1 -1
  114. package/src/storage/ContentContainer.ts +6 -2
  115. package/src/transform/types.ts +1 -1
  116. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  117. package/dist/chunk-3E5F2XMR.js.map +0 -1
  118. package/dist/chunk-4AKUQPEE.js.map +0 -1
  119. package/dist/chunk-4X3JQXNM.js +0 -35
  120. package/dist/chunk-4X3JQXNM.js.map +0 -1
  121. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  122. package/dist/chunk-7MQGNR76.js.map +0 -1
  123. package/dist/chunk-FBKP5CAO.js +0 -145
  124. package/dist/chunk-FBKP5CAO.js.map +0 -1
  125. package/dist/chunk-FR2RBTKO.js.map +0 -1
  126. package/dist/chunk-K32VJONL.js.map +0 -1
  127. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  128. package/dist/chunk-OH43RPTP.js.map +0 -1
  129. package/dist/chunk-OKJOQXK4.js.map +0 -1
  130. package/dist/chunk-RUDYOTA6.js.map +0 -1
  131. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  132. package/src/generate/slideshowGenerator.ts +0 -280
  133. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  134. /package/dist/{chunk-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
  135. /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
+ }
@@ -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)` + `clipEndpoints(a, b)` → the line/edge
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,