@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.
- 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-6PJRB2CF.js → chunk-JHZWYRSW.js} +570 -2
- package/dist/chunk-JHZWYRSW.js.map +1 -0
- package/dist/{chunk-GF3BVNLU.js → chunk-JI77MGAK.js} +195 -27
- package/dist/chunk-JI77MGAK.js.map +1 -0
- package/dist/{chunk-QVANWDPH.js → chunk-OTKVFVL6.js} +1403 -41
- package/dist/chunk-OTKVFVL6.js.map +1 -0
- package/dist/{chunk-LYNJEOAC.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-QTSJOY54.js → chunk-VYFRI7R6.js} +2 -2
- package/dist/{chunk-T3TI7GSZ.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__/customThemesFrontmatter.test.ts +1 -0
- package/src/__tests__/markdown.test.ts +13 -4
- package/src/__tests__/markdownSanitize.test.ts +7 -5
- 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/markdownToDoc.ts +3 -1
- 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/doc/templates/__tests__/listBlock.test.ts +64 -0
- package/src/doc/templates/listBlock.ts +88 -30
- 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 +31 -1
- 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-6PJRB2CF.js.map +0 -1
- package/dist/chunk-FVJUUTEM.js +0 -74
- package/dist/chunk-FVJUUTEM.js.map +0 -1
- package/dist/chunk-GF3BVNLU.js.map +0 -1
- package/dist/chunk-LYNJEOAC.js.map +0 -1
- package/dist/chunk-PXLVXTLW.js.map +0 -1
- package/dist/chunk-QVANWDPH.js.map +0 -1
- /package/dist/{chunk-QTSJOY54.js.map → chunk-VYFRI7R6.js.map} +0 -0
- /package/dist/{chunk-T3TI7GSZ.js.map → chunk-XNLB57UD.js.map} +0 -0
|
@@ -115,6 +115,7 @@ squisq-custom-themes: ${encoded}
|
|
|
115
115
|
A small editor for big ideas.
|
|
116
116
|
`;
|
|
117
117
|
const doc = markdownToDoc(parseMarkdown(sourceMd));
|
|
118
|
+
expect(doc.themeId).toBe('my-brand');
|
|
118
119
|
expect(doc.customThemes).toHaveLength(1);
|
|
119
120
|
expect(doc.customThemes![0]).toEqual(brand);
|
|
120
121
|
|
|
@@ -155,14 +155,14 @@ describe('parseMarkdown / stringifyMarkdown', () => {
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it('parses mentions from @[Name](scheme:id)', () => {
|
|
158
|
-
const doc = parse('Hey @[Leo](
|
|
158
|
+
const doc = parse('Hey @[Leo](person:leo), take a look.');
|
|
159
159
|
const p = doc.children[0] as MarkdownParagraph;
|
|
160
160
|
const text0 = p.children[0] as MarkdownText;
|
|
161
161
|
expect(text0).toEqual({ type: 'text', value: 'Hey ' });
|
|
162
162
|
const mention = p.children[1] as MarkdownMention;
|
|
163
163
|
expect(mention).toEqual({
|
|
164
164
|
type: 'mention',
|
|
165
|
-
targetKind: '
|
|
165
|
+
targetKind: 'person',
|
|
166
166
|
targetId: 'leo',
|
|
167
167
|
displayName: 'Leo',
|
|
168
168
|
});
|
|
@@ -176,7 +176,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
|
|
|
176
176
|
// re-parsing `\:` produces the unescaped url, so the round-trip
|
|
177
177
|
// preserves the mention semantically even if the raw text changes
|
|
178
178
|
// on the first pass.
|
|
179
|
-
const input = 'Hey @[Leo](
|
|
179
|
+
const input = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.\n';
|
|
180
180
|
const once = stringifyMarkdown(parseMarkdown(input));
|
|
181
181
|
const twice = stringifyMarkdown(parseMarkdown(once));
|
|
182
182
|
expect(twice).toBe(once);
|
|
@@ -184,7 +184,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
|
|
|
184
184
|
const doc = parseMarkdown(once) as MarkdownDocument;
|
|
185
185
|
const p = doc.children[0] as MarkdownParagraph;
|
|
186
186
|
const m1 = p.children.find((c) => c.type === 'mention') as MarkdownMention;
|
|
187
|
-
expect(m1.targetKind).toBe('
|
|
187
|
+
expect(m1.targetKind).toBe('person');
|
|
188
188
|
expect(m1.targetId).toBe('leo');
|
|
189
189
|
expect(m1.displayName).toBe('Leo');
|
|
190
190
|
});
|
|
@@ -944,6 +944,15 @@ describe('multi-line frontmatter round-trips', () => {
|
|
|
944
944
|
path: 'C:\\temp\\"quoted"',
|
|
945
945
|
});
|
|
946
946
|
});
|
|
947
|
+
|
|
948
|
+
it('writes boolean values as unquoted YAML booleans', () => {
|
|
949
|
+
const out = setFrontmatterValues('# Body\n', { enabled: true, disabled: false });
|
|
950
|
+
expect(out).toContain('enabled: true');
|
|
951
|
+
expect(out).toContain('disabled: false');
|
|
952
|
+
expect(out).not.toContain('enabled: "true"');
|
|
953
|
+
expect(out).not.toContain('disabled: "false"');
|
|
954
|
+
expect(parseMarkdown(out).frontmatter).toEqual({ enabled: true, disabled: false });
|
|
955
|
+
});
|
|
947
956
|
});
|
|
948
957
|
|
|
949
958
|
describe('frontmatter in parseMarkdown', () => {
|
|
@@ -18,17 +18,19 @@ describe('markdown HTML sanitization', () => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('extraLinkSchemes allows host-app schemes but never executable ones', () => {
|
|
21
|
-
expect(sanitizeUrl('
|
|
22
|
-
expect(
|
|
23
|
-
'
|
|
24
|
-
);
|
|
21
|
+
expect(sanitizeUrl('workspace-nav:src%2Fa.ts', 'link')).toBeNull();
|
|
22
|
+
expect(
|
|
23
|
+
sanitizeUrl('workspace-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['workspace-nav'] }),
|
|
24
|
+
).toBe('workspace-nav:src%2Fa.ts');
|
|
25
25
|
expect(
|
|
26
26
|
sanitizeUrl('javascript:alert(1)', 'link', { extraLinkSchemes: ['javascript'] }),
|
|
27
27
|
).toBeNull();
|
|
28
28
|
expect(sanitizeUrl('data:text/html,x', 'link', { extraLinkSchemes: ['data'] })).toBeNull();
|
|
29
29
|
// media URLs ignore the option entirely
|
|
30
30
|
expect(
|
|
31
|
-
sanitizeUrl('
|
|
31
|
+
sanitizeUrl('workspace-nav:src%2Fa.ts', 'media', {
|
|
32
|
+
extraLinkSchemes: ['workspace-nav'],
|
|
33
|
+
}),
|
|
32
34
|
).toBeNull();
|
|
33
35
|
});
|
|
34
36
|
|
|
@@ -39,6 +39,38 @@ describe('materializeBlockLayers', () => {
|
|
|
39
39
|
expect(materialized.layers[0]).not.toBe(authoredLayer);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('materializes Mermaid fences for empty and templated blocks', () => {
|
|
43
|
+
const contents = [
|
|
44
|
+
{
|
|
45
|
+
type: 'code' as const,
|
|
46
|
+
lang: 'mermaid',
|
|
47
|
+
value: 'flowchart LR\n start --> next',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
const empty: Block = {
|
|
51
|
+
id: 'rich-empty',
|
|
52
|
+
startTime: 0,
|
|
53
|
+
duration: 5,
|
|
54
|
+
audioSegment: 0,
|
|
55
|
+
contents,
|
|
56
|
+
};
|
|
57
|
+
const richOnly = materializeBlockLayers(empty, { persistentLayers: false });
|
|
58
|
+
expect(richOnly.source).toBe('rich-content');
|
|
59
|
+
expect(richOnly.layers).toHaveLength(1);
|
|
60
|
+
expect(richOnly.layers[0]).toMatchObject({
|
|
61
|
+
type: 'mermaid',
|
|
62
|
+
content: { source: 'flowchart LR\n start --> next' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const templated = materializeBlockLayers(
|
|
66
|
+
{ ...titleBlock, contents } as unknown as TemplateBlock,
|
|
67
|
+
{ persistentLayers: false },
|
|
68
|
+
);
|
|
69
|
+
expect(templated.source).toBe('template');
|
|
70
|
+
expect(templated.layers.some((layer) => layer.type === 'text')).toBe(true);
|
|
71
|
+
expect(templated.layers.some((layer) => layer.type === 'mermaid')).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
42
74
|
it('resolves document-scoped custom templates in the on-demand API', () => {
|
|
43
75
|
const customTemplate: CustomTemplateDefinition = {
|
|
44
76
|
name: 'hero',
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import type { Block, Doc } from '../schemas/Doc.js';
|
|
3
|
+
import type { Theme } from '../schemas/Theme.js';
|
|
4
|
+
import { PAGE_SECTION_KINDS } from '../schemas/PageStyle.js';
|
|
5
|
+
import { THEMES, DEFAULT_THEME } from '../schemas/themeLibrary.js';
|
|
6
|
+
import { createTheme } from '../schemas/Theme.js';
|
|
7
|
+
import { templateRegistry } from '../doc/templates/registry.js';
|
|
8
|
+
import { TEMPLATE_ALIASES } from '../doc/templates/templateNames.js';
|
|
9
|
+
import { sectionExtractors } from '../doc/page/sectionExtractors.js';
|
|
10
|
+
import {
|
|
11
|
+
materializePageSection,
|
|
12
|
+
materializePageSections,
|
|
13
|
+
resolvePageStyle,
|
|
14
|
+
} from '../doc/page/materializePageSection.js';
|
|
15
|
+
import { parseMarkdown } from '../markdown/parse.js';
|
|
16
|
+
import { markdownToDoc } from '../doc/markdownToDoc.js';
|
|
17
|
+
import { buildPageCss, buildPageCssVars, PAGE_BASE_CSS } from '../doc/pageCss.js';
|
|
18
|
+
import { resolveTransformStyle } from '../transform/registry.js';
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function templateBlock(template: string, extra: Record<string, unknown> = {}): Block {
|
|
25
|
+
return {
|
|
26
|
+
id: `test-${template}`,
|
|
27
|
+
startTime: 0,
|
|
28
|
+
duration: 1,
|
|
29
|
+
audioSegment: 0,
|
|
30
|
+
template,
|
|
31
|
+
...extra,
|
|
32
|
+
} as unknown as Block;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function docOf(blocks: Block[], extra: Partial<Doc> = {}): Doc {
|
|
36
|
+
return { blocks, duration: 10, ...extra } as Doc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('section extractor coverage', () => {
|
|
40
|
+
it('covers every registered template id', () => {
|
|
41
|
+
for (const id of Object.keys(templateRegistry)) {
|
|
42
|
+
expect(sectionExtractors[id], `template "${id}" has no section extractor`).toBeDefined();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('maps every registry template (and alias) to a valid section kind', () => {
|
|
47
|
+
const names = [...Object.keys(templateRegistry), ...Object.keys(TEMPLATE_ALIASES)];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const { section, source } = materializePageSection(templateBlock(name));
|
|
50
|
+
expect(source, name).toBe('template');
|
|
51
|
+
expect(PAGE_SECTION_KINDS, `${name} → ${section.kind}`).toContain(section.kind);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('extracts typed slots for representative templates', () => {
|
|
56
|
+
const stat = materializePageSection(
|
|
57
|
+
templateBlock('statHighlight', { stat: '42%', description: 'more speed', detail: 'ctx' }),
|
|
58
|
+
).section;
|
|
59
|
+
expect(stat.kind).toBe('stat-band');
|
|
60
|
+
expect(stat.slots.items?.[0]).toMatchObject({ value: '42%', title: 'more speed' });
|
|
61
|
+
|
|
62
|
+
const quote = materializePageSection(
|
|
63
|
+
templateBlock('quote', { quote: 'Words matter.', attribution: 'Someone' }),
|
|
64
|
+
).section;
|
|
65
|
+
expect(quote.kind).toBe('quote-band');
|
|
66
|
+
expect(quote.slots.body?.text).toBe('Words matter.');
|
|
67
|
+
expect(quote.slots.attribution).toBe('Someone');
|
|
68
|
+
|
|
69
|
+
const feature = materializePageSection(
|
|
70
|
+
templateBlock('rightFeature', { imageSrc: 'a.png', title: 'T', body: 'B' }),
|
|
71
|
+
).section;
|
|
72
|
+
expect(feature.kind).toBe('feature-split');
|
|
73
|
+
expect(feature.variant).toBe('media-right');
|
|
74
|
+
expect(feature.slots.media).toMatchObject({ type: 'image', src: 'a.png' });
|
|
75
|
+
|
|
76
|
+
const table = materializePageSection(
|
|
77
|
+
templateBlock('dataTable', { headers: ['A'], rows: [['1']] }),
|
|
78
|
+
).section;
|
|
79
|
+
expect(table.kind).toBe('table-section');
|
|
80
|
+
expect(table.slots.table).toMatchObject({ headers: ['A'], rows: [['1']] });
|
|
81
|
+
|
|
82
|
+
const diagram = materializePageSection(templateBlock('diagram', { nodes: [] })).section;
|
|
83
|
+
expect(diagram.kind).toBe('canvas-embed');
|
|
84
|
+
expect(diagram.slots.media).toMatchObject({ type: 'canvas', spatial: 'diagram' });
|
|
85
|
+
|
|
86
|
+
const video = materializePageSection(
|
|
87
|
+
templateBlock('videoWithCaption', {
|
|
88
|
+
videoSrc: 'v.mp4',
|
|
89
|
+
videoAlt: 'clip',
|
|
90
|
+
clipStart: 0,
|
|
91
|
+
clipEnd: 3,
|
|
92
|
+
}),
|
|
93
|
+
).section;
|
|
94
|
+
expect(video.kind).toBe('media-figure');
|
|
95
|
+
expect(video.slots.media).toMatchObject({ type: 'video', src: 'v.mp4' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('pullQuote with a background image becomes a media-backed quote band', () => {
|
|
99
|
+
const { section } = materializePageSection(
|
|
100
|
+
templateBlock('pullQuote', {
|
|
101
|
+
text: 'Big words',
|
|
102
|
+
backgroundImage: { src: 'bg.jpg', alt: 'bg' },
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(section.kind).toBe('quote-band');
|
|
106
|
+
expect(section.background).toBe('media');
|
|
107
|
+
expect(section.slots.media).toMatchObject({ type: 'image', src: 'bg.jpg' });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('non-template paths', () => {
|
|
112
|
+
it('unknown template falls back to a visible diagnostic callout without console.warn', () => {
|
|
113
|
+
const warnSpy = vi.spyOn(console, 'warn');
|
|
114
|
+
const { section, source, diagnostic } = materializePageSection(templateBlock('zzz-nope'));
|
|
115
|
+
expect(source).toBe('fallback');
|
|
116
|
+
expect(section.kind).toBe('callout');
|
|
117
|
+
expect(section.variant).toBe('diagnostic');
|
|
118
|
+
expect(section.slots.meta).toContain('Unknown template');
|
|
119
|
+
expect(diagnostic).toMatchObject({ code: 'unknown-template', template: 'zzz-nope' });
|
|
120
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('custom templates become custom canvas embeds at their authored aspect', () => {
|
|
124
|
+
const { section, source } = materializePageSection(templateBlock('my-custom'), {
|
|
125
|
+
customTemplates: [
|
|
126
|
+
{
|
|
127
|
+
name: 'my-custom',
|
|
128
|
+
label: 'My Custom',
|
|
129
|
+
viewport: { width: 1080, height: 1080 },
|
|
130
|
+
layers: [],
|
|
131
|
+
} as never,
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
expect(source).toBe('custom-template');
|
|
135
|
+
expect(section.kind).toBe('canvas-embed');
|
|
136
|
+
expect(section.slots.media).toMatchObject({
|
|
137
|
+
type: 'canvas',
|
|
138
|
+
spatial: 'custom',
|
|
139
|
+
aspect: { width: 1080, height: 1080 },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('authored layer blocks become authored canvas embeds', () => {
|
|
144
|
+
const block = {
|
|
145
|
+
id: 'raw',
|
|
146
|
+
startTime: 0,
|
|
147
|
+
duration: 1,
|
|
148
|
+
audioSegment: 0,
|
|
149
|
+
layers: [{ id: 'l1', type: 'text', content: { text: 'hi' } }],
|
|
150
|
+
} as unknown as Block;
|
|
151
|
+
const { section, source } = materializePageSection(block);
|
|
152
|
+
expect(source).toBe('authored');
|
|
153
|
+
expect(section.kind).toBe('canvas-embed');
|
|
154
|
+
expect(section.slots.media).toMatchObject({ type: 'canvas', spatial: 'authored' });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('plain heading blocks become prose sections with markdown body', () => {
|
|
158
|
+
const doc = markdownToDoc(parseMarkdown('# Hello\n\nSome *rich* text.'));
|
|
159
|
+
const sections = materializePageSections(doc);
|
|
160
|
+
const prose = sections.find((entry) => entry.section.kind === 'prose');
|
|
161
|
+
expect(prose).toBeDefined();
|
|
162
|
+
expect(prose!.section.slots.title).toBe('Hello');
|
|
163
|
+
expect(prose!.section.slots.headingLevel).toBe(1);
|
|
164
|
+
expect(prose!.section.slots.body?.markdown?.length).toBeGreaterThan(0);
|
|
165
|
+
expect(prose!.section.slots.body?.text).toContain('Some');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('auto-templated blocks map to their typed section', () => {
|
|
169
|
+
const doc = markdownToDoc(parseMarkdown('# Journey\n\n> The only way out is through.'));
|
|
170
|
+
const sections = materializePageSections(doc);
|
|
171
|
+
const auto = sections.find((entry) => entry.section.template === 'quote');
|
|
172
|
+
expect(auto).toBeDefined();
|
|
173
|
+
expect(auto!.section.kind).toBe('quote-band');
|
|
174
|
+
expect(auto!.section.slots.body?.text).toContain('only way out');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('preserves Mermaid and unconsumed media alongside typed page templates', () => {
|
|
178
|
+
const doc = markdownToDoc(
|
|
179
|
+
parseMarkdown(
|
|
180
|
+
'# Rich {[factCard]}\n\nNarrative copy.\n\n```mermaid\nflowchart LR\n a --> b\n```',
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
const [entry] = materializePageSections(doc);
|
|
184
|
+
expect(entry.section.kind).toBe('callout');
|
|
185
|
+
expect(entry.section.slots.body?.text).not.toContain('flowchart LR');
|
|
186
|
+
expect(entry.section.slots.richContent?.markdown).toEqual([
|
|
187
|
+
expect.objectContaining({ type: 'code', lang: 'mermaid' }),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const imageBlock = templateBlock('quote', {
|
|
191
|
+
quote: 'Keep the picture too.',
|
|
192
|
+
contents: parseMarkdown('').children,
|
|
193
|
+
});
|
|
194
|
+
const imageSection = materializePageSection(imageBlock).section;
|
|
195
|
+
expect(imageSection.slots.richContent?.markdown).toEqual([
|
|
196
|
+
expect.objectContaining({ type: 'paragraph' }),
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('doc-level art direction', () => {
|
|
202
|
+
it('synthesizes a cover hero when requested', () => {
|
|
203
|
+
const doc = docOf([templateBlock('quote', { quote: 'q' })], {
|
|
204
|
+
startBlock: { title: 'Cover Title', subtitle: 'Sub', heroSrc: 'hero.jpg' },
|
|
205
|
+
});
|
|
206
|
+
const sections = materializePageSections(doc, { cover: doc.startBlock });
|
|
207
|
+
expect(sections[0].section.kind).toBe('hero');
|
|
208
|
+
expect(sections[0].section.source).toBe('cover');
|
|
209
|
+
expect(sections[0].section.slots.title).toBe('Cover Title');
|
|
210
|
+
expect(sections[0].section.background).toBe('media');
|
|
211
|
+
expect(sections[0].section.emphasis).toBe('lead');
|
|
212
|
+
|
|
213
|
+
const withoutCover = materializePageSections(doc);
|
|
214
|
+
expect(withoutCover[0].section.kind).not.toBe('hero');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('dedupes the cover against an authored title block (authored hero wins)', () => {
|
|
218
|
+
const doc = docOf([templateBlock('title', { title: 'Same Title', subtitle: 'Authored sub' })], {
|
|
219
|
+
startBlock: { title: 'Same Title', heroSrc: 'hero.jpg', heroAlt: 'hero' },
|
|
220
|
+
});
|
|
221
|
+
const sections = materializePageSections(doc, { cover: doc.startBlock });
|
|
222
|
+
const heroes = sections.filter((entry) => entry.section.kind === 'hero');
|
|
223
|
+
expect(heroes).toHaveLength(1);
|
|
224
|
+
expect(heroes[0].section.source).toBe('template');
|
|
225
|
+
// The cover's hero image grafts onto the authored hero when it has none.
|
|
226
|
+
expect(heroes[0].section.slots.media).toMatchObject({ type: 'image', src: 'hero.jpg' });
|
|
227
|
+
expect(heroes[0].section.background).toBe('media');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('dedupes the cover against a mirrored leading prose H1', () => {
|
|
231
|
+
const doc = markdownToDoc(parseMarkdown('# My Doc\n\nIntro paragraph.\n\nSecond paragraph.'));
|
|
232
|
+
expect(doc.startBlock?.title).toBe('My Doc');
|
|
233
|
+
const sections = materializePageSections(doc, { cover: doc.startBlock });
|
|
234
|
+
expect(sections[0].section.kind).toBe('hero');
|
|
235
|
+
expect(sections[0].section.slots.subtitle).toBe('Intro paragraph.');
|
|
236
|
+
// The H1's own section keeps only the non-mirrored body.
|
|
237
|
+
const prose = sections.find((entry) => entry.section.kind === 'prose')!;
|
|
238
|
+
expect(prose.section.slots.title).toBeUndefined();
|
|
239
|
+
expect(prose.section.slots.body?.text).toBe('Second paragraph.');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('applies the alternate background rhythm across non-prose sections', () => {
|
|
243
|
+
const theme = THEMES.standard; // backgroundRhythm: alternate
|
|
244
|
+
const doc = docOf([
|
|
245
|
+
templateBlock('quote', { quote: 'a' }),
|
|
246
|
+
templateBlock('quote', { quote: 'b' }),
|
|
247
|
+
templateBlock('quote', { quote: 'c' }),
|
|
248
|
+
]);
|
|
249
|
+
const sections = materializePageSections(doc, { theme });
|
|
250
|
+
expect(sections.map((entry) => entry.section.background)).toEqual([
|
|
251
|
+
'base',
|
|
252
|
+
'alternate',
|
|
253
|
+
'base',
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('rotates accent schemes with cycle strategy and lets block colorScheme win', () => {
|
|
258
|
+
const theme = THEMES.standard; // accentRotation: cycle
|
|
259
|
+
const schemeNames = Object.keys(theme.colorSchemes);
|
|
260
|
+
const doc = docOf([
|
|
261
|
+
templateBlock('sectionHeader', { title: 'one' }),
|
|
262
|
+
templateBlock('sectionHeader', { title: 'two' }),
|
|
263
|
+
templateBlock('sectionHeader', { title: 'pinned', colorScheme: 'teal' }),
|
|
264
|
+
]);
|
|
265
|
+
const sections = materializePageSections(doc, { theme });
|
|
266
|
+
expect(sections[0].section.accent).toMatchObject({
|
|
267
|
+
schemeName: schemeNames[0],
|
|
268
|
+
role: 'rotation',
|
|
269
|
+
});
|
|
270
|
+
expect(sections[1].section.accent).toMatchObject({
|
|
271
|
+
schemeName: schemeNames[1],
|
|
272
|
+
role: 'rotation',
|
|
273
|
+
});
|
|
274
|
+
expect(sections[2].section.accent).toMatchObject({ schemeName: 'teal', role: 'block' });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('primary-only strategy skips rotation', () => {
|
|
278
|
+
const theme = THEMES.documentary;
|
|
279
|
+
const doc = docOf([templateBlock('sectionHeader', { title: 'one' })]);
|
|
280
|
+
const sections = materializePageSections(doc, { theme });
|
|
281
|
+
expect(sections[0].section.accent.role).toBe('primary');
|
|
282
|
+
expect(sections[0].section.accent.schemeName).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('applies theme section overrides (documentary quote-band → editorial variant)', () => {
|
|
286
|
+
const sections = materializePageSections(docOf([templateBlock('quote', { quote: 'q' })]), {
|
|
287
|
+
theme: THEMES.documentary,
|
|
288
|
+
});
|
|
289
|
+
expect(sections[0].section.variant).toBe('editorial');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('applies per-template hints (tech-dark diagram → terminal frame)', () => {
|
|
293
|
+
const sections = materializePageSections(docOf([templateBlock('diagram', {})]), {
|
|
294
|
+
theme: THEMES['tech-dark'],
|
|
295
|
+
});
|
|
296
|
+
expect(sections[0].section.hints).toMatchObject({ frame: 'terminal' });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('numbers hero/banner eyebrows under numbered and mono-tag treatments', () => {
|
|
300
|
+
const doc = docOf([
|
|
301
|
+
templateBlock('sectionHeader', { title: 'one' }),
|
|
302
|
+
templateBlock('quote', { quote: 'interlude' }),
|
|
303
|
+
templateBlock('sectionHeader', { title: 'two' }),
|
|
304
|
+
]);
|
|
305
|
+
const sections = materializePageSections(doc, { theme: THEMES.documentary });
|
|
306
|
+
expect(sections[0].section.slots.eyebrow).toBe('01');
|
|
307
|
+
expect(sections[1].section.slots.eyebrow).toBeUndefined();
|
|
308
|
+
expect(sections[2].section.slots.eyebrow).toBe('02');
|
|
309
|
+
|
|
310
|
+
const clean = materializePageSections(doc, { theme: THEMES.standard });
|
|
311
|
+
expect(clean[0].section.slots.eyebrow).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('container template children are consumed; other children recurse with depth', () => {
|
|
315
|
+
const container = templateBlock('diagram', {
|
|
316
|
+
children: [
|
|
317
|
+
{
|
|
318
|
+
id: 'child-consumed',
|
|
319
|
+
startTime: 0,
|
|
320
|
+
duration: 1,
|
|
321
|
+
audioSegment: 0,
|
|
322
|
+
template: 'sectionHeader',
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
const parent = {
|
|
327
|
+
...templateBlock('sectionHeader', { title: 'parent' }),
|
|
328
|
+
children: [
|
|
329
|
+
{
|
|
330
|
+
id: 'child-kept',
|
|
331
|
+
startTime: 0,
|
|
332
|
+
duration: 1,
|
|
333
|
+
audioSegment: 0,
|
|
334
|
+
sourceHeading: {
|
|
335
|
+
type: 'heading',
|
|
336
|
+
depth: 2,
|
|
337
|
+
children: [{ type: 'text', value: 'Child' }],
|
|
338
|
+
},
|
|
339
|
+
} as unknown as Block,
|
|
340
|
+
],
|
|
341
|
+
} as Block;
|
|
342
|
+
const sections = materializePageSections(docOf([container, parent]));
|
|
343
|
+
const ids = sections.map((entry) => entry.section.blockId);
|
|
344
|
+
expect(ids).not.toContain('child-consumed');
|
|
345
|
+
expect(ids).toContain('child-kept');
|
|
346
|
+
const child = sections.find((entry) => entry.section.blockId === 'child-kept')!;
|
|
347
|
+
expect(child.section.depth).toBe(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('transform page hints overlay spacing and front-load emphasis', () => {
|
|
351
|
+
const hints = resolveTransformStyle('documentary').page;
|
|
352
|
+
expect(hints).toMatchObject({ spacing: 'generous', emphasisCurve: 'front-loaded' });
|
|
353
|
+
|
|
354
|
+
const style = resolvePageStyle(THEMES.standard, { spacing: 'compact' });
|
|
355
|
+
expect(style.tokens.sectionSpacing).toBe('compact');
|
|
356
|
+
expect(THEMES.standard.pageStyle!.tokens.sectionSpacing).toBe('comfortable');
|
|
357
|
+
|
|
358
|
+
const doc = docOf([
|
|
359
|
+
templateBlock('quote', { quote: 'a' }),
|
|
360
|
+
templateBlock('quote', { quote: 'b' }),
|
|
361
|
+
templateBlock('quote', { quote: 'c' }),
|
|
362
|
+
]);
|
|
363
|
+
const sections = materializePageSections(doc, {
|
|
364
|
+
transformPage: { emphasisCurve: 'front-loaded' },
|
|
365
|
+
});
|
|
366
|
+
expect(sections[0].section.emphasis).toBe('strong');
|
|
367
|
+
expect(sections[1].section.emphasis).toBe('strong');
|
|
368
|
+
expect(sections[2].section.emphasis).toBe('standard');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('indexes sections pre-order with totals', () => {
|
|
372
|
+
const doc = docOf([
|
|
373
|
+
templateBlock('quote', { quote: 'a' }),
|
|
374
|
+
templateBlock('quote', { quote: 'b' }),
|
|
375
|
+
]);
|
|
376
|
+
const sections = materializePageSections(doc);
|
|
377
|
+
expect(sections.map((entry) => entry.section.index)).toEqual([0, 1]);
|
|
378
|
+
expect(sections.every((entry) => entry.section.totalSections === 2)).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('page CSS', () => {
|
|
383
|
+
it('builds theme vars covering colors, fonts, and token dimensions', () => {
|
|
384
|
+
const vars = buildPageCssVars(THEMES.magazine);
|
|
385
|
+
expect(vars['--squisq-page-bg']).toBe(THEMES.magazine.colors.background);
|
|
386
|
+
expect(vars['--squisq-page-wide-max']).toBe('1240px');
|
|
387
|
+
expect(vars['--squisq-page-title-font']).toContain('DM Serif');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('buildPageCss embeds the vars and the structural sheet', () => {
|
|
391
|
+
const css = buildPageCss(DEFAULT_THEME);
|
|
392
|
+
expect(css).toContain('.squisq-page {');
|
|
393
|
+
expect(css).toContain('--squisq-page-bg:');
|
|
394
|
+
expect(css).toContain(PAGE_BASE_CSS.slice(0, 60).trim());
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('resolvePageStyle derives a style for themes without pageStyle', () => {
|
|
398
|
+
const theme = createTheme(DEFAULT_THEME, { id: 'legacy' });
|
|
399
|
+
delete (theme as unknown as Record<string, unknown>).pageStyle;
|
|
400
|
+
const style = resolvePageStyle(theme as Theme);
|
|
401
|
+
expect(style.family).toBe('clean');
|
|
402
|
+
expect(style.tokens.contentMaxWidth).toBeGreaterThan(0);
|
|
403
|
+
});
|
|
404
|
+
});
|