@bendyline/squisq 2.0.1 → 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.
- package/dist/{Doc-ylqWKDc_.d.ts → Doc-DynJleo-.d.ts} +159 -2
- package/dist/{ImageEditDoc-D828IXJ1.d.ts → ImageEditDoc-Ca4Tr9uN.d.ts} +1 -1
- package/dist/{chunk-PXLVXTLW.js → chunk-ILWDXLUK.js} +24 -6
- package/dist/chunk-ILWDXLUK.js.map +1 -0
- package/dist/{chunk-W4O2TX5I.js → chunk-JHZWYRSW.js} +569 -1
- package/dist/chunk-JHZWYRSW.js.map +1 -0
- package/dist/{chunk-X6DUONGT.js → chunk-JI77MGAK.js} +123 -10
- package/dist/chunk-JI77MGAK.js.map +1 -0
- package/dist/{chunk-TFJVXUHH.js → chunk-OTKVFVL6.js} +1403 -41
- package/dist/chunk-OTKVFVL6.js.map +1 -0
- package/dist/{chunk-MMPBOUK7.js → chunk-QUUPYOSP.js} +8 -2
- package/dist/chunk-QUUPYOSP.js.map +1 -0
- package/dist/chunk-VWCJ2WXC.js +215 -0
- package/dist/chunk-VWCJ2WXC.js.map +1 -0
- package/dist/{chunk-2PWOO4ZE.js → chunk-VYFRI7R6.js} +2 -2
- package/dist/{chunk-CY2R2VPR.js → chunk-XNLB57UD.js} +2 -2
- package/dist/doc/index.d.ts +144 -5
- package/dist/doc/index.js +24 -4
- package/dist/generate/index.d.ts +1 -1
- package/dist/imageEdit/index.d.ts +3 -3
- package/dist/index.d.ts +6 -5
- package/dist/index.js +66 -8
- package/dist/jsonForm/index.d.ts +1 -1
- package/dist/jsonForm/index.js +4 -4
- package/dist/materializePageSection-DDzubQXo.d.ts +223 -0
- package/dist/narration/index.d.ts +1 -1
- package/dist/narration/index.js +3 -3
- package/dist/schemas/index.d.ts +30 -5
- package/dist/schemas/index.js +41 -3
- package/dist/{themeLibrary-RjVgLpUi.d.ts → themeLibrary-B72m0W1y.d.ts} +1 -1
- package/dist/transform/index.d.ts +8 -9
- package/dist/transform/index.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/materializeBlockLayers.test.ts +32 -0
- package/src/__tests__/materializePageSections.test.ts +404 -0
- package/src/__tests__/pageStyle.test.ts +258 -0
- package/src/__tests__/templateAnnotationParse.test.ts +13 -0
- package/src/doc/index.ts +34 -0
- package/src/doc/materializeBlockLayers.ts +170 -9
- package/src/doc/page/PageSection.ts +174 -0
- package/src/doc/page/materializePageSection.ts +616 -0
- package/src/doc/page/resolvePageBlock.ts +108 -0
- package/src/doc/page/sectionExtractors.ts +466 -0
- package/src/doc/pageCss.ts +628 -0
- package/src/doc/templateInputs.ts +1 -0
- package/src/schemas/Doc.ts +22 -1
- package/src/schemas/PageStyle.ts +213 -0
- package/src/schemas/Theme.ts +7 -0
- package/src/schemas/index.ts +2 -0
- package/src/schemas/pageStyleDefaults.ts +204 -0
- package/src/schemas/themeCompile.ts +7 -0
- package/src/schemas/themeValidator.ts +183 -0
- package/src/schemas/themes/bold.json +29 -0
- package/src/schemas/themes/cinematic.json +32 -0
- package/src/schemas/themes/documentary.json +29 -0
- package/src/schemas/themes/gezellig.json +30 -0
- package/src/schemas/themes/magazine.json +37 -0
- package/src/schemas/themes/minimalist.json +29 -0
- package/src/schemas/themes/morning-light.json +32 -0
- package/src/schemas/themes/standard-dark.json +24 -0
- package/src/schemas/themes/standard.json +24 -0
- package/src/schemas/themes/tech-dark.json +42 -0
- package/src/schemas/themes/warm-earth.json +24 -0
- package/src/transform/registry.ts +19 -0
- package/src/transform/styles/dataDriven.ts +1 -0
- package/src/transform/styles/documentary.ts +1 -0
- package/src/transform/styles/magazine.ts +1 -0
- package/src/transform/styles/minimal.ts +1 -0
- package/src/transform/styles/narrative.ts +1 -0
- package/src/transform/types.ts +7 -0
- package/dist/chunk-FVJUUTEM.js +0 -74
- package/dist/chunk-FVJUUTEM.js.map +0 -1
- package/dist/chunk-MMPBOUK7.js.map +0 -1
- package/dist/chunk-PXLVXTLW.js.map +0 -1
- package/dist/chunk-TFJVXUHH.js.map +0 -1
- package/dist/chunk-W4O2TX5I.js.map +0 -1
- package/dist/chunk-X6DUONGT.js.map +0 -1
- /package/dist/{chunk-2PWOO4ZE.js.map → chunk-VYFRI7R6.js.map} +0 -0
- /package/dist/{chunk-CY2R2VPR.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 {
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|