@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
@@ -1,8 +1,8 @@
1
- import { a2 as PersistentLayer, ax as Theme, R as Layer, a3 as PersistentLayerConfig, aE as TitleBlockInput, as as TemplateContext, ag as SectionHeaderInput, am as StatHighlightInput, ab as QuoteBlockInput, H as FactCardInput, aG as TwoColumnInput, t as DateEventInput, P as ImageWithCaptionInput, T as LeftFeatureInput, ae as RightFeatureInput, W as MapBlockInput, al as StartBlockConfig, K as FullBleedQuoteInput, V as ListBlockInput, a7 as PhotoGridInput, v as DefinitionCardInput, n as ComparisonBarInput, aa as PullQuoteInput, aK as VideoWithCaptionInput, aJ as VideoPullQuoteInput, s as DataTableInput, w as DiagramBlockInput, B as Block, E as DrawingBlockInput, _ as MarkerStyle, ar as TemplateBlock, A as AccentImage, O as ImageTreatment, a as AccentPosition, aL as ViewportConfig, p as CustomTemplateDefinition, y as DocBlock, au as TemplateRegistry, c as Animation, d as AnimationType, az as ThemeColorScheme, x as Doc, z as DocDiagnostic } from '../Doc-BsMkmzH8.js';
2
- export { F as FRONTMATTER_CUSTOM_TEMPLATES_KEY, G as FRONTMATTER_CUSTOM_THEMES_KEY, S as LayoutHints, ad as RenderStyle, ay as ThemeColorPalette, aC as ThemeStyle, aD as ThemeTypography, aH as VIEWPORT_PRESETS, aM as ViewportOrientation, aN as ViewportPreset, aS as createTemplateContext, aY as getLayoutHints, b0 as getTwoColumnPositions, b1 as getViewport, b2 as getViewportOrientation, b4 as isTemplateBlock, b9 as scaledFontSize } from '../Doc-BsMkmzH8.js';
1
+ import { a4 as PersistentLayer, az as Theme, S as Layer, a5 as PersistentLayerConfig, aG as TitleBlockInput, au as TemplateContext, ai as SectionHeaderInput, ao as StatHighlightInput, ad as QuoteBlockInput, I as FactCardInput, aI as TwoColumnInput, u as DateEventInput, Q as ImageWithCaptionInput, V as LeftFeatureInput, ag as RightFeatureInput, Y as MapBlockInput, an as StartBlockConfig, L as FullBleedQuoteInput, X as ListBlockInput, a9 as PhotoGridInput, w as DefinitionCardInput, n as ComparisonBarInput, ac as PullQuoteInput, aM as VideoWithCaptionInput, aL as VideoPullQuoteInput, t as DataTableInput, x as DiagramBlockInput, B as Block, F as DrawingBlockInput, a0 as MarkerStyle, at as TemplateBlock, A as AccentImage, P as ImageTreatment, a as AccentPosition, aN as ViewportConfig, p as CustomTemplateDefinition, z as DocBlock, aw as TemplateRegistry, c as Animation, d as AnimationType, aB as ThemeColorScheme, y as Doc, E as DocDiagnostic } from '../Doc-BBVGq1_9.js';
2
+ export { G as FRONTMATTER_CUSTOM_TEMPLATES_KEY, H as FRONTMATTER_CUSTOM_THEMES_KEY, U as LayoutHints, af as RenderStyle, aA as ThemeColorPalette, aE as ThemeStyle, aF as ThemeTypography, aJ as VIEWPORT_PRESETS, aO as ViewportOrientation, aP as ViewportPreset, aU as createTemplateContext, a_ as getLayoutHints, b2 as getTwoColumnPositions, b3 as getViewport, b4 as getViewportOrientation, b6 as isTemplateBlock, bb as scaledFontSize } from '../Doc-BBVGq1_9.js';
3
3
  import { I as MarkdownNode, $ as TransitionType, _ as TransitionDirection, M as MarkdownBlockNode, q as MarkdownHeading, m as MarkdownDocument, P as MarkdownTable, h as MarkdownCodeBlock } from '../types-DlAZ7MW4.js';
4
- import { C as ContentContainer } from '../ContentContainer-DNx460y8.js';
5
- export { D as DEFAULT_THEME, g as getAvailableThemes, b as getThemeSummaries, r as resolveTheme } from '../themeLibrary-DQQKuRMx.js';
4
+ import { C as ContentContainer } from '../ContentContainer-BXUlIf18.js';
5
+ export { D as DEFAULT_THEME, g as getAvailableThemes, b as getThemeSummaries, r as resolveTheme } from '../themeLibrary-CehcJhzz.js';
6
6
 
7
7
  /**
8
8
  * Canonical, framework-free UI metadata for every built-in block template.
@@ -1336,7 +1336,9 @@ declare function readCustomTemplatesFromFrontmatter(frontmatter: Record<string,
1336
1336
  * object described in the module header. Returns undefined when the
1337
1337
  * input list is empty so callers can leave the key off the output.
1338
1338
  */
1339
- declare function writeCustomTemplatesToFrontmatter(templates: readonly CustomTemplateDefinition[] | undefined): string | undefined;
1339
+ declare function writeCustomTemplatesToFrontmatter(templates: readonly CustomTemplateDefinition[] | undefined, options?: {
1340
+ pretty?: boolean;
1341
+ }): string | undefined;
1340
1342
 
1341
1343
  /**
1342
1344
  * Frontmatter serialization for user-defined custom themes.
@@ -1375,7 +1377,9 @@ declare function readCustomThemesFromFrontmatter(frontmatter: Record<string, unk
1375
1377
  * the module header (keyed by theme id). Returns undefined when the input is
1376
1378
  * empty so callers can leave the key off the output.
1377
1379
  */
1378
- declare function writeCustomThemesToFrontmatter(themes: readonly Theme[] | undefined): string | undefined;
1380
+ declare function writeCustomThemesToFrontmatter(themes: readonly Theme[] | undefined, options?: {
1381
+ pretty?: boolean;
1382
+ }): string | undefined;
1379
1383
 
1380
1384
  /**
1381
1385
  * Doc-scoped theme resolution.
@@ -1542,10 +1546,12 @@ declare function resolveAudioMapping(doc: Doc, container: ContentContainer): Pro
1542
1546
  * didn't provide them explicitly.
1543
1547
  *
1544
1548
  * YAML support is a documented subset (this package has no YAML
1545
- * dependency): top-level `key: scalar`, `key: [inline, array]`, and
1546
- * `key:` followed by an indented `- item` block sequence whose items are
1547
- * scalars or inline arrays. Nested maps are rejected with a clear error —
1548
- * use a `json data` fence for deeply nested input.
1549
+ * dependency): top-level `key: scalar`, `key: [inline, array]`, `key:`
1550
+ * followed by an indented `- item` block sequence (scalars or inline
1551
+ * arrays), and `key:` followed by *one level* of indented `subkey: scalar`
1552
+ * lines (a nested map, e.g. `center:` + indented `lat:` / `lng:`). Deeper
1553
+ * nesting, and mixing list items with mapping keys under one key, are
1554
+ * rejected with a clear error — use a `json data` fence for those.
1549
1555
  */
1550
1556
 
1551
1557
  /**
@@ -1565,6 +1571,17 @@ interface DataFenceParseResult {
1565
1571
  * fences are parsed with {@link parseYamlSubset}.
1566
1572
  */
1567
1573
  declare function parseDataFence(node: MarkdownCodeBlock): DataFenceParseResult;
1574
+ /**
1575
+ * Return a new contents array with the first data fence's code content
1576
+ * replaced by `data` (pretty-printed JSON). The fence node keeps its
1577
+ * language and `data` marker; only `value` changes. When no data fence
1578
+ * exists, a new ` ```json data ` fence is appended instead.
1579
+ *
1580
+ * Pure: neither `contents` nor its nodes are mutated. This is the write
1581
+ * half of the data-fence channel — editors update `block.templateData`
1582
+ * and call this to reflect the change back into `block.contents`.
1583
+ */
1584
+ declare function replaceDataFence(contents: MarkdownBlockNode[], data: Record<string, unknown>): MarkdownBlockNode[];
1568
1585
  /**
1569
1586
  * Parse the supported YAML subset (see module doc) into a key→value map.
1570
1587
  * Throws an Error with a line-anchored message on anything outside the
@@ -1624,6 +1641,7 @@ interface MarkdownValidationResult {
1624
1641
  diagnostics: DocDiagnostic[];
1625
1642
  errorCount: number;
1626
1643
  warningCount: number;
1644
+ infoCount: number;
1627
1645
  /** The converted Doc (conversion diagnostics are included above). */
1628
1646
  doc: Doc;
1629
1647
  }
@@ -1660,4 +1678,100 @@ declare function validateMarkdownDoc(markdownDoc: MarkdownDocument, options?: Va
1660
1678
  */
1661
1679
  declare function fallbackBlockLayers(block: DocBlock, context: TemplateContext, notice: string): Layer[];
1662
1680
 
1663
- export { type AccentLayout, type AudioSegmentTiming, CONTAINER_TEMPLATES, type ClipBox, type ConnectorRouting, type CoverBlockInput, DEFAULT_LAYOUT, type DataFenceParseResult, type DeriveTemplateInputsOptions, type DiagramEdge, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodePosition, DocBlock, type DocStylePreset, type DrawingConnector, type DrawingLayout, type DrawingLayoutOptions, type DrawingShape, type DrawingShapeKind, type ExpandDocBlocksOptions, type ExtractedTableData, type FirstImage, type LayoutLayerDefaults, type LayoutLayersResult, type MarkdownToDocOptions, type MarkdownValidationResult, PATH_SHAPE_KINDS, PersistentLayerConfig, type RenderContext, type RuntimeTemplateRegistry, SHAPE_NAMES, TEMPLATE_ALIASES, TEMPLATE_METADATA, TemplateBlock, TemplateContext, type TemplateMetadata, Theme, ThemeColorScheme, type ValidateOptions, ViewportConfig, adjustY, applyRenderStyleToLayers, buildRegistry, clipEndpoints, clipPoint, comparisonBar, computeDiagramLayout, computeDrawingLayout, computeLayoutLayers, connectorPath, countBlocks, coverBlock, createAccentLayers, cssFilterForTreatment, dataTable, dateEvent, decodeLayersFromFrontmatter, definitionCard, deriveTemplateInputs, diagramBlock, docToMarkdown, drawingBlock, encodeLayersForFrontmatter, expandCoverBlock, expandDocBlocks, expandPersistentLayers, expandTemplateBlock, extractBlockquoteText, extractBodyPlainText, extractFirstImage, extractImages, extractListItems, extractTableData, extractTableFromContents, factCard, fallbackBlockLayers, findFirstTable, flattenBlocks, flattenRenderableBlocks, fullBleedQuote, getAccentLayout, getAnimationProgress, getAnimationStyle, getAvailableTemplates, getBlockDepth, getDefaultAnimation, getDefaultAnimationDuration, getDocStyleConfig, getLayers, getOverlayOpacity, getPersistentLayersFromTheme, getTemplateHint, getThemeFont, getTransitionClass, hasTemplate, imageWithCaption, isContainerTemplate, isDataFence, isShapeName, layoutBlock, leftFeature, lineStyleDasharray, listBlock, mapBlock, markdownToDoc, markerPath, normalizeShapeKind, parseDataFence, parseYamlSubset, photoGrid, pullQuote, quoteBlock, readCustomTemplatesFromFrontmatter, readCustomThemesFromFrontmatter, resolveAudioMapping, resolveColorScheme, resolvePersistentLayers, resolveTemplateName, resolveThemeForDoc, rightFeature, scaleAnimationDuration, scoreTextSimilarity, sectionHeader, shapePath, shouldUseShadow, statHighlight, templateRegistry, themeWantsAmbientMotion, themedEntrance, themedFontSize, themedImageTreatment, themedScrim, themedSurfaceGradient, titleBlock, twoColumn, validateMarkdownDoc, validateMarkdownSource, videoPullQuote, videoWithCaption, wrapWithPersistentLayers, writeCustomTemplatesToFrontmatter, writeCustomThemesToFrontmatter };
1681
+ /**
1682
+ * Template input descriptors — a typed catalog of the inline `{[…]}` params
1683
+ * each built-in template understands, plus the pure coercion + lint logic
1684
+ * that consumes it.
1685
+ *
1686
+ * Two jobs, one source of truth:
1687
+ *
1688
+ * - **Coercion (`coerceTemplateParams`)** turns a flat `key → raw string`
1689
+ * param map (from a `{[name key=value]}` annotation, always strings) into a
1690
+ * partial typed input the template functions can consume directly — e.g.
1691
+ * `center="47.6,-122.3"` → `{ lat: 47.6, lng: -122.3 }`, `zoom=9` → `9`.
1692
+ * This is what makes `{[map center="47.6,-122.3" zoom=9]}` render with no
1693
+ * changes to any template function. Unknown keys ALWAYS pass through
1694
+ * untouched (as strings), so coercion is never lossy; coercion failures are
1695
+ * returned as `warnings` and the raw string is preserved.
1696
+ *
1697
+ * - **Linting (`lintTemplateParams`)** checks a param map against the
1698
+ * descriptors and reports unknown keys (with a did-you-mean suggestion),
1699
+ * values outside a closed enum / that fail coercion, and missing required
1700
+ * inputs. The document validator surfaces these as diagnostics.
1701
+ *
1702
+ * This mirrors the proven block-meta descriptor pattern in
1703
+ * `markdown/annotationCoercion.ts` (`BLOCK_META_KEY_DESCRIPTORS`), extended
1704
+ * from block geometry/timing to per-template content inputs.
1705
+ *
1706
+ * The descriptors are the `{[…]}` counterpart of the template input
1707
+ * interfaces in `schemas/BlockTemplates.ts` — only keys/enums that provably
1708
+ * exist on those interfaces are declared. Keys that can't be meaningfully
1709
+ * expressed as a single inline string (e.g. a `dataTable`'s `rows`) are still
1710
+ * declared with `coerce: 'string'` so they aren't mis-flagged as unknown.
1711
+ */
1712
+ /** How a raw `{[…]}` param string is coerced into a typed input value. */
1713
+ type InputCoercion = 'string' | 'number' | 'boolean' | 'latLng' | 'labeledPair' | 'stringList';
1714
+ /**
1715
+ * Editor/validator-facing description of a single template input key: what it
1716
+ * does, how to coerce its raw string, and either its closed set of valid
1717
+ * values or a free-form format hint. `values`/`valueHint` are mutually
1718
+ * exclusive (a key has a closed enum OR a hint, not both).
1719
+ */
1720
+ interface TemplateInputDescriptor {
1721
+ /** The `{[name key=…]}` param key, matching the template input interface. */
1722
+ key: string;
1723
+ /** One-line description of the input. */
1724
+ description: string;
1725
+ /** Coercion strategy for the raw string (default `'string'`). */
1726
+ coerce?: InputCoercion;
1727
+ /** Closed set of valid values, for keys backed by an enum. */
1728
+ values?: readonly string[];
1729
+ /** Format hint shown when the value is free-form (no closed set). */
1730
+ valueHint?: string;
1731
+ /** When true, the validator warns if the key is absent and can't be derived. */
1732
+ required?: boolean;
1733
+ }
1734
+ /** A single lint finding produced by {@link lintTemplateParams}. */
1735
+ interface TemplateParamFinding {
1736
+ kind: 'unknown-input' | 'invalid-input-value' | 'missing-input';
1737
+ key: string;
1738
+ message: string;
1739
+ /** Did-you-mean candidate (for `unknown-input`). */
1740
+ suggestion?: string;
1741
+ }
1742
+ /**
1743
+ * Inputs shared by every template via `BaseTemplateBlock`. Merged into each
1744
+ * template's known-key set for coercion + lint. Block-level timing/geometry
1745
+ * keys (`duration`, `x`, `y`, …) are NOT here — those stay owned by the
1746
+ * block-meta registry (`KNOWN_BLOCK_META_KEYS`) and are folded into the lint
1747
+ * known-key set separately.
1748
+ */
1749
+ declare const BASE_INPUT_DESCRIPTORS: readonly TemplateInputDescriptor[];
1750
+ /**
1751
+ * Per-template input descriptors, keyed by canonical template id. A template
1752
+ * absent from this map has no descriptors and is exempt from lint (custom
1753
+ * templates and not-yet-described built-ins).
1754
+ */
1755
+ declare const TEMPLATE_INPUT_DESCRIPTORS: Readonly<Record<string, readonly TemplateInputDescriptor[]>>;
1756
+ /**
1757
+ * Coerce a flat `{[…]}` param map into a partial typed template input.
1758
+ *
1759
+ * Known descriptor keys are coerced per their `coerce` strategy; unknown keys
1760
+ * (and `string`-typed keys) pass through unchanged. A coercion failure leaves
1761
+ * the raw string in place and adds a human-readable `warnings` entry — the
1762
+ * output is never lossy. Returns a fresh object; the caller must NOT feed
1763
+ * `templateOverrides` back in — only the ephemeral merged input is coerced.
1764
+ */
1765
+ declare function coerceTemplateParams(template: string | undefined, params: Record<string, string>): {
1766
+ input: Record<string, unknown>;
1767
+ warnings: string[];
1768
+ };
1769
+ /**
1770
+ * Lint a template's `{[…]}` params against its descriptors. Returns findings
1771
+ * for unknown keys, values outside a closed enum / that fail coercion, and
1772
+ * missing required inputs. Templates without descriptors (custom or
1773
+ * not-yet-described built-ins) return `[]`.
1774
+ */
1775
+ declare function lintTemplateParams(template: string, params: Record<string, string>): TemplateParamFinding[];
1776
+
1777
+ export { type AccentLayout, type AudioSegmentTiming, BASE_INPUT_DESCRIPTORS, CONTAINER_TEMPLATES, type ClipBox, type ConnectorRouting, type CoverBlockInput, DEFAULT_LAYOUT, type DataFenceParseResult, type DeriveTemplateInputsOptions, type DiagramEdge, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodePosition, DocBlock, type DocStylePreset, type DrawingConnector, type DrawingLayout, type DrawingLayoutOptions, type DrawingShape, type DrawingShapeKind, type ExpandDocBlocksOptions, type ExtractedTableData, type FirstImage, type InputCoercion, type LayoutLayerDefaults, type LayoutLayersResult, type MarkdownToDocOptions, type MarkdownValidationResult, PATH_SHAPE_KINDS, PersistentLayerConfig, type RenderContext, type RuntimeTemplateRegistry, SHAPE_NAMES, TEMPLATE_ALIASES, TEMPLATE_INPUT_DESCRIPTORS, TEMPLATE_METADATA, TemplateBlock, TemplateContext, type TemplateInputDescriptor, type TemplateMetadata, type TemplateParamFinding, Theme, ThemeColorScheme, type ValidateOptions, ViewportConfig, adjustY, applyRenderStyleToLayers, buildRegistry, clipEndpoints, clipPoint, coerceTemplateParams, comparisonBar, computeDiagramLayout, computeDrawingLayout, computeLayoutLayers, connectorPath, countBlocks, coverBlock, createAccentLayers, cssFilterForTreatment, dataTable, dateEvent, decodeLayersFromFrontmatter, definitionCard, deriveTemplateInputs, diagramBlock, docToMarkdown, drawingBlock, encodeLayersForFrontmatter, expandCoverBlock, expandDocBlocks, expandPersistentLayers, expandTemplateBlock, extractBlockquoteText, extractBodyPlainText, extractFirstImage, extractImages, extractListItems, extractTableData, extractTableFromContents, factCard, fallbackBlockLayers, findFirstTable, flattenBlocks, flattenRenderableBlocks, fullBleedQuote, getAccentLayout, getAnimationProgress, getAnimationStyle, getAvailableTemplates, getBlockDepth, getDefaultAnimation, getDefaultAnimationDuration, getDocStyleConfig, getLayers, getOverlayOpacity, getPersistentLayersFromTheme, getTemplateHint, getThemeFont, getTransitionClass, hasTemplate, imageWithCaption, isContainerTemplate, isDataFence, isShapeName, layoutBlock, leftFeature, lineStyleDasharray, lintTemplateParams, listBlock, mapBlock, markdownToDoc, markerPath, normalizeShapeKind, parseDataFence, parseYamlSubset, photoGrid, pullQuote, quoteBlock, readCustomTemplatesFromFrontmatter, readCustomThemesFromFrontmatter, replaceDataFence, resolveAudioMapping, resolveColorScheme, resolvePersistentLayers, resolveTemplateName, resolveThemeForDoc, rightFeature, scaleAnimationDuration, scoreTextSimilarity, sectionHeader, shapePath, shouldUseShadow, statHighlight, templateRegistry, themeWantsAmbientMotion, themedEntrance, themedFontSize, themedImageTreatment, themedScrim, themedSurfaceGradient, titleBlock, twoColumn, validateMarkdownDoc, validateMarkdownSource, videoPullQuote, videoWithCaption, wrapWithPersistentLayers, writeCustomTemplatesToFrontmatter, writeCustomThemesToFrontmatter };
@@ -1,12 +1,15 @@
1
1
  import {
2
+ BASE_INPUT_DESCRIPTORS,
2
3
  CONTAINER_TEMPLATES,
3
4
  DEFAULT_LAYOUT,
4
5
  SHAPE_NAMES,
5
6
  TEMPLATE_ALIASES,
7
+ TEMPLATE_INPUT_DESCRIPTORS,
6
8
  TEMPLATE_METADATA,
7
9
  adjustY,
8
10
  applyRenderStyleToLayers,
9
11
  buildRegistry,
12
+ coerceTemplateParams,
10
13
  comparisonBar,
11
14
  computeDiagramLayout,
12
15
  computeDrawingLayout,
@@ -62,6 +65,7 @@ import {
62
65
  isShapeName,
63
66
  layoutBlock,
64
67
  leftFeature,
68
+ lintTemplateParams,
65
69
  listBlock,
66
70
  mapBlock,
67
71
  markdownToDoc,
@@ -73,6 +77,7 @@ import {
73
77
  quoteBlock,
74
78
  readCustomTemplatesFromFrontmatter,
75
79
  readCustomThemesFromFrontmatter,
80
+ replaceDataFence,
76
81
  resolveAudioMapping,
77
82
  resolveColorScheme,
78
83
  resolvePersistentLayers,
@@ -100,8 +105,8 @@ import {
100
105
  wrapWithPersistentLayers,
101
106
  writeCustomTemplatesToFrontmatter,
102
107
  writeCustomThemesToFrontmatter
103
- } from "../chunk-4ZFRZBU2.js";
104
- import "../chunk-LRCV7N7F.js";
108
+ } from "../chunk-LH7I6SH7.js";
109
+ import "../chunk-UY7KGQ5R.js";
105
110
  import "../chunk-V543AI2F.js";
106
111
  import "../chunk-FVTQSNR7.js";
107
112
  import {
@@ -120,7 +125,7 @@ import {
120
125
  getAvailableThemes,
121
126
  getThemeSummaries,
122
127
  resolveTheme
123
- } from "../chunk-4AKUQPEE.js";
128
+ } from "../chunk-KKNUBQ6Y.js";
124
129
  import {
125
130
  VIEWPORT_PRESETS,
126
131
  createTemplateContext,
@@ -131,13 +136,13 @@ import {
131
136
  isTemplateBlock,
132
137
  scaledFontSize2 as scaledFontSize
133
138
  } from "../chunk-DLXZMS5K.js";
134
- import "../chunk-OH43RPTP.js";
135
- import "../chunk-OKJOQXK4.js";
139
+ import "../chunk-TEEEILMP.js";
140
+ import "../chunk-HWVFJAAH.js";
136
141
  import "../chunk-DBS6RIFL.js";
137
142
  import "../chunk-QE7TWGVT.js";
138
- import "../chunk-4X3JQXNM.js";
139
- import "../chunk-3E5F2XMR.js";
143
+ import "../chunk-VWUFZ6ZG.js";
140
144
  export {
145
+ BASE_INPUT_DESCRIPTORS,
141
146
  CONTAINER_TEMPLATES,
142
147
  DEFAULT_LAYOUT,
143
148
  DEFAULT_THEME,
@@ -146,6 +151,7 @@ export {
146
151
  PATH_SHAPE_KINDS,
147
152
  SHAPE_NAMES,
148
153
  TEMPLATE_ALIASES,
154
+ TEMPLATE_INPUT_DESCRIPTORS,
149
155
  TEMPLATE_METADATA,
150
156
  VIEWPORT_PRESETS,
151
157
  adjustY,
@@ -153,6 +159,7 @@ export {
153
159
  buildRegistry,
154
160
  clipEndpoints,
155
161
  clipPoint,
162
+ coerceTemplateParams,
156
163
  comparisonBar,
157
164
  computeDiagramLayout,
158
165
  computeDrawingLayout,
@@ -218,6 +225,7 @@ export {
218
225
  layoutBlock,
219
226
  leftFeature,
220
227
  lineStyleDasharray,
228
+ lintTemplateParams,
221
229
  listBlock,
222
230
  mapBlock,
223
231
  markdownToDoc,
@@ -230,6 +238,7 @@ export {
230
238
  quoteBlock,
231
239
  readCustomTemplatesFromFrontmatter,
232
240
  readCustomThemesFromFrontmatter,
241
+ replaceDataFence,
233
242
  resolveAudioMapping,
234
243
  resolveColorScheme,
235
244
  resolvePersistentLayers,
@@ -1,4 +1,4 @@
1
- import { ax as Theme } from './Doc-BsMkmzH8.js';
1
+ import { az as Theme } from './Doc-BBVGq1_9.js';
2
2
 
3
3
  /**
4
4
  * Built-in Theme Library
@@ -1,16 +1,14 @@
1
1
  import "../chunk-DNJKBAI7.js";
2
- import {
3
- estimateNarrationTime,
4
- estimateReadingTime
5
- } from "../chunk-4X3JQXNM.js";
6
2
  import {
7
3
  DEFAULT_WORDS_PER_SECOND,
8
4
  calculatePrefixDuration,
9
5
  countSpokenWords,
10
6
  estimateNarrationDuration,
7
+ estimateNarrationTime,
8
+ estimateReadingTime,
11
9
  estimateSpokenWordCount,
12
10
  estimateTimeFromText
13
- } from "../chunk-3E5F2XMR.js";
11
+ } from "../chunk-VWUFZ6ZG.js";
14
12
  export {
15
13
  DEFAULT_WORDS_PER_SECOND,
16
14
  calculatePrefixDuration,
@@ -1,4 +1,4 @@
1
- import { m as ColorScheme, x as Doc, B as Block } from '../Doc-BsMkmzH8.js';
1
+ import { m as ColorScheme, y as Doc, B as Block } from '../Doc-BBVGq1_9.js';
2
2
  import { d as ExtractionType, E as ExtractedElement, b as ExtractionOptions } from '../contentExtractor-BNfVJV2U.js';
3
3
  import '../types-DlAZ7MW4.js';
4
4
 
@@ -70,7 +70,7 @@ interface TransformStyleConfig {
70
70
  outro?: boolean;
71
71
  };
72
72
  /**
73
- * Duration-based slide budget (folded in from the deprecated
73
+ * Duration-based slide budget (folded in from the since-removed
74
74
  * `generateSlideshow`): caps promoted template blocks at roughly
75
75
  * `slidesPerMinute` of doc duration (default: uncapped — only
76
76
  * `transformRatio` applies).
@@ -8,11 +8,11 @@ import {
8
8
  registerTransformStyle,
9
9
  resolveTransformStyle,
10
10
  unregisterTransformStyle
11
- } from "../chunk-TVSGLUID.js";
12
- import "../chunk-FR2RBTKO.js";
11
+ } from "../chunk-LDQ2HJIX.js";
12
+ import "../chunk-TQWLI6S2.js";
13
13
  import "../chunk-FVTQSNR7.js";
14
14
  import "../chunk-DLXZMS5K.js";
15
- import "../chunk-OKJOQXK4.js";
15
+ import "../chunk-HWVFJAAH.js";
16
16
  export {
17
17
  DEFAULT_TRANSFORM_STYLE_ID,
18
18
  analyzeBlocks,
@@ -1,5 +1,5 @@
1
1
  import { C as CoalesceOptions, V as Version, P as PrunePolicy, R as RevertOptions, S as SaveVersionOptions, a as SaveVersionResult } from '../types-D98li1gV.js';
2
- import { C as ContentContainer } from '../ContentContainer-DNx460y8.js';
2
+ import { C as ContentContainer } from '../ContentContainer-BXUlIf18.js';
3
3
 
4
4
  /**
5
5
  * Pure-ish version operations against a {@link ContentContainer}.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Headless utilities for doc/block rendering, spatial math, Markdown, and storage",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -116,6 +116,36 @@ describe('extractContent — comparisons', () => {
116
116
  expect(comp.right.label).toBeTruthy();
117
117
  }
118
118
  });
119
+
120
+ it('does not let a comparison capture cross a sentence boundary', () => {
121
+ // Regression: greedy `.{2,30}` used to capture right="witness. Robertson"
122
+ // from the "from witness to witness." idiom bleeding into the next sentence.
123
+ const text =
124
+ 'Testimony about who carried guns and how many shots were fired ' +
125
+ 'contradicted from witness to witness. Robertson, who initially ' +
126
+ 'testified against the others, was later named as the ringleader.';
127
+ const result = extractContent(text, { types: ['comparison'] });
128
+ for (const el of result.elements) {
129
+ if (el.data.type === 'comparison') {
130
+ expect(el.data.left.label).not.toContain('.');
131
+ expect(el.data.right.label).not.toContain('.');
132
+ expect(el.data.right.label.toLowerCase()).not.toContain('robertson');
133
+ }
134
+ }
135
+ });
136
+
137
+ it('rejects idiomatic "from X to X" non-comparisons', () => {
138
+ // "from witness to witness", "from day to day" mean "it varied", not "A vs B".
139
+ for (const text of [
140
+ 'The testimony varied from witness to witness across the trial.',
141
+ 'Conditions changed from day to day during the expedition.',
142
+ 'The practice was handed down from generation to generation.',
143
+ ]) {
144
+ const result = extractContent(text, { types: ['comparison'] });
145
+ const comparisons = result.elements.filter((e) => e.data.type === 'comparison');
146
+ expect(comparisons.length).toBe(0);
147
+ }
148
+ });
119
149
  });
120
150
 
121
151
  describe('extractContent — impact lines', () => {
@@ -125,6 +155,36 @@ describe('extractContent — impact lines', () => {
125
155
  const result = extractContent(text, { types: ['impactLine'] });
126
156
  expect(result.elements.length).toBeGreaterThan(0);
127
157
  });
158
+
159
+ it('does not split sentences on abbreviations or initials', () => {
160
+ // Regression: "…at George W. Tibbetts' store…" was split at "W." into the
161
+ // fragment "They were met at George W" and shown as a standalone slide.
162
+ const text =
163
+ "They were met at George W. Tibbetts' store by a crowd of white and Native men " +
164
+ 'who intimidated them into turning back.';
165
+ const result = extractContent(text, { types: ['impactLine', 'fact'] });
166
+ for (const el of result.elements) {
167
+ if (el.data.type === 'impactLine') {
168
+ expect(el.data.text).not.toMatch(/George W$/);
169
+ }
170
+ if (el.data.type === 'fact') {
171
+ expect(el.data.fact).not.toMatch(/George W$/);
172
+ }
173
+ // No fragment should end on a bare single-letter initial.
174
+ expect(el.text).not.toMatch(/\b[A-Z]$/);
175
+ }
176
+ });
177
+
178
+ it('keeps initials-heavy name lists intact as one sentence', () => {
179
+ const text =
180
+ 'A jury named M. DeWitt Rumsey, Joseph Day, and David Hughes among the killers. ' +
181
+ 'The verdict shocked the town.';
182
+ const result = extractContent(text, { types: ['fact', 'impactLine'] });
183
+ // The "M." initial must not spawn a "A jury named M" fragment.
184
+ for (const el of result.elements) {
185
+ expect(el.text).not.toMatch(/named M$/);
186
+ }
187
+ });
128
188
  });
129
189
 
130
190
  describe('extractContent — lists', () => {
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { coverBlock, expandCoverBlock, startBlockToCoverInput } from '../doc/templates/coverBlock';
2
+ import {
3
+ coverBlock,
4
+ expandCoverBlock,
5
+ startBlockToCoverInput,
6
+ fitCoverTitleSize,
7
+ } from '../doc/templates/coverBlock';
3
8
  import { createTemplateContext, DEFAULT_THEME, VIEWPORT_PRESETS } from '../doc/templates/index';
4
9
  import type { StartBlockConfig } from '../schemas/Doc';
5
10
  import type { CoverBlockInput } from '../doc/templates/coverBlock';
@@ -179,6 +184,46 @@ describe('coverBlock viewport scaling', () => {
179
184
  });
180
185
  });
181
186
 
187
+ // ── Long-title auto-fit ─────────────────────────────────────────────
188
+
189
+ describe('coverBlock long-title auto-fit', () => {
190
+ const LONG = 'Seattle: The Emerald City That Rebuilt Itself on Top of Itself';
191
+
192
+ it('fitCoverTitleSize returns full base for short titles', () => {
193
+ expect(fitCoverTitleSize('Kirkland')).toBe(120);
194
+ expect(fitCoverTitleSize('Test Title')).toBe(120);
195
+ });
196
+
197
+ it('fitCoverTitleSize shrinks long titles below the full base', () => {
198
+ const size = fitCoverTitleSize(LONG);
199
+ expect(size).toBeLessThan(120);
200
+ expect(size).toBeGreaterThanOrEqual(62); // never below the readability floor
201
+ });
202
+
203
+ it('fitCoverTitleSize never drops below the readability floor', () => {
204
+ const size = fitCoverTitleSize('x'.repeat(400));
205
+ expect(size).toBe(62);
206
+ });
207
+
208
+ it('fitCoverTitleSize is monotonic — longer titles are never larger', () => {
209
+ const a = fitCoverTitleSize('A moderately long cover title here');
210
+ const b = fitCoverTitleSize('A moderately long cover title here that keeps going and going');
211
+ expect(b).toBeLessThanOrEqual(a);
212
+ });
213
+
214
+ it('renders a long title with a smaller font than a short one', () => {
215
+ const shortTitle = findText(
216
+ coverBlock({ heroSrc: 'h.jpg', title: 'Short' }, landscapeContext),
217
+ 'cover-title',
218
+ );
219
+ const longTitle = findText(
220
+ coverBlock({ heroSrc: 'h.jpg', title: LONG }, landscapeContext),
221
+ 'cover-title',
222
+ );
223
+ expect(longTitle!.content.style.fontSize).toBeLessThan(shortTitle!.content.style.fontSize);
224
+ });
225
+ });
226
+
182
227
  // ── startBlockToCoverInput ──────────────────────────────────────────
183
228
 
184
229
  describe('startBlockToCoverInput', () => {
@@ -11,8 +11,12 @@ import { describe, it, expect } from 'vitest';
11
11
  import { markdownToDoc } from '../doc/markdownToDoc';
12
12
  import { expandDocBlocks } from '../doc/templates/index';
13
13
  import { parseMarkdown } from '../markdown/parse';
14
- import { writeCustomTemplatesToFrontmatter } from '../doc/customTemplatesFrontmatter';
15
- import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
14
+ import {
15
+ writeCustomTemplatesToFrontmatter,
16
+ readCustomTemplatesFromFrontmatter,
17
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
18
+ } from '../doc/customTemplatesFrontmatter';
19
+ import type { CustomTemplateDefinition, LayerRepeat } from '../schemas/CustomTemplates.js';
16
20
  import type { TextLayer } from '../schemas/Doc.js';
17
21
  import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
18
22
 
@@ -80,4 +84,61 @@ describe('Custom template pipeline (markdown → expanded layers)', () => {
80
84
  expect(layers[0].position.x).toBe('6%');
81
85
  expect(layers[0].position.width).toBe('88%');
82
86
  });
87
+
88
+ it('resolves {attr:key} against a block heading annotation through the pipeline', () => {
89
+ const attrDef: CustomTemplateDefinition = {
90
+ name: 'badge',
91
+ label: 'Badge',
92
+ viewport: { width: 1920, height: 1080 },
93
+ layers: [
94
+ {
95
+ id: 'badge-role',
96
+ type: 'text',
97
+ position: { x: '6%', y: '20%', width: '88%' },
98
+ content: { text: 'Role: {attr:role|none}', style: { fontSize: 48, color: '#000' } },
99
+ },
100
+ ],
101
+ };
102
+ const payload = writeCustomTemplatesToFrontmatter([attrDef]);
103
+ const md = `---
104
+ squisq-custom-templates: "${payload}"
105
+ ---
106
+
107
+ # Team {[badge role=lead]}
108
+
109
+ Body.
110
+ `;
111
+ const doc = markdownToDoc(parseMarkdown(md));
112
+ const blocks = expandDocBlocks(doc.blocks, { customTemplates: doc.customTemplates });
113
+ const layers = blocks[0].layers ?? [];
114
+ expect((layers[0] as TextLayer).content.text).toBe('Role: lead');
115
+ });
116
+ });
117
+
118
+ describe('Custom template codec — repeat round-trip', () => {
119
+ it('preserves a layer.repeat directive through write → read (passes through unmapped)', () => {
120
+ const repeat: LayerRepeat = { source: 'images', direction: 'row', gap: 12, max: 4 };
121
+ const def: CustomTemplateDefinition = {
122
+ name: 'gallery',
123
+ label: 'Gallery',
124
+ viewport: { width: 1920, height: 1080 },
125
+ layers: [
126
+ {
127
+ id: 'thumb',
128
+ type: 'image',
129
+ position: { x: '0%', y: '0%', width: '25%', height: '25%' },
130
+ content: { src: '{item:src}', alt: '{item:label}' },
131
+ repeat,
132
+ },
133
+ ],
134
+ };
135
+ const payload = writeCustomTemplatesToFrontmatter([def]);
136
+ expect(payload).toBeDefined();
137
+ const round = readCustomTemplatesFromFrontmatter({
138
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: JSON.parse(payload as string),
139
+ });
140
+ expect(round).toBeDefined();
141
+ const layer = round?.[0].layers[0] as (typeof def.layers)[number];
142
+ expect(layer.repeat).toEqual(repeat);
143
+ });
83
144
  });
@@ -71,6 +71,37 @@ describe('writeCustomTemplatesToFrontmatter → readCustomTemplatesFromFrontmatt
71
71
  expect(out![0]).toEqual(heroDef);
72
72
  });
73
73
 
74
+ it('default output is compact (single line) and byte-unchanged', () => {
75
+ const payload = writeCustomTemplatesToFrontmatter([heroDef])!;
76
+ expect(payload).not.toContain('\n');
77
+ // Explicit pretty:false must match the historical default exactly.
78
+ expect(writeCustomTemplatesToFrontmatter([heroDef], { pretty: false })).toBe(payload);
79
+ });
80
+
81
+ it('pretty:true emits multi-line JSON that still reads back', () => {
82
+ const payload = writeCustomTemplatesToFrontmatter([heroDef], { pretty: true })!;
83
+ expect(payload).toContain('\n');
84
+ // Same compact key codec, just pretty-printed.
85
+ expect(payload).toContain('"lb": "Hero"');
86
+ const fm = { [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload };
87
+ const out = readCustomTemplatesFromFrontmatter(fm);
88
+ expect(out).toHaveLength(1);
89
+ expect(out![0]).toEqual(heroDef);
90
+ });
91
+
92
+ it('a hand-written block-scalar frontmatter parses back into the definition', () => {
93
+ const payload = writeCustomTemplatesToFrontmatter([heroDef], { pretty: true })!;
94
+ const body = payload
95
+ .split('\n')
96
+ .map((l) => ` ${l}`)
97
+ .join('\n');
98
+ const md = `---\n${FRONTMATTER_CUSTOM_TEMPLATES_KEY}: |-\n${body}\n---\n\n# Hi\n`;
99
+ const parsed = parseMarkdown(md);
100
+ const out = readCustomTemplatesFromFrontmatter(parsed.frontmatter);
101
+ expect(out).toHaveLength(1);
102
+ expect(out![0]).toEqual(heroDef);
103
+ });
104
+
74
105
  it('is markedly smaller than the legacy base64 form', () => {
75
106
  const compact = writeCustomTemplatesToFrontmatter([heroDef])!;
76
107
  const legacyBase64 = btoa(unescape(encodeURIComponent(JSON.stringify([{ ...heroDef }]))));
@@ -37,6 +37,33 @@ describe('writeCustomThemesToFrontmatter → readCustomThemesFromFrontmatter', (
37
37
  expect(out![0]).toEqual(brand);
38
38
  });
39
39
 
40
+ it('default output is compact (single line) and byte-unchanged', () => {
41
+ const payload = writeCustomThemesToFrontmatter([brand])!;
42
+ expect(payload).not.toContain('\n');
43
+ expect(writeCustomThemesToFrontmatter([brand], { pretty: false })).toBe(payload);
44
+ });
45
+
46
+ it('pretty:true emits multi-line JSON that still reads back', () => {
47
+ const payload = writeCustomThemesToFrontmatter([brand], { pretty: true })!;
48
+ expect(payload).toContain('\n');
49
+ const out = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
50
+ expect(out).toHaveLength(1);
51
+ expect(out![0]).toEqual(brand);
52
+ });
53
+
54
+ it('a hand-written block-scalar frontmatter parses back into the theme', () => {
55
+ const payload = writeCustomThemesToFrontmatter([brand], { pretty: true })!;
56
+ const body = payload
57
+ .split('\n')
58
+ .map((l) => ` ${l}`)
59
+ .join('\n');
60
+ const md = `---\n${FRONTMATTER_CUSTOM_THEMES_KEY}: |-\n${body}\n---\n\n# Hi\n`;
61
+ const parsed = parseMarkdown(md);
62
+ const out = readCustomThemesFromFrontmatter(parsed.frontmatter);
63
+ expect(out).toHaveLength(1);
64
+ expect(out![0]).toEqual(brand);
65
+ });
66
+
40
67
  it('round-trips multiple themes keyed by id', () => {
41
68
  const second = compileTheme({
42
69
  id: 'second',