@bendyline/squisq 2.0.0 → 2.1.0

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 (85) hide show
  1. package/dist/{Doc-ylqWKDc_.d.ts → Doc-DynJleo-.d.ts} +159 -2
  2. package/dist/{ImageEditDoc-D828IXJ1.d.ts → ImageEditDoc-Ca4Tr9uN.d.ts} +1 -1
  3. package/dist/{chunk-PXLVXTLW.js → chunk-ILWDXLUK.js} +24 -6
  4. package/dist/chunk-ILWDXLUK.js.map +1 -0
  5. package/dist/{chunk-6PJRB2CF.js → chunk-JHZWYRSW.js} +570 -2
  6. package/dist/chunk-JHZWYRSW.js.map +1 -0
  7. package/dist/{chunk-GF3BVNLU.js → chunk-JI77MGAK.js} +195 -27
  8. package/dist/chunk-JI77MGAK.js.map +1 -0
  9. package/dist/{chunk-QVANWDPH.js → chunk-OTKVFVL6.js} +1403 -41
  10. package/dist/chunk-OTKVFVL6.js.map +1 -0
  11. package/dist/{chunk-LYNJEOAC.js → chunk-QUUPYOSP.js} +8 -2
  12. package/dist/chunk-QUUPYOSP.js.map +1 -0
  13. package/dist/chunk-VWCJ2WXC.js +215 -0
  14. package/dist/chunk-VWCJ2WXC.js.map +1 -0
  15. package/dist/{chunk-QTSJOY54.js → chunk-VYFRI7R6.js} +2 -2
  16. package/dist/{chunk-T3TI7GSZ.js → chunk-XNLB57UD.js} +2 -2
  17. package/dist/doc/index.d.ts +144 -5
  18. package/dist/doc/index.js +24 -4
  19. package/dist/generate/index.d.ts +1 -1
  20. package/dist/imageEdit/index.d.ts +3 -3
  21. package/dist/index.d.ts +6 -5
  22. package/dist/index.js +66 -8
  23. package/dist/jsonForm/index.d.ts +1 -1
  24. package/dist/jsonForm/index.js +4 -4
  25. package/dist/materializePageSection-DDzubQXo.d.ts +223 -0
  26. package/dist/narration/index.d.ts +1 -1
  27. package/dist/narration/index.js +3 -3
  28. package/dist/schemas/index.d.ts +30 -5
  29. package/dist/schemas/index.js +41 -3
  30. package/dist/{themeLibrary-RjVgLpUi.d.ts → themeLibrary-B72m0W1y.d.ts} +1 -1
  31. package/dist/transform/index.d.ts +8 -9
  32. package/dist/transform/index.js +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/customThemesFrontmatter.test.ts +1 -0
  35. package/src/__tests__/markdown.test.ts +13 -4
  36. package/src/__tests__/markdownSanitize.test.ts +7 -5
  37. package/src/__tests__/materializeBlockLayers.test.ts +32 -0
  38. package/src/__tests__/materializePageSections.test.ts +404 -0
  39. package/src/__tests__/pageStyle.test.ts +258 -0
  40. package/src/__tests__/templateAnnotationParse.test.ts +13 -0
  41. package/src/doc/index.ts +34 -0
  42. package/src/doc/markdownToDoc.ts +3 -1
  43. package/src/doc/materializeBlockLayers.ts +170 -9
  44. package/src/doc/page/PageSection.ts +174 -0
  45. package/src/doc/page/materializePageSection.ts +616 -0
  46. package/src/doc/page/resolvePageBlock.ts +108 -0
  47. package/src/doc/page/sectionExtractors.ts +466 -0
  48. package/src/doc/pageCss.ts +628 -0
  49. package/src/doc/templateInputs.ts +1 -0
  50. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  51. package/src/doc/templates/listBlock.ts +88 -30
  52. package/src/schemas/Doc.ts +22 -1
  53. package/src/schemas/PageStyle.ts +213 -0
  54. package/src/schemas/Theme.ts +7 -0
  55. package/src/schemas/index.ts +2 -0
  56. package/src/schemas/pageStyleDefaults.ts +204 -0
  57. package/src/schemas/themeCompile.ts +7 -0
  58. package/src/schemas/themeValidator.ts +183 -0
  59. package/src/schemas/themes/bold.json +29 -0
  60. package/src/schemas/themes/cinematic.json +32 -0
  61. package/src/schemas/themes/documentary.json +29 -0
  62. package/src/schemas/themes/gezellig.json +31 -1
  63. package/src/schemas/themes/magazine.json +37 -0
  64. package/src/schemas/themes/minimalist.json +29 -0
  65. package/src/schemas/themes/morning-light.json +32 -0
  66. package/src/schemas/themes/standard-dark.json +24 -0
  67. package/src/schemas/themes/standard.json +24 -0
  68. package/src/schemas/themes/tech-dark.json +42 -0
  69. package/src/schemas/themes/warm-earth.json +24 -0
  70. package/src/transform/registry.ts +19 -0
  71. package/src/transform/styles/dataDriven.ts +1 -0
  72. package/src/transform/styles/documentary.ts +1 -0
  73. package/src/transform/styles/magazine.ts +1 -0
  74. package/src/transform/styles/minimal.ts +1 -0
  75. package/src/transform/styles/narrative.ts +1 -0
  76. package/src/transform/types.ts +7 -0
  77. package/dist/chunk-6PJRB2CF.js.map +0 -1
  78. package/dist/chunk-FVJUUTEM.js +0 -74
  79. package/dist/chunk-FVJUUTEM.js.map +0 -1
  80. package/dist/chunk-GF3BVNLU.js.map +0 -1
  81. package/dist/chunk-LYNJEOAC.js.map +0 -1
  82. package/dist/chunk-PXLVXTLW.js.map +0 -1
  83. package/dist/chunk-QVANWDPH.js.map +0 -1
  84. /package/dist/{chunk-QTSJOY54.js.map → chunk-VYFRI7R6.js.map} +0 -0
  85. /package/dist/{chunk-T3TI7GSZ.js.map → chunk-XNLB57UD.js.map} +0 -0
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateTheme } from '../schemas/themeValidator.js';
3
+ import { compileTheme } from '../schemas/themeCompile.js';
4
+ import { defaultPageStyle } from '../schemas/pageStyleDefaults.js';
5
+ import { THEMES, DEFAULT_THEME } from '../schemas/themeLibrary.js';
6
+ import { PAGE_SECTION_KINDS } from '../schemas/PageStyle.js';
7
+ import type { Theme } from '../schemas/Theme.js';
8
+ import type { ThemePageStyle } from '../schemas/PageStyle.js';
9
+ import {
10
+ readCustomThemesFromFrontmatter,
11
+ writeCustomThemesToFrontmatter,
12
+ } from '../doc/customThemesFrontmatter.js';
13
+
14
+ function clone<T>(v: T): T {
15
+ return JSON.parse(JSON.stringify(v)) as T;
16
+ }
17
+
18
+ function themed(pageStyle: unknown): Record<string, unknown> {
19
+ return { ...clone(DEFAULT_THEME as unknown as Record<string, unknown>), pageStyle };
20
+ }
21
+
22
+ const VALID_PAGE_STYLE: ThemePageStyle = {
23
+ family: 'clean',
24
+ tokens: {
25
+ contentMaxWidth: 760,
26
+ wideMaxWidth: 1100,
27
+ sectionSpacing: 'comfortable',
28
+ cornerRadius: 8,
29
+ divider: 'hairline',
30
+ backgroundRhythm: 'alternate',
31
+ heroStyle: 'stacked',
32
+ headingTreatment: { eyebrow: 'kicker', scale: 'regular' },
33
+ imageFraming: 'rounded',
34
+ shadow: 'soft',
35
+ quoteMark: 'accent-bar',
36
+ numeralStyle: 'plain',
37
+ },
38
+ accentRotation: { strategy: 'cycle' },
39
+ };
40
+
41
+ describe('pageStyle validation', () => {
42
+ it('accepts a theme without pageStyle (optional)', () => {
43
+ const theme = clone(DEFAULT_THEME) as unknown as Record<string, unknown>;
44
+ delete theme.pageStyle;
45
+ expect(validateTheme(theme).valid).toBe(true);
46
+ });
47
+
48
+ it('accepts a complete valid pageStyle', () => {
49
+ expect(validateTheme(themed(clone(VALID_PAGE_STYLE))).valid).toBe(true);
50
+ });
51
+
52
+ it('rejects an unknown family', () => {
53
+ const ps = clone(VALID_PAGE_STYLE) as unknown as Record<string, unknown>;
54
+ ps.family = 'baroque';
55
+ const result = validateTheme(themed(ps));
56
+ expect(result.valid).toBe(false);
57
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.family');
58
+ });
59
+
60
+ it('rejects a bad token enum value', () => {
61
+ const ps = clone(VALID_PAGE_STYLE);
62
+ (ps.tokens as unknown as Record<string, unknown>).divider = 'lasers';
63
+ const result = validateTheme(themed(ps));
64
+ expect(result.valid).toBe(false);
65
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.tokens.divider');
66
+ });
67
+
68
+ it('rejects non-numeric column widths', () => {
69
+ const ps = clone(VALID_PAGE_STYLE);
70
+ (ps.tokens as unknown as Record<string, unknown>).contentMaxWidth = '760px';
71
+ const result = validateTheme(themed(ps));
72
+ expect(result.valid).toBe(false);
73
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.tokens.contentMaxWidth');
74
+ });
75
+
76
+ it('rejects unknown section-kind keys in sections', () => {
77
+ const ps = clone(VALID_PAGE_STYLE);
78
+ ps.sections = { jumbotron: { variant: 'x' } } as unknown as ThemePageStyle['sections'];
79
+ const result = validateTheme(themed(ps));
80
+ expect(result.valid).toBe(false);
81
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.sections.jumbotron');
82
+ });
83
+
84
+ it('rejects non-scalar hint values', () => {
85
+ const ps = clone(VALID_PAGE_STYLE);
86
+ ps.templates = {
87
+ diagram: { hints: { frame: { nested: true } } },
88
+ } as unknown as ThemePageStyle['templates'];
89
+ const result = validateTheme(themed(ps));
90
+ expect(result.valid).toBe(false);
91
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.templates.diagram.hints.frame');
92
+ });
93
+
94
+ it('requires accentRotation with a known strategy', () => {
95
+ const ps = clone(VALID_PAGE_STYLE) as unknown as Record<string, unknown>;
96
+ delete ps.accentRotation;
97
+ const missing = validateTheme(themed(ps));
98
+ expect(missing.valid).toBe(false);
99
+ expect(missing.errors.map((e) => e.path)).toContain('pageStyle.accentRotation');
100
+
101
+ const ps2 = clone(VALID_PAGE_STYLE);
102
+ ps2.accentRotation = { strategy: 'random' } as unknown as ThemePageStyle['accentRotation'];
103
+ const bad = validateTheme(themed(ps2));
104
+ expect(bad.valid).toBe(false);
105
+ expect(bad.errors.map((e) => e.path)).toContain('pageStyle.accentRotation.strategy');
106
+ });
107
+
108
+ it('rejects a section override that remaps to an invalid kind', () => {
109
+ const ps = clone(VALID_PAGE_STYLE);
110
+ ps.sections = {
111
+ 'quote-band': { kind: 'jumbo' },
112
+ } as unknown as ThemePageStyle['sections'];
113
+ const result = validateTheme(themed(ps));
114
+ expect(result.valid).toBe(false);
115
+ expect(result.errors.map((e) => e.path)).toContain('pageStyle.sections.quote-band.kind');
116
+ });
117
+ });
118
+
119
+ describe('built-in theme pageStyle art direction', () => {
120
+ it('every built-in theme declares an explicit pageStyle', () => {
121
+ for (const [id, theme] of Object.entries(THEMES)) {
122
+ expect(theme.pageStyle, `theme "${id}" is missing pageStyle`).toBeDefined();
123
+ }
124
+ });
125
+
126
+ it('every built-in art direction is pairwise distinct', () => {
127
+ // Two themes may share a family, but their page identity — the tuple of
128
+ // (family, heroStyle, divider, backgroundRhythm, imageFraming) — must
129
+ // differ so each theme reads as its own website design.
130
+ const seen = new Map<string, string>();
131
+ for (const [id, theme] of Object.entries(THEMES)) {
132
+ const ps = theme.pageStyle!;
133
+ const tuple = [
134
+ ps.family,
135
+ ps.tokens.heroStyle,
136
+ ps.tokens.divider,
137
+ ps.tokens.backgroundRhythm,
138
+ ps.tokens.imageFraming,
139
+ ].join('|');
140
+ const prior = seen.get(tuple);
141
+ expect(prior, `themes "${prior}" and "${id}" share art direction tuple ${tuple}`).toBe(
142
+ undefined,
143
+ );
144
+ seen.set(tuple, id);
145
+ }
146
+ });
147
+
148
+ it('section override keys reference valid kinds in every built-in', () => {
149
+ for (const theme of Object.values(THEMES)) {
150
+ for (const key of Object.keys(theme.pageStyle?.sections ?? {})) {
151
+ expect(PAGE_SECTION_KINDS).toContain(key);
152
+ }
153
+ }
154
+ });
155
+ });
156
+
157
+ describe('defaultPageStyle derivation', () => {
158
+ it('maps renderStyle.name to the matching design family', () => {
159
+ const base = clone(DEFAULT_THEME);
160
+ const cases: Array<[string, string]> = [
161
+ ['documentary', 'documentary'],
162
+ ['magazine', 'editorial'],
163
+ ['bold', 'brutalist'],
164
+ ['tech-dark', 'terminal'],
165
+ ['cinematic', 'cinematic'],
166
+ ['warm-earth', 'organic'],
167
+ ['gezellig', 'soft'],
168
+ ['standard', 'clean'],
169
+ ['some-unknown-style', 'clean'],
170
+ ];
171
+ for (const [renderName, family] of cases) {
172
+ const theme = { ...base, renderStyle: { ...base.renderStyle, name: renderName } } as Theme;
173
+ expect(defaultPageStyle(theme).family, renderName).toBe(family);
174
+ }
175
+ });
176
+
177
+ it('derives cornerRadius and framing from style fields', () => {
178
+ const base = clone(DEFAULT_THEME);
179
+ const rounded = defaultPageStyle({ ...base, style: { ...base.style, borderRadius: 12 } });
180
+ expect(rounded.tokens.cornerRadius).toBe(12);
181
+ expect(rounded.tokens.imageFraming).toBe('rounded');
182
+
183
+ const flush = defaultPageStyle({ ...base, style: { ...base.style, borderRadius: 0 } });
184
+ expect(flush.tokens.imageFraming).toBe('flush');
185
+
186
+ const mono = defaultPageStyle({
187
+ ...base,
188
+ style: { ...base.style, imageTreatment: { type: 'mono', strength: 0.4 } },
189
+ });
190
+ expect(mono.tokens.imageFraming).toBe('letterboxed');
191
+ });
192
+
193
+ it('derives pattern from persistentLayers and shadow from textShadow', () => {
194
+ const base = clone(DEFAULT_THEME);
195
+ const patterned = defaultPageStyle({
196
+ ...base,
197
+ persistentLayers: {
198
+ topLayers: [
199
+ {
200
+ template: 'patternBackground',
201
+ config: { type: 'patternBackground', pattern: 'grid' },
202
+ },
203
+ ],
204
+ },
205
+ } as Theme);
206
+ expect(patterned.tokens.pattern).toBe('grid');
207
+
208
+ const shadowed = defaultPageStyle({ ...base, style: { ...base.style, textShadow: true } });
209
+ expect(shadowed.tokens.shadow).toBe('soft');
210
+ const flat = defaultPageStyle({ ...base, style: { ...base.style, textShadow: false } });
211
+ expect(flat.tokens.shadow).toBe('none');
212
+ });
213
+
214
+ it('derived styles validate', () => {
215
+ for (const theme of Object.values(THEMES)) {
216
+ const stripped = clone(theme) as unknown as Record<string, unknown>;
217
+ delete stripped.pageStyle;
218
+ const derived = { ...stripped, pageStyle: defaultPageStyle(stripped as unknown as Theme) };
219
+ expect(validateTheme(derived).valid).toBe(true);
220
+ }
221
+ });
222
+ });
223
+
224
+ describe('compileTheme pageStyle behavior', () => {
225
+ it('fills pageStyle when the partial omits it', () => {
226
+ const compiled = compileTheme({ id: 'test-fill', name: 'Test Fill' });
227
+ expect(compiled.pageStyle).toBeDefined();
228
+ expect(compiled.pageStyle!.family).toBe('clean');
229
+ });
230
+
231
+ it('preserves an explicit pageStyle untouched', () => {
232
+ const explicit = clone(VALID_PAGE_STYLE);
233
+ explicit.family = 'brutalist';
234
+ const compiled = compileTheme({
235
+ id: 'test-explicit',
236
+ name: 'Test Explicit',
237
+ pageStyle: explicit,
238
+ });
239
+ expect(compiled.pageStyle!.family).toBe('brutalist');
240
+ });
241
+
242
+ it('inherits the base theme pageStyle through compile', () => {
243
+ const compiled = compileTheme({ id: 'derived', name: 'Derived' }, { base: THEMES.magazine });
244
+ expect(compiled.pageStyle).toEqual(THEMES.magazine.pageStyle);
245
+ });
246
+ });
247
+
248
+ describe('pageStyle frontmatter round-trip', () => {
249
+ it('survives the squisq-custom-themes codec deep-equal', () => {
250
+ const theme = clone(THEMES['tech-dark']);
251
+ theme.id = 'custom-tech';
252
+ const encoded = writeCustomThemesToFrontmatter([theme]);
253
+ expect(encoded).toBeDefined();
254
+ const decoded = readCustomThemesFromFrontmatter({ 'squisq-custom-themes': encoded });
255
+ expect(decoded).toHaveLength(1);
256
+ expect(decoded![0].pageStyle).toEqual(theme.pageStyle);
257
+ });
258
+ });
@@ -57,6 +57,19 @@ describe('Template annotation in Heading parse path', () => {
57
57
  if (heading.type !== 'heading') throw new Error('expected heading');
58
58
  expect(heading.templateAnnotation).toBeUndefined();
59
59
  });
60
+
61
+ it('keeps annotation-shaped text inside a code fence as literal code', () => {
62
+ const source = '```markdown\n## Example {[comparisonBar]}\n{[ ]}\n```';
63
+ const parsed = parseMarkdown(source);
64
+
65
+ expect(parsed.children).toHaveLength(1);
66
+ expect(parsed.children[0]).toMatchObject({
67
+ type: 'code',
68
+ lang: 'markdown',
69
+ value: '## Example {[comparisonBar]}\n{[ ]}',
70
+ });
71
+ expect(stringifyMarkdown(parsed).trim()).toBe(source);
72
+ });
60
73
  });
61
74
 
62
75
  describe('Quoted values in {[…]} annotations', () => {
package/src/doc/index.ts CHANGED
@@ -28,6 +28,40 @@ export {
28
28
  FRONTMATTER_CUSTOM_THEMES_KEY,
29
29
  } from './customThemesFrontmatter.js';
30
30
  export { resolveThemeForDoc } from './resolveDocTheme.js';
31
+ export {
32
+ materializePageSection,
33
+ materializePageSections,
34
+ resolvePageStyle,
35
+ } from './page/materializePageSection.js';
36
+ export type {
37
+ MaterializePageSectionOptions,
38
+ MaterializePageSectionsOptions,
39
+ PageTransformHints,
40
+ } from './page/materializePageSection.js';
41
+ export { isTemplatedPageBlock, resolvePageBlock } from './page/resolvePageBlock.js';
42
+ export type { ResolvedPageBlock } from './page/resolvePageBlock.js';
43
+ export { sectionExtractors } from './page/sectionExtractors.js';
44
+ export type {
45
+ PageSectionContext,
46
+ PageSectionDraft,
47
+ SectionExtractor,
48
+ } from './page/sectionExtractors.js';
49
+ export type {
50
+ PageMedia,
51
+ PageRichText,
52
+ PageSection,
53
+ PageSectionDiagnostic,
54
+ PageSectionItem,
55
+ PageSectionMaterialization,
56
+ PageSectionSource,
57
+ PageSpatialKind,
58
+ } from './page/PageSection.js';
59
+ export {
60
+ PAGE_BASE_CSS,
61
+ buildPageCss,
62
+ buildPageCssVars,
63
+ pageStyleDataAttributes,
64
+ } from './pageCss.js';
31
65
  export { resolveAudioMapping, scoreTextSimilarity } from './audioMapping.js';
32
66
  export { applyNarrationTiming, type NarrationResolution } from './applyNarrationTiming.js';
33
67
  export {
@@ -40,7 +40,7 @@ import type {
40
40
  MarkdownNode,
41
41
  HtmlNode,
42
42
  } from '../markdown/types.js';
43
- import { extractPlainText } from '../markdown/utils.js';
43
+ import { extractPlainText, readFrontmatterThemeId } from '../markdown/utils.js';
44
44
  import { coerceAnnotationValues, type CoercedBlockMeta } from '../markdown/annotationCoercion.js';
45
45
  import { estimateReadingTime } from '../timing/readingTime.js';
46
46
  import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
@@ -600,6 +600,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
600
600
 
601
601
  const customTemplates = readCustomTemplatesFromFrontmatter(markdownDoc.frontmatter);
602
602
  const customThemes = readCustomThemesFromFrontmatter(markdownDoc.frontmatter);
603
+ const themeId = readFrontmatterThemeId(markdownDoc.frontmatter);
603
604
  const doc: Doc = {
604
605
  articleId,
605
606
  duration: currentTime,
@@ -609,6 +610,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
609
610
  },
610
611
  ...(captions ? { captions } : {}),
611
612
  ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
613
+ ...(themeId ? { themeId } : {}),
612
614
  ...(customTemplates ? { customTemplates } : {}),
613
615
  ...(customThemes ? { customThemes } : {}),
614
616
  ...(diagnostics.length > 0 ? { diagnostics } : {}),
@@ -6,7 +6,7 @@
6
6
  * failure policy, and diagnostics cannot drift by render mode.
7
7
  */
8
8
 
9
- import type { Block, Layer } from '../schemas/Doc.js';
9
+ import type { Block, Layer, MermaidLayer, Position } from '../schemas/Doc.js';
10
10
  import type {
11
11
  DocBlock,
12
12
  PersistentLayerConfig,
@@ -16,6 +16,7 @@ import type {
16
16
  import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
17
17
  import type { Theme } from '../schemas/Theme.js';
18
18
  import type { ViewportConfig } from '../schemas/Viewport.js';
19
+ import type { MarkdownCodeBlock } from '../markdown/types.js';
19
20
  import { createTemplateContext, isTemplateBlock } from '../schemas/BlockTemplates.js';
20
21
  import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
21
22
  import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
@@ -62,7 +63,12 @@ export type LayerMaterializationDiagnostic =
62
63
  };
63
64
 
64
65
  /** Identifies which path produced a materialized layer graph. */
65
- export type LayerMaterializationSource = 'authored' | 'template' | 'fallback' | 'empty';
66
+ export type LayerMaterializationSource =
67
+ | 'authored'
68
+ | 'template'
69
+ | 'rich-content'
70
+ | 'fallback'
71
+ | 'empty';
66
72
 
67
73
  /**
68
74
  * Complete result from {@link materializeBlockLayers}. Failures are data, not
@@ -145,9 +151,10 @@ export function materializeBlockLayersWithRuntime(
145
151
 
146
152
  const existingLayers = (block as Block).layers;
147
153
  if (existingLayers && existingLayers.length > 0 && !isTemplateBlock(block)) {
154
+ const layers = appendMermaidLayers(cloneData(existingLayers), block as Block, theme, viewport);
148
155
  return {
149
156
  layers: injectPersistentLayers(
150
- cloneData(existingLayers),
157
+ layers,
151
158
  block,
152
159
  theme,
153
160
  effectivePersistentLayers,
@@ -158,15 +165,16 @@ export function materializeBlockLayersWithRuntime(
158
165
  }
159
166
 
160
167
  if (!isTemplateBlock(block)) {
168
+ const layers = appendMermaidLayers([], block as Block, theme, viewport);
161
169
  return {
162
170
  layers: injectPersistentLayers(
163
- [],
171
+ layers,
164
172
  block,
165
173
  theme,
166
174
  effectivePersistentLayers,
167
175
  runtime.expandedPersistentLayers,
168
176
  ),
169
- source: 'empty',
177
+ source: layers.length > 0 ? 'rich-content' : 'empty',
170
178
  };
171
179
  }
172
180
 
@@ -181,9 +189,10 @@ export function materializeBlockLayersWithRuntime(
181
189
  runtime.applyRenderStyle !== false
182
190
  ? applyRenderStyleToLayers(execution.layers, block as Block, theme)
183
191
  : execution.layers;
192
+ const layers = appendMermaidLayers(styledLayers, block as Block, theme, viewport);
184
193
  return {
185
194
  layers: injectPersistentLayers(
186
- styledLayers,
195
+ layers,
187
196
  block,
188
197
  theme,
189
198
  effectivePersistentLayers,
@@ -195,22 +204,29 @@ export function materializeBlockLayersWithRuntime(
195
204
 
196
205
  const diagnostic = toLayerMaterializationDiagnostic(block.template, execution);
197
206
  if ((options.failureMode ?? 'fallback') === 'empty') {
207
+ const layers = appendMermaidLayers([], block as Block, theme, viewport);
198
208
  return {
199
209
  layers: injectPersistentLayers(
200
- [],
210
+ layers,
201
211
  block,
202
212
  theme,
203
213
  effectivePersistentLayers,
204
214
  runtime.expandedPersistentLayers,
205
215
  ),
206
- source: 'empty',
216
+ source: layers.length > 0 ? 'rich-content' : 'empty',
207
217
  diagnostic,
208
218
  };
209
219
  }
210
220
 
221
+ const fallbackLayers = appendMermaidLayers(
222
+ fallbackBlockLayers(block, context, diagnostic.message),
223
+ block as Block,
224
+ theme,
225
+ viewport,
226
+ );
211
227
  return {
212
228
  layers: injectPersistentLayers(
213
- fallbackBlockLayers(block, context, diagnostic.message),
229
+ fallbackLayers,
214
230
  block,
215
231
  theme,
216
232
  effectivePersistentLayers,
@@ -221,6 +237,151 @@ export function materializeBlockLayersWithRuntime(
221
237
  };
222
238
  }
223
239
 
240
+ interface LayerRect {
241
+ x: number;
242
+ y: number;
243
+ width: number;
244
+ height: number;
245
+ }
246
+
247
+ /** Mermaid fences are explicit author intent and never inferred from other code. */
248
+ function mermaidSources(block: Block): string[] {
249
+ return (block.contents ?? [])
250
+ .filter(
251
+ (node): node is MarkdownCodeBlock =>
252
+ node.type === 'code' && node.lang?.trim().toLowerCase() === 'mermaid',
253
+ )
254
+ .map((node) => node.value)
255
+ .filter((source) => source.trim().length > 0);
256
+ }
257
+
258
+ function resolvePositionValue(value: number | string | undefined, dimension: number): number {
259
+ if (value === undefined) return 0;
260
+ if (typeof value === 'number') return value;
261
+ const percent = value.match(/^\s*(-?\d+(?:\.\d+)?)%\s*$/);
262
+ if (percent) return (Number(percent[1]) / 100) * dimension;
263
+ const numeric = Number.parseFloat(value);
264
+ return Number.isFinite(numeric) ? numeric : 0;
265
+ }
266
+
267
+ function positionRect(
268
+ position: Position,
269
+ viewport: ViewportConfig,
270
+ fallbackWidth: number,
271
+ fallbackHeight: number,
272
+ ): LayerRect {
273
+ const width = position.width
274
+ ? resolvePositionValue(position.width, viewport.width)
275
+ : fallbackWidth;
276
+ const height = position.height
277
+ ? resolvePositionValue(position.height, viewport.height)
278
+ : fallbackHeight;
279
+ let x = resolvePositionValue(position.x, viewport.width);
280
+ let y = resolvePositionValue(position.y, viewport.height);
281
+ const anchor = position.anchor ?? 'top-left';
282
+ if (anchor === 'center') {
283
+ x -= width / 2;
284
+ y -= height / 2;
285
+ } else {
286
+ if (anchor.endsWith('right')) x -= width;
287
+ if (anchor.startsWith('bottom')) y -= height;
288
+ }
289
+ return { x, y, width, height };
290
+ }
291
+
292
+ function overlapArea(a: LayerRect, b: LayerRect): number {
293
+ const width = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
294
+ const height = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
295
+ return width * height;
296
+ }
297
+
298
+ /**
299
+ * Pick the least-obstructed media region when a template already owns the
300
+ * slide. Background shapes/paths are deliberately ignored; text and existing
301
+ * rich media are what the Mermaid inset must avoid.
302
+ */
303
+ function mermaidRegion(layers: readonly Layer[], viewport: ViewportConfig): LayerRect {
304
+ const occupied = layers
305
+ .filter((layer) => layer.type !== 'shape' && layer.type !== 'path' && layer.type !== 'mermaid')
306
+ .map((layer) =>
307
+ positionRect(
308
+ layer.position,
309
+ viewport,
310
+ layer.type === 'text' ? viewport.width * 0.45 : viewport.width * 0.5,
311
+ layer.type === 'text' ? viewport.height * 0.2 : viewport.height * 0.55,
312
+ ),
313
+ );
314
+ if (occupied.length === 0) {
315
+ return {
316
+ x: viewport.width * 0.06,
317
+ y: viewport.height * 0.08,
318
+ width: viewport.width * 0.88,
319
+ height: viewport.height * 0.84,
320
+ };
321
+ }
322
+
323
+ const candidates: LayerRect[] = [
324
+ {
325
+ x: viewport.width * 0.52,
326
+ y: viewport.height * 0.12,
327
+ width: viewport.width * 0.44,
328
+ height: viewport.height * 0.76,
329
+ },
330
+ {
331
+ x: viewport.width * 0.04,
332
+ y: viewport.height * 0.12,
333
+ width: viewport.width * 0.44,
334
+ height: viewport.height * 0.76,
335
+ },
336
+ {
337
+ x: viewport.width * 0.15,
338
+ y: viewport.height * 0.53,
339
+ width: viewport.width * 0.7,
340
+ height: viewport.height * 0.41,
341
+ },
342
+ ];
343
+ return candidates.reduce((best, candidate) => {
344
+ const score = occupied.reduce((sum, rect) => sum + overlapArea(candidate, rect), 0);
345
+ const bestScore = occupied.reduce((sum, rect) => sum + overlapArea(best, rect), 0);
346
+ return score < bestScore ? candidate : best;
347
+ });
348
+ }
349
+
350
+ /** Add every Mermaid fence as a responsive rich-media layer. */
351
+ function appendMermaidLayers(
352
+ layers: Layer[],
353
+ block: Block,
354
+ theme: Theme,
355
+ viewport: ViewportConfig,
356
+ ): Layer[] {
357
+ const sources = mermaidSources(block);
358
+ if (sources.length === 0 || layers.some((layer) => layer.type === 'mermaid')) return layers;
359
+
360
+ const region = mermaidRegion(layers, viewport);
361
+ const columns = sources.length === 1 ? 1 : 2;
362
+ const rows = Math.ceil(sources.length / columns);
363
+ const gap = Math.min(viewport.width, viewport.height) * 0.018;
364
+ const width = (region.width - gap * (columns - 1)) / columns;
365
+ const height = (region.height - gap * (rows - 1)) / rows;
366
+ const mermaidLayers: MermaidLayer[] = sources.map((source, index) => ({
367
+ id: `${block.id}-mermaid-${index + 1}`,
368
+ type: 'mermaid',
369
+ position: {
370
+ x: region.x + (index % columns) * (width + gap),
371
+ y: region.y + Math.floor(index / columns) * (height + gap),
372
+ width,
373
+ height,
374
+ },
375
+ content: {
376
+ source,
377
+ background: theme.colors.backgroundLight,
378
+ foreground: theme.colors.text,
379
+ padding: Math.max(12, Math.round(Math.min(width, height) * 0.025)),
380
+ },
381
+ }));
382
+ return [...layers, ...mermaidLayers];
383
+ }
384
+
224
385
  function executeTemplateMaterialization(
225
386
  templateBlock: TemplateBlock,
226
387
  context: TemplateContext,