@bendyline/squisq 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Doc-Dwn5aHFC.d.ts → Doc-BfBhiCNr.d.ts} +2 -2
- package/dist/__tests__/coverBlock.test.d.ts +2 -0
- package/dist/__tests__/coverBlock.test.d.ts.map +1 -0
- package/dist/__tests__/coverBlock.test.js +197 -0
- package/dist/__tests__/coverBlock.test.js.map +1 -0
- package/dist/__tests__/markdown.test.js +21 -0
- package/dist/__tests__/markdown.test.js.map +1 -1
- package/dist/__tests__/markdownToDoc.test.js +59 -0
- package/dist/__tests__/markdownToDoc.test.js.map +1 -1
- package/dist/{chunk-TVPKD76V.js → chunk-53TZ2UTL.js} +2 -3
- package/dist/chunk-53TZ2UTL.js.map +1 -0
- package/dist/{chunk-QWVRN6I4.js → chunk-7GF6RDED.js} +3 -2
- package/dist/chunk-7GF6RDED.js.map +1 -0
- package/dist/{chunk-4CRBS35L.js → chunk-CYQHG27J.js} +7 -2
- package/dist/chunk-CYQHG27J.js.map +1 -0
- package/dist/{chunk-T6WBC3ND.js → chunk-GAQLMORN.js} +105 -49
- package/dist/chunk-GAQLMORN.js.map +1 -0
- package/dist/{chunk-NJLL5XYC.js → chunk-LAZELBON.js} +2 -2
- package/dist/{chunk-KEFWM2VC.js → chunk-XM4WSOHB.js} +1 -1
- package/dist/chunk-XM4WSOHB.js.map +1 -0
- package/dist/doc/markdownToDoc.d.ts +7 -0
- package/dist/doc/markdownToDoc.d.ts.map +1 -1
- package/dist/doc/markdownToDoc.js +49 -1
- package/dist/doc/markdownToDoc.js.map +1 -1
- package/dist/doc/templates/coverBlock.d.ts +2 -2
- package/dist/doc/templates/coverBlock.d.ts.map +1 -1
- package/dist/doc/templates/coverBlock.js +58 -33
- package/dist/doc/templates/coverBlock.js.map +1 -1
- package/dist/markdown/utils.d.ts.map +1 -1
- package/dist/markdown/utils.js +5 -1
- package/dist/markdown/utils.js.map +1 -1
- package/dist/schemas/Doc.d.ts +2 -2
- package/dist/schemas/Doc.d.ts.map +1 -1
- package/dist/schemas/themeLibrary.d.ts.map +1 -1
- package/dist/schemas/themeLibrary.js +1 -2
- package/dist/schemas/themeLibrary.js.map +1 -1
- package/dist/storage/ContentContainer.d.ts.map +1 -1
- package/dist/storage/ContentContainer.js +10 -1
- package/dist/storage/ContentContainer.js.map +1 -1
- package/dist/story/index.d.ts +12 -5
- package/dist/story/index.js +3 -3
- package/dist/{themeLibrary-DLJtGh8-.d.ts → themeLibrary-IP7EdyRq.d.ts} +1 -1
- package/package.json +1 -1
- package/src/__tests__/coverBlock.test.ts +234 -0
- package/src/__tests__/markdown.test.ts +22 -0
- package/src/__tests__/markdownToDoc.test.ts +80 -0
- package/src/doc/markdownToDoc.ts +74 -3
- package/src/doc/templates/coverBlock.ts +77 -47
- package/src/markdown/utils.ts +5 -1
- package/src/schemas/Doc.ts +2 -2
- package/src/schemas/themeLibrary.ts +1 -2
- package/src/storage/ContentContainer.ts +9 -1
- package/dist/chunk-4CRBS35L.js.map +0 -1
- package/dist/chunk-KEFWM2VC.js.map +0 -1
- package/dist/chunk-QWVRN6I4.js.map +0 -1
- package/dist/chunk-T6WBC3ND.js.map +0 -1
- package/dist/chunk-TVPKD76V.js.map +0 -1
- /package/dist/{chunk-NJLL5XYC.js.map → chunk-LAZELBON.js.map} +0 -0
|
@@ -46,7 +46,16 @@ export class MemoryContentContainer {
|
|
|
46
46
|
return this.files.get(path)?.data ?? null;
|
|
47
47
|
}
|
|
48
48
|
async writeFile(path, data, mimeType) {
|
|
49
|
-
|
|
49
|
+
// Copy to a standalone ArrayBuffer. Uint8Array/Buffer may share a larger
|
|
50
|
+
// backing buffer (e.g., Node's buffer pool), so we must slice to the
|
|
51
|
+
// exact byte range to avoid storing stale pool data.
|
|
52
|
+
let buffer;
|
|
53
|
+
if (data instanceof ArrayBuffer) {
|
|
54
|
+
buffer = data;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
58
|
+
}
|
|
50
59
|
this.files.set(path, {
|
|
51
60
|
data: buffer,
|
|
52
61
|
mimeType: mimeType ?? guessMimeType(path),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContentContainer.js","sourceRoot":"","sources":["../../src/storage/ContentContainer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA6DH,+CAA+C;AAC/C,kDAAkD;AAClD,+CAA+C;AAE/C,MAAM,iBAAiB,GAAG,CAAC,UAAU,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC;AAEhE;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAuB;IACtD,kDAAkD;IAClD,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAE/D,KAAK,MAAM,IAAI,IAAI,iBAAiB,EAAE,CAAC;QACrC,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACzD,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,mCAAmC;IACnC,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;IAC5E,OAAO,OAAO,EAAE,IAAI,IAAI,IAAI,CAAC;AAC/B,CAAC;AAWD;;;;GAIG;AACH,MAAM,OAAO,sBAAsB;IAAnC;QACU,UAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ContentContainer.js","sourceRoot":"","sources":["../../src/storage/ContentContainer.ts"],"names":[],"mappings":"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"}
|
package/dist/story/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { H as PersistentLayer, L as Layer, J as PersistentLayerConfig, ac as TitleBlockInput, a2 as TemplateContext, W as SectionHeaderInput, _ as StatHighlightInput, U as QuoteBlockInput, F as FactCardInput, ag as TwoColumnInput, o as DateEventInput, v as ImageWithCaptionInput, M as MapBlockInput, Z as StartBlockConfig, t as FullBleedQuoteInput, x as ListBlockInput, Q as PhotoGridInput, q as DefinitionCardInput, k as ComparisonBarInput, T as PullQuoteInput, ak as VideoWithCaptionInput, aj as VideoPullQuoteInput, n as DataTableInput, A as AccentImage, a as AccentPosition, a7 as Theme, al as ViewportConfig, s as DocBlock, B as Block, a1 as TemplateBlock, a4 as TemplateRegistry, b as Animation, c as AnimationType, af as TransitionType, a9 as ThemeColorScheme, r as Doc } from '../Doc-
|
|
2
|
-
export { w as LayoutHints, V as RenderStyle, a8 as ThemeColorPalette, aa as ThemeStyle, ab as ThemeTypography, ah as VIEWPORT_PRESETS, am as ViewportOrientation, an as ViewportPreset, aq as createTemplateContext, av as getLayoutHints, ay as getTwoColumnPositions, az as getViewport, aA as getViewportOrientation, aC as isTemplateBlock, aE as scaledFontSize } from '../Doc-
|
|
1
|
+
import { H as PersistentLayer, L as Layer, J as PersistentLayerConfig, ac as TitleBlockInput, a2 as TemplateContext, W as SectionHeaderInput, _ as StatHighlightInput, U as QuoteBlockInput, F as FactCardInput, ag as TwoColumnInput, o as DateEventInput, v as ImageWithCaptionInput, M as MapBlockInput, Z as StartBlockConfig, t as FullBleedQuoteInput, x as ListBlockInput, Q as PhotoGridInput, q as DefinitionCardInput, k as ComparisonBarInput, T as PullQuoteInput, ak as VideoWithCaptionInput, aj as VideoPullQuoteInput, n as DataTableInput, A as AccentImage, a as AccentPosition, a7 as Theme, al as ViewportConfig, s as DocBlock, B as Block, a1 as TemplateBlock, a4 as TemplateRegistry, b as Animation, c as AnimationType, af as TransitionType, a9 as ThemeColorScheme, r as Doc } from '../Doc-BfBhiCNr.js';
|
|
2
|
+
export { w as LayoutHints, V as RenderStyle, a8 as ThemeColorPalette, aa as ThemeStyle, ab as ThemeTypography, ah as VIEWPORT_PRESETS, am as ViewportOrientation, an as ViewportPreset, aq as createTemplateContext, av as getLayoutHints, ay as getTwoColumnPositions, az as getViewport, aA as getViewportOrientation, aC as isTemplateBlock, aE as scaledFontSize } from '../Doc-BfBhiCNr.js';
|
|
3
3
|
import { p as MarkdownHeading, l as MarkdownDocument } from '../types-DYAzisMG.js';
|
|
4
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-
|
|
5
|
+
export { D as DEFAULT_THEME, g as getAvailableThemes, b as getThemeSummaries, r as resolveTheme } from '../themeLibrary-IP7EdyRq.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Persistent Layers Expansion
|
|
@@ -175,8 +175,8 @@ declare function mapBlock(input: MapBlockInput, context: TemplateContext): Layer
|
|
|
175
175
|
* Input for coverBlock template - matches StartBlockConfig
|
|
176
176
|
*/
|
|
177
177
|
interface CoverBlockInput {
|
|
178
|
-
/** Path to hero image */
|
|
179
|
-
heroSrc
|
|
178
|
+
/** Path to hero image (omit for theme-driven background) */
|
|
179
|
+
heroSrc?: string;
|
|
180
180
|
/** Alt text for the hero image */
|
|
181
181
|
heroAlt?: string;
|
|
182
182
|
/** Title to display over the hero */
|
|
@@ -579,6 +579,13 @@ interface MarkdownToDocOptions {
|
|
|
579
579
|
defaultDuration?: number;
|
|
580
580
|
/** Custom ID generator. Receives the heading node and its index. */
|
|
581
581
|
generateId?: (heading: MarkdownHeading, index: number) => string;
|
|
582
|
+
/**
|
|
583
|
+
* Whether to auto-generate a cover startBlock from the first H1 heading.
|
|
584
|
+
* When true (default), a StartBlockConfig is created using the first H1's
|
|
585
|
+
* text as the title. If the document contains an image, the first image
|
|
586
|
+
* is used as the hero. Set to false to suppress automatic cover generation.
|
|
587
|
+
*/
|
|
588
|
+
generateCoverBlock?: boolean;
|
|
582
589
|
}
|
|
583
590
|
/**
|
|
584
591
|
* Convert a MarkdownDocument into a Doc with a heading-driven Block hierarchy.
|
package/dist/story/index.js
CHANGED
|
@@ -50,13 +50,13 @@ import {
|
|
|
50
50
|
twoColumn,
|
|
51
51
|
videoPullQuote,
|
|
52
52
|
videoWithCaption
|
|
53
|
-
} from "../chunk-
|
|
53
|
+
} from "../chunk-GAQLMORN.js";
|
|
54
54
|
import {
|
|
55
55
|
DEFAULT_THEME,
|
|
56
56
|
getAvailableThemes,
|
|
57
57
|
getThemeSummaries,
|
|
58
58
|
resolveTheme
|
|
59
|
-
} from "../chunk-
|
|
59
|
+
} from "../chunk-53TZ2UTL.js";
|
|
60
60
|
import {
|
|
61
61
|
VIEWPORT_PRESETS,
|
|
62
62
|
createTemplateContext,
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
isTemplateBlock,
|
|
68
68
|
scaledFontSize2 as scaledFontSize
|
|
69
69
|
} from "../chunk-QM5PKNPW.js";
|
|
70
|
-
import "../chunk-
|
|
70
|
+
import "../chunk-7GF6RDED.js";
|
|
71
71
|
import "../chunk-4X3JQXNM.js";
|
|
72
72
|
import "../chunk-3E5F2XMR.js";
|
|
73
73
|
export {
|
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { coverBlock, expandCoverBlock, startBlockToCoverInput } from '../doc/templates/coverBlock';
|
|
3
|
+
import { createTemplateContext, DEFAULT_THEME, VIEWPORT_PRESETS } from '../doc/templates/index';
|
|
4
|
+
import type { StartBlockConfig } from '../schemas/Doc';
|
|
5
|
+
import type { CoverBlockInput } from '../doc/templates/coverBlock';
|
|
6
|
+
import type { ImageLayer, TextLayer, ShapeLayer } from '../schemas/Doc';
|
|
7
|
+
|
|
8
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const landscapeContext = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
|
|
11
|
+
const portraitContext = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.portrait);
|
|
12
|
+
|
|
13
|
+
function findLayer(layers: ReturnType<typeof coverBlock>, id: string) {
|
|
14
|
+
return layers.find((l) => l.id === id);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findImage(layers: ReturnType<typeof coverBlock>, id: string) {
|
|
18
|
+
return findLayer(layers, id) as ImageLayer | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findText(layers: ReturnType<typeof coverBlock>, id: string) {
|
|
22
|
+
return findLayer(layers, id) as TextLayer | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findShape(layers: ReturnType<typeof coverBlock>, id: string) {
|
|
26
|
+
return findLayer(layers, id) as ShapeLayer | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── coverBlock with hero image ──────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe('coverBlock with hero image', () => {
|
|
32
|
+
const input: CoverBlockInput = {
|
|
33
|
+
heroSrc: 'hero.jpg',
|
|
34
|
+
heroAlt: 'Hero image',
|
|
35
|
+
title: 'Test Title',
|
|
36
|
+
subtitle: 'A subtitle',
|
|
37
|
+
ambientMotion: 'zoomIn',
|
|
38
|
+
heroCredit: 'Photographer',
|
|
39
|
+
heroLicense: 'CC-BY-4.0',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
it('produces hero image layer', () => {
|
|
43
|
+
const layers = coverBlock(input, landscapeContext);
|
|
44
|
+
const hero = findImage(layers, 'cover-hero');
|
|
45
|
+
expect(hero).toBeDefined();
|
|
46
|
+
expect(hero!.type).toBe('image');
|
|
47
|
+
expect(hero!.content.src).toBe('hero.jpg');
|
|
48
|
+
expect(hero!.content.alt).toBe('Hero image');
|
|
49
|
+
expect(hero!.content.fit).toBe('cover');
|
|
50
|
+
expect(hero!.content.credit).toBe('Photographer');
|
|
51
|
+
expect(hero!.content.license).toBe('CC-BY-4.0');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('produces gradient overlay', () => {
|
|
55
|
+
const layers = coverBlock(input, landscapeContext);
|
|
56
|
+
const gradient = findShape(layers, 'cover-gradient');
|
|
57
|
+
expect(gradient).toBeDefined();
|
|
58
|
+
expect(gradient!.type).toBe('shape');
|
|
59
|
+
expect(gradient!.content.fill).toContain('linear-gradient');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('produces title layer with shadow', () => {
|
|
63
|
+
const layers = coverBlock(input, landscapeContext);
|
|
64
|
+
const title = findText(layers, 'cover-title');
|
|
65
|
+
expect(title).toBeDefined();
|
|
66
|
+
expect(title!.type).toBe('text');
|
|
67
|
+
expect(title!.content.text).toBe('Test Title');
|
|
68
|
+
expect(title!.content.style.shadow).toBe(true);
|
|
69
|
+
expect(title!.content.style.fontWeight).toBe('bold');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('produces subtitle layer', () => {
|
|
73
|
+
const layers = coverBlock(input, landscapeContext);
|
|
74
|
+
const subtitle = findText(layers, 'cover-subtitle');
|
|
75
|
+
expect(subtitle).toBeDefined();
|
|
76
|
+
expect(subtitle!.type).toBe('text');
|
|
77
|
+
expect(subtitle!.content.text).toBe('A subtitle');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not produce theme background layers', () => {
|
|
81
|
+
const layers = coverBlock(input, landscapeContext);
|
|
82
|
+
expect(findLayer(layers, 'cover-bg')).toBeUndefined();
|
|
83
|
+
expect(findLayer(layers, 'cover-accent')).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('title positioned lower (over hero)', () => {
|
|
87
|
+
const layers = coverBlock(input, landscapeContext);
|
|
88
|
+
const title = findLayer(layers, 'cover-title');
|
|
89
|
+
// With subtitle: 70%, without: 75% — both below center
|
|
90
|
+
expect(title!.position.y).toBe('70%');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('applies Ken Burns animation to hero image', () => {
|
|
94
|
+
const layers = coverBlock(input, landscapeContext);
|
|
95
|
+
const hero = findLayer(layers, 'cover-hero');
|
|
96
|
+
expect(hero!.animation).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── coverBlock without hero image (theme background) ────────────────
|
|
101
|
+
|
|
102
|
+
describe('coverBlock without hero image', () => {
|
|
103
|
+
const input: CoverBlockInput = {
|
|
104
|
+
title: 'Title Only',
|
|
105
|
+
subtitle: 'Subtitle text',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
it('produces theme background instead of hero', () => {
|
|
109
|
+
const layers = coverBlock(input, landscapeContext);
|
|
110
|
+
const bg = findShape(layers, 'cover-bg');
|
|
111
|
+
expect(bg).toBeDefined();
|
|
112
|
+
expect(bg!.type).toBe('shape');
|
|
113
|
+
expect(bg!.content.fill).toContain('radial-gradient');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('produces decorative accent line', () => {
|
|
117
|
+
const layers = coverBlock(input, landscapeContext);
|
|
118
|
+
const accent = findLayer(layers, 'cover-accent');
|
|
119
|
+
expect(accent).toBeDefined();
|
|
120
|
+
expect(accent!.type).toBe('shape');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not produce hero image or gradient overlay', () => {
|
|
124
|
+
const layers = coverBlock(input, landscapeContext);
|
|
125
|
+
expect(findLayer(layers, 'cover-hero')).toBeUndefined();
|
|
126
|
+
expect(findLayer(layers, 'cover-gradient')).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('title centered (not pushed to bottom)', () => {
|
|
130
|
+
const layers = coverBlock(input, landscapeContext);
|
|
131
|
+
const title = findLayer(layers, 'cover-title');
|
|
132
|
+
// Without hero: uses layout.primaryY which is typically around 35-40%
|
|
133
|
+
expect(title!.position.y).not.toBe('70%');
|
|
134
|
+
expect(title!.position.y).not.toBe('75%');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('subtitle uses layout secondaryY', () => {
|
|
138
|
+
const layers = coverBlock(input, landscapeContext);
|
|
139
|
+
const subtitle = findLayer(layers, 'cover-subtitle');
|
|
140
|
+
expect(subtitle!.position.y).not.toBe('82%');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── coverBlock title-only (no subtitle) ─────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('coverBlock title-only', () => {
|
|
147
|
+
it('no subtitle layer when subtitle is omitted', () => {
|
|
148
|
+
const layers = coverBlock({ title: 'Solo Title' }, landscapeContext);
|
|
149
|
+
expect(findLayer(layers, 'cover-subtitle')).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('hero title at 75% when no subtitle', () => {
|
|
153
|
+
const layers = coverBlock({ heroSrc: 'img.jpg', title: 'Solo' }, landscapeContext);
|
|
154
|
+
const title = findLayer(layers, 'cover-title');
|
|
155
|
+
expect(title!.position.y).toBe('75%');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('no-hero title at 50% when no subtitle', () => {
|
|
159
|
+
const layers = coverBlock({ title: 'Solo' }, landscapeContext);
|
|
160
|
+
const title = findLayer(layers, 'cover-title');
|
|
161
|
+
expect(title!.position.y).toBe('50%');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── Viewport scaling ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('coverBlock viewport scaling', () => {
|
|
168
|
+
it('produces layers for portrait viewport', () => {
|
|
169
|
+
const layers = coverBlock({ heroSrc: 'img.jpg', title: 'Portrait' }, portraitContext);
|
|
170
|
+
expect(layers.length).toBeGreaterThanOrEqual(3); // hero + gradient + title
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('title font size differs between landscape and portrait', () => {
|
|
174
|
+
const landLayers = coverBlock({ title: 'Test' }, landscapeContext);
|
|
175
|
+
const portLayers = coverBlock({ title: 'Test' }, portraitContext);
|
|
176
|
+
const landTitle = findText(landLayers, 'cover-title');
|
|
177
|
+
const portTitle = findText(portLayers, 'cover-title');
|
|
178
|
+
expect(landTitle!.content.style.fontSize).not.toBe(portTitle!.content.style.fontSize);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── startBlockToCoverInput ──────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('startBlockToCoverInput', () => {
|
|
185
|
+
it('maps all StartBlockConfig fields to CoverBlockInput', () => {
|
|
186
|
+
const config: StartBlockConfig = {
|
|
187
|
+
heroSrc: 'hero.jpg',
|
|
188
|
+
heroAlt: 'Alt',
|
|
189
|
+
title: 'Title',
|
|
190
|
+
subtitle: 'Sub',
|
|
191
|
+
ambientMotion: 'panLeft',
|
|
192
|
+
heroCredit: 'Credit',
|
|
193
|
+
heroLicense: 'MIT',
|
|
194
|
+
};
|
|
195
|
+
const result = startBlockToCoverInput(config);
|
|
196
|
+
expect(result.heroSrc).toBe('hero.jpg');
|
|
197
|
+
expect(result.heroAlt).toBe('Alt');
|
|
198
|
+
expect(result.title).toBe('Title');
|
|
199
|
+
expect(result.subtitle).toBe('Sub');
|
|
200
|
+
expect(result.ambientMotion).toBe('panLeft');
|
|
201
|
+
expect(result.heroCredit).toBe('Credit');
|
|
202
|
+
expect(result.heroLicense).toBe('MIT');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('handles config without heroSrc', () => {
|
|
206
|
+
const config: StartBlockConfig = { title: 'Title Only' };
|
|
207
|
+
const result = startBlockToCoverInput(config);
|
|
208
|
+
expect(result.heroSrc).toBeUndefined();
|
|
209
|
+
expect(result.title).toBe('Title Only');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── expandCoverBlock ────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe('expandCoverBlock', () => {
|
|
216
|
+
it('returns layers from StartBlockConfig', () => {
|
|
217
|
+
const config: StartBlockConfig = {
|
|
218
|
+
heroSrc: 'cover.jpg',
|
|
219
|
+
title: 'Cover Title',
|
|
220
|
+
};
|
|
221
|
+
const layers = expandCoverBlock(config, landscapeContext);
|
|
222
|
+
expect(layers.length).toBeGreaterThanOrEqual(3);
|
|
223
|
+
expect(layers.some((l) => l.id === 'cover-hero')).toBe(true);
|
|
224
|
+
expect(layers.some((l) => l.id === 'cover-title')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns theme background layers when no heroSrc', () => {
|
|
228
|
+
const config: StartBlockConfig = { title: 'No Hero' };
|
|
229
|
+
const layers = expandCoverBlock(config, landscapeContext);
|
|
230
|
+
expect(layers.some((l) => l.id === 'cover-bg')).toBe(true);
|
|
231
|
+
expect(layers.some((l) => l.id === 'cover-title')).toBe(true);
|
|
232
|
+
expect(layers.some((l) => l.id === 'cover-hero')).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -591,6 +591,28 @@ describe('Tree utilities', () => {
|
|
|
591
591
|
expect(extractPlainText(doc)).toBe('TitleHello world');
|
|
592
592
|
});
|
|
593
593
|
|
|
594
|
+
it('extractPlainText preserves list item boundaries', () => {
|
|
595
|
+
const list: MarkdownList = {
|
|
596
|
+
type: 'list',
|
|
597
|
+
ordered: false,
|
|
598
|
+
children: [
|
|
599
|
+
{
|
|
600
|
+
type: 'listItem',
|
|
601
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Apple' }] }],
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
type: 'listItem',
|
|
605
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Banana' }] }],
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
type: 'listItem',
|
|
609
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Cherry' }] }],
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
expect(extractPlainText(list)).toBe('Apple\nBanana\nCherry');
|
|
614
|
+
});
|
|
615
|
+
|
|
594
616
|
it('countNodes counts all nodes', () => {
|
|
595
617
|
expect(countNodes(doc)).toBe(7); // document + heading + text + paragraph + text + link + text
|
|
596
618
|
});
|
|
@@ -468,3 +468,83 @@ describe('caption generation', () => {
|
|
|
468
468
|
}
|
|
469
469
|
});
|
|
470
470
|
});
|
|
471
|
+
|
|
472
|
+
// ============================================
|
|
473
|
+
// Auto cover block (startBlock) generation
|
|
474
|
+
// ============================================
|
|
475
|
+
|
|
476
|
+
describe('auto cover block generation', () => {
|
|
477
|
+
it('creates startBlock from H1 title', () => {
|
|
478
|
+
const md = parseMarkdown('# My Great Article\n\nSome introduction text.');
|
|
479
|
+
const doc = markdownToDoc(md);
|
|
480
|
+
|
|
481
|
+
expect(doc.startBlock).toBeDefined();
|
|
482
|
+
expect(doc.startBlock!.title).toBe('My Great Article');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('uses first paragraph after H1 as subtitle', () => {
|
|
486
|
+
const md = parseMarkdown('# Title\n\nThis is a subtitle paragraph.\n\n## Section\n\nBody');
|
|
487
|
+
const doc = markdownToDoc(md);
|
|
488
|
+
|
|
489
|
+
expect(doc.startBlock).toBeDefined();
|
|
490
|
+
expect(doc.startBlock!.subtitle).toBe('This is a subtitle paragraph.');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('picks first image as heroSrc', () => {
|
|
494
|
+
const md = parseMarkdown('# Title\n\n\n\nSome text.');
|
|
495
|
+
const doc = markdownToDoc(md);
|
|
496
|
+
|
|
497
|
+
expect(doc.startBlock).toBeDefined();
|
|
498
|
+
expect(doc.startBlock!.heroSrc).toBe('hero.jpg');
|
|
499
|
+
expect(doc.startBlock!.heroAlt).toBe('photo');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('picks image from deeper in document', () => {
|
|
503
|
+
const md = parseMarkdown(
|
|
504
|
+
'# Title\n\nIntro text.\n\n## Section\n\nBody text.\n\n',
|
|
505
|
+
);
|
|
506
|
+
const doc = markdownToDoc(md);
|
|
507
|
+
|
|
508
|
+
expect(doc.startBlock).toBeDefined();
|
|
509
|
+
expect(doc.startBlock!.heroSrc).toBe('deep.png');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('works without images (no heroSrc)', () => {
|
|
513
|
+
const md = parseMarkdown('# Title\n\nPlain text, no images.');
|
|
514
|
+
const doc = markdownToDoc(md);
|
|
515
|
+
|
|
516
|
+
expect(doc.startBlock).toBeDefined();
|
|
517
|
+
expect(doc.startBlock!.title).toBe('Title');
|
|
518
|
+
expect(doc.startBlock!.heroSrc).toBeUndefined();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('sets ambientMotion to zoomIn by default', () => {
|
|
522
|
+
const md = parseMarkdown('# Title\n\nContent.');
|
|
523
|
+
const doc = markdownToDoc(md);
|
|
524
|
+
|
|
525
|
+
expect(doc.startBlock!.ambientMotion).toBe('zoomIn');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('does not create startBlock when no H1 exists', () => {
|
|
529
|
+
const md = parseMarkdown('## Section A\n\nText A\n\n## Section B\n\nText B');
|
|
530
|
+
const doc = markdownToDoc(md);
|
|
531
|
+
|
|
532
|
+
expect(doc.startBlock).toBeUndefined();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('does not create startBlock when generateCoverBlock is false', () => {
|
|
536
|
+
const md = parseMarkdown('# Title\n\nContent.');
|
|
537
|
+
const doc = markdownToDoc(md, { generateCoverBlock: false });
|
|
538
|
+
|
|
539
|
+
expect(doc.startBlock).toBeUndefined();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('does not create startBlock for heading-only document with no content', () => {
|
|
543
|
+
const md = parseMarkdown('# Title');
|
|
544
|
+
const doc = markdownToDoc(md);
|
|
545
|
+
|
|
546
|
+
expect(doc.startBlock).toBeDefined();
|
|
547
|
+
expect(doc.startBlock!.title).toBe('Title');
|
|
548
|
+
expect(doc.startBlock!.subtitle).toBeUndefined();
|
|
549
|
+
});
|
|
550
|
+
});
|
package/src/doc/markdownToDoc.ts
CHANGED
|
@@ -22,8 +22,14 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type { Doc, Block, CaptionTrack, CaptionPhrase } from '../schemas/Doc.js';
|
|
26
|
-
import type {
|
|
25
|
+
import type { Doc, Block, CaptionTrack, CaptionPhrase, StartBlockConfig } from '../schemas/Doc.js';
|
|
26
|
+
import type {
|
|
27
|
+
MarkdownDocument,
|
|
28
|
+
MarkdownBlockNode,
|
|
29
|
+
MarkdownHeading,
|
|
30
|
+
MarkdownNode,
|
|
31
|
+
MarkdownImage,
|
|
32
|
+
} from '../markdown/types.js';
|
|
27
33
|
import { extractPlainText } from '../markdown/utils.js';
|
|
28
34
|
import { estimateReadingTime } from '../timing/readingTime.js';
|
|
29
35
|
|
|
@@ -46,6 +52,14 @@ export interface MarkdownToDocOptions {
|
|
|
46
52
|
|
|
47
53
|
/** Custom ID generator. Receives the heading node and its index. */
|
|
48
54
|
generateId?: (heading: MarkdownHeading, index: number) => string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether to auto-generate a cover startBlock from the first H1 heading.
|
|
58
|
+
* When true (default), a StartBlockConfig is created using the first H1's
|
|
59
|
+
* text as the title. If the document contains an image, the first image
|
|
60
|
+
* is used as the hero. Set to false to suppress automatic cover generation.
|
|
61
|
+
*/
|
|
62
|
+
generateCoverBlock?: boolean;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
// ============================================
|
|
@@ -267,7 +281,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
267
281
|
const captions: CaptionTrack | undefined =
|
|
268
282
|
phrases.length > 0 ? { phrases, generatedAt: new Date().toISOString(), version: 1 } : undefined;
|
|
269
283
|
|
|
270
|
-
|
|
284
|
+
const doc: Doc = {
|
|
271
285
|
articleId,
|
|
272
286
|
duration: currentTime,
|
|
273
287
|
blocks: rootBlocks,
|
|
@@ -277,6 +291,16 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
277
291
|
...(captions ? { captions } : {}),
|
|
278
292
|
...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
|
|
279
293
|
};
|
|
294
|
+
|
|
295
|
+
// Auto-generate cover startBlock from the first H1 heading
|
|
296
|
+
if (options?.generateCoverBlock ?? true) {
|
|
297
|
+
const coverConfig = buildStartBlock(markdownDoc, rootBlocks);
|
|
298
|
+
if (coverConfig) {
|
|
299
|
+
doc.startBlock = coverConfig;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return doc;
|
|
280
304
|
}
|
|
281
305
|
|
|
282
306
|
// ============================================
|
|
@@ -409,3 +433,50 @@ function splitIntoSentences(text: string): string[] {
|
|
|
409
433
|
}
|
|
410
434
|
return merged;
|
|
411
435
|
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Walk a MarkdownNode tree depth-first to find the first image node.
|
|
439
|
+
* Returns the MarkdownImage or undefined if none found.
|
|
440
|
+
*/
|
|
441
|
+
function findFirstImage(node: MarkdownNode): MarkdownImage | undefined {
|
|
442
|
+
if (node.type === 'image') return node as MarkdownImage;
|
|
443
|
+
if ('children' in node && Array.isArray((node as { children?: unknown[] }).children)) {
|
|
444
|
+
for (const child of (node as { children: MarkdownNode[] }).children) {
|
|
445
|
+
const found = findFirstImage(child);
|
|
446
|
+
if (found) return found;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Build a StartBlockConfig from the document's first H1 heading and optional
|
|
454
|
+
* first image. Returns undefined if the document has no H1 heading.
|
|
455
|
+
*/
|
|
456
|
+
function buildStartBlock(
|
|
457
|
+
markdownDoc: MarkdownDocument,
|
|
458
|
+
rootBlocks: Block[],
|
|
459
|
+
): StartBlockConfig | undefined {
|
|
460
|
+
// Find the first H1 block — it provides the cover title
|
|
461
|
+
const firstH1 = rootBlocks.find((b) => b.sourceHeading?.depth === 1);
|
|
462
|
+
if (!firstH1) return undefined;
|
|
463
|
+
|
|
464
|
+
const title = firstH1.title ?? extractPlainText(firstH1.sourceHeading!);
|
|
465
|
+
if (!title) return undefined;
|
|
466
|
+
|
|
467
|
+
// Look for the first paragraph immediately after the H1 to use as subtitle
|
|
468
|
+
const subtitle =
|
|
469
|
+
firstH1.contents?.[0]?.type === 'paragraph' ? extractPlainText(firstH1.contents[0]) : undefined;
|
|
470
|
+
|
|
471
|
+
// Scan the whole document for the first image to use as the hero
|
|
472
|
+
const firstImage = findFirstImage(markdownDoc);
|
|
473
|
+
|
|
474
|
+
const config: StartBlockConfig = {
|
|
475
|
+
title,
|
|
476
|
+
...(subtitle ? { subtitle } : {}),
|
|
477
|
+
...(firstImage ? { heroSrc: firstImage.url, heroAlt: firstImage.alt ?? title } : {}),
|
|
478
|
+
ambientMotion: 'zoomIn',
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
return config;
|
|
482
|
+
}
|