@bendyline/squisq 1.2.1 → 1.3.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-Dwn5aHFC.d.ts → Doc-Ch4tc3vv.d.ts} +35 -4
- 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 +61 -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/__tests__/surface.test.d.ts +2 -0
- package/dist/__tests__/surface.test.d.ts.map +1 -0
- package/dist/__tests__/surface.test.js +57 -0
- package/dist/__tests__/surface.test.js.map +1 -0
- package/dist/{chunk-T6WBC3ND.js → chunk-6BAVXFYC.js} +137 -53
- package/dist/chunk-6BAVXFYC.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-KEFWM2VC.js → chunk-F22AE6BY.js} +31 -2
- package/dist/chunk-F22AE6BY.js.map +1 -0
- package/dist/{chunk-TVPKD76V.js → chunk-FIQR4AJG.js} +50 -4
- package/dist/chunk-FIQR4AJG.js.map +1 -0
- package/dist/{chunk-NJLL5XYC.js → chunk-OASJKWO5.js} +76 -13
- package/dist/chunk-OASJKWO5.js.map +1 -0
- package/dist/doc/audioMapping.d.ts.map +1 -1
- package/dist/doc/audioMapping.js +41 -5
- package/dist/doc/audioMapping.js.map +1 -1
- 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/convert.d.ts.map +1 -1
- package/dist/markdown/convert.js +101 -11
- package/dist/markdown/convert.js.map +1 -1
- package/dist/markdown/index.d.ts +1 -1
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js.map +1 -1
- package/dist/markdown/types.d.ts +19 -1
- package/dist/markdown/types.d.ts.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/Theme.d.ts +31 -0
- package/dist/schemas/Theme.d.ts.map +1 -1
- package/dist/schemas/Theme.js +35 -0
- package/dist/schemas/Theme.js.map +1 -1
- package/dist/schemas/themeLibrary.d.ts.map +1 -1
- package/dist/schemas/themeLibrary.js +68 -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 +13 -6
- package/dist/story/index.js +3 -3
- package/dist/{themeLibrary-DLJtGh8-.d.ts → themeLibrary-CEo2D4b6.d.ts} +1 -1
- package/dist/{types-DYAzisMG.d.ts → types-CkNttVSF.d.ts} +20 -2
- package/package.json +1 -1
- package/src/__tests__/coverBlock.test.ts +234 -0
- package/src/__tests__/markdown.test.ts +66 -0
- package/src/__tests__/markdownToDoc.test.ts +80 -0
- package/src/__tests__/surface.test.ts +68 -0
- package/src/doc/audioMapping.ts +43 -5
- package/src/doc/markdownToDoc.ts +74 -3
- package/src/doc/templates/coverBlock.ts +77 -47
- package/src/markdown/convert.ts +104 -11
- package/src/markdown/index.ts +1 -0
- package/src/markdown/types.ts +21 -1
- package/src/markdown/utils.ts +5 -1
- package/src/schemas/Doc.ts +2 -2
- package/src/schemas/Theme.ts +62 -0
- package/src/schemas/themeLibrary.ts +74 -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-NJLL5XYC.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
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
MarkdownLinkReference,
|
|
34
34
|
MarkdownImageReference,
|
|
35
35
|
MarkdownText,
|
|
36
|
+
MarkdownMention,
|
|
36
37
|
} from '../markdown/index';
|
|
37
38
|
|
|
38
39
|
// ============================================
|
|
@@ -151,6 +152,49 @@ describe('parseMarkdown / stringifyMarkdown', () => {
|
|
|
151
152
|
expect(link.children[0]).toEqual({ type: 'text', value: 'Click here' });
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
it('parses mentions from @[Name](scheme:id)', () => {
|
|
156
|
+
const doc = parse('Hey @[Leo](gezel:leo), take a look.');
|
|
157
|
+
const p = doc.children[0] as MarkdownParagraph;
|
|
158
|
+
const text0 = p.children[0] as MarkdownText;
|
|
159
|
+
expect(text0).toEqual({ type: 'text', value: 'Hey ' });
|
|
160
|
+
const mention = p.children[1] as MarkdownMention;
|
|
161
|
+
expect(mention).toEqual({
|
|
162
|
+
type: 'mention',
|
|
163
|
+
targetKind: 'gezel',
|
|
164
|
+
targetId: 'leo',
|
|
165
|
+
displayName: 'Leo',
|
|
166
|
+
});
|
|
167
|
+
const text2 = p.children[2] as MarkdownText;
|
|
168
|
+
expect(text2.value).toBe(', take a look.');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('round-trips a mention back to markdown', () => {
|
|
172
|
+
// remark-stringify escapes `:` in link URLs that follow an `@` (to
|
|
173
|
+
// disambiguate from autolink syntax). The escape is idempotent —
|
|
174
|
+
// re-parsing `\:` produces the unescaped url, so the round-trip
|
|
175
|
+
// preserves the mention semantically even if the raw text changes
|
|
176
|
+
// on the first pass.
|
|
177
|
+
const input = 'Hey @[Leo](gezel:leo), ping @[Tess](gezel:tess) too.\n';
|
|
178
|
+
const once = stringifyMarkdown(parseMarkdown(input));
|
|
179
|
+
const twice = stringifyMarkdown(parseMarkdown(once));
|
|
180
|
+
expect(twice).toBe(once);
|
|
181
|
+
// Semantics are preserved: re-parsing yields mentions.
|
|
182
|
+
const doc = parseMarkdown(once) as MarkdownDocument;
|
|
183
|
+
const p = doc.children[0] as MarkdownParagraph;
|
|
184
|
+
const m1 = p.children.find((c) => c.type === 'mention') as MarkdownMention;
|
|
185
|
+
expect(m1.targetKind).toBe('gezel');
|
|
186
|
+
expect(m1.targetId).toBe('leo');
|
|
187
|
+
expect(m1.displayName).toBe('Leo');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not collapse @ + link when the url has no scheme', () => {
|
|
191
|
+
const doc = parse('Cost @[100]($100)');
|
|
192
|
+
const p = doc.children[0] as MarkdownParagraph;
|
|
193
|
+
// "$100" doesn't match scheme:id because $ isn't a scheme character
|
|
194
|
+
// → stays as text("Cost @") + link
|
|
195
|
+
expect((p.children[1] as MarkdownLink).type).toBe('link');
|
|
196
|
+
});
|
|
197
|
+
|
|
154
198
|
it('parses images', () => {
|
|
155
199
|
const doc = parse('');
|
|
156
200
|
const p = doc.children[0] as MarkdownParagraph;
|
|
@@ -591,6 +635,28 @@ describe('Tree utilities', () => {
|
|
|
591
635
|
expect(extractPlainText(doc)).toBe('TitleHello world');
|
|
592
636
|
});
|
|
593
637
|
|
|
638
|
+
it('extractPlainText preserves list item boundaries', () => {
|
|
639
|
+
const list: MarkdownList = {
|
|
640
|
+
type: 'list',
|
|
641
|
+
ordered: false,
|
|
642
|
+
children: [
|
|
643
|
+
{
|
|
644
|
+
type: 'listItem',
|
|
645
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Apple' }] }],
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
type: 'listItem',
|
|
649
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Banana' }] }],
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
type: 'listItem',
|
|
653
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Cherry' }] }],
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
};
|
|
657
|
+
expect(extractPlainText(list)).toBe('Apple\nBanana\nCherry');
|
|
658
|
+
});
|
|
659
|
+
|
|
594
660
|
it('countNodes counts all nodes', () => {
|
|
595
661
|
expect(countNodes(doc)).toBe(7); // document + heading + text + paragraph + text + link + text
|
|
596
662
|
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
applySurface,
|
|
4
|
+
DARK_SURFACE,
|
|
5
|
+
DEFAULT_THEME,
|
|
6
|
+
LIGHT_SURFACE,
|
|
7
|
+
type SurfaceScheme,
|
|
8
|
+
} from '../schemas/index.js';
|
|
9
|
+
|
|
10
|
+
describe('SurfaceScheme', () => {
|
|
11
|
+
it('LIGHT_SURFACE and DARK_SURFACE expose the full field set', () => {
|
|
12
|
+
for (const s of [LIGHT_SURFACE, DARK_SURFACE]) {
|
|
13
|
+
expect(typeof s.id).toBe('string');
|
|
14
|
+
expect(typeof s.background).toBe('string');
|
|
15
|
+
expect(typeof s.backgroundLight).toBe('string');
|
|
16
|
+
expect(typeof s.text).toBe('string');
|
|
17
|
+
expect(typeof s.textMuted).toBe('string');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('applySurface', () => {
|
|
23
|
+
it('overlays surface fields onto a theme', () => {
|
|
24
|
+
const out = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
|
|
25
|
+
expect(out.colors.background).toBe(LIGHT_SURFACE.background);
|
|
26
|
+
expect(out.colors.backgroundLight).toBe(LIGHT_SURFACE.backgroundLight);
|
|
27
|
+
expect(out.colors.text).toBe(LIGHT_SURFACE.text);
|
|
28
|
+
expect(out.colors.textMuted).toBe(LIGHT_SURFACE.textMuted);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves editorial colors (primary, highlight, warning) untouched', () => {
|
|
32
|
+
const out = applySurface(DEFAULT_THEME, DARK_SURFACE);
|
|
33
|
+
expect(out.colors.primary).toBe(DEFAULT_THEME.colors.primary);
|
|
34
|
+
expect(out.colors.secondary).toBe(DEFAULT_THEME.colors.secondary);
|
|
35
|
+
expect(out.colors.highlight).toBe(DEFAULT_THEME.colors.highlight);
|
|
36
|
+
expect(out.colors.warning).toBe(DEFAULT_THEME.colors.warning);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('leaves typography, style, renderStyle, colorSchemes untouched', () => {
|
|
40
|
+
const out = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
|
|
41
|
+
expect(out.typography).toBe(DEFAULT_THEME.typography);
|
|
42
|
+
expect(out.style).toBe(DEFAULT_THEME.style);
|
|
43
|
+
expect(out.renderStyle).toBe(DEFAULT_THEME.renderStyle);
|
|
44
|
+
expect(out.colorSchemes).toBe(DEFAULT_THEME.colorSchemes);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns a new theme (does not mutate the base)', () => {
|
|
48
|
+
const frozenTheme = DEFAULT_THEME;
|
|
49
|
+
const originalBg = frozenTheme.colors.background;
|
|
50
|
+
const out = applySurface(frozenTheme, DARK_SURFACE);
|
|
51
|
+
expect(out).not.toBe(frozenTheme);
|
|
52
|
+
expect(out.colors).not.toBe(frozenTheme.colors);
|
|
53
|
+
expect(frozenTheme.colors.background).toBe(originalBg);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts custom SurfaceScheme objects', () => {
|
|
57
|
+
const custom: SurfaceScheme = {
|
|
58
|
+
id: 'parchment',
|
|
59
|
+
background: '#f4ecd8',
|
|
60
|
+
backgroundLight: '#e8dfc4',
|
|
61
|
+
text: '#2b2b2b',
|
|
62
|
+
textMuted: '#6b6b6b',
|
|
63
|
+
};
|
|
64
|
+
const out = applySurface(DEFAULT_THEME, custom);
|
|
65
|
+
expect(out.colors.background).toBe('#f4ecd8');
|
|
66
|
+
expect(out.colors.textMuted).toBe('#6b6b6b');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/doc/audioMapping.ts
CHANGED
|
@@ -124,23 +124,61 @@ interface Mp3Info {
|
|
|
124
124
|
|
|
125
125
|
// ── Core Functions ───────────────────────────────────────────────────
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Parse a consolidated timing.json (version 2) which contains all section
|
|
129
|
+
* timing data in a single file.
|
|
130
|
+
*/
|
|
131
|
+
function parseConsolidatedTiming(data: ArrayBuffer): Record<string, AudioTimingData> | null {
|
|
132
|
+
try {
|
|
133
|
+
const text = new TextDecoder().decode(data);
|
|
134
|
+
const parsed = JSON.parse(text);
|
|
135
|
+
if (parsed && parsed.version === 2 && parsed.sections) {
|
|
136
|
+
return parsed.sections as Record<string, AudioTimingData>;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
/**
|
|
128
145
|
* Discover all MP3 files in a container and load their timing data.
|
|
146
|
+
* Checks for a consolidated timing.json first, then falls back to per-file .timing.json.
|
|
129
147
|
*/
|
|
130
148
|
async function discoverMp3s(container: ContentContainer): Promise<Mp3Info[]> {
|
|
131
149
|
const files = await container.listFiles();
|
|
132
150
|
const mp3Files = files.filter((f) => f.path.endsWith('.mp3'));
|
|
133
151
|
const results: Mp3Info[] = [];
|
|
134
152
|
|
|
153
|
+
// Try to load consolidated timing.json
|
|
154
|
+
let consolidatedSections: Record<string, AudioTimingData> | null = null;
|
|
155
|
+
const consolidatedData = await container.readFile('timing.json');
|
|
156
|
+
if (consolidatedData) {
|
|
157
|
+
consolidatedSections = parseConsolidatedTiming(consolidatedData);
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
for (const file of mp3Files) {
|
|
136
161
|
const filename = file.path.split('/').pop() ?? file.path;
|
|
137
162
|
let timing: AudioTimingData | null = null;
|
|
138
163
|
|
|
139
|
-
// Try
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
164
|
+
// Try consolidated timing first: match section name from filename
|
|
165
|
+
if (consolidatedSections) {
|
|
166
|
+
const mp3Base = filename.replace(/\.mp3$/, '');
|
|
167
|
+
for (const [sectionName, sectionTiming] of Object.entries(consolidatedSections)) {
|
|
168
|
+
if (mp3Base.endsWith(`-${sectionName}`)) {
|
|
169
|
+
timing = sectionTiming;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fall back to per-file .timing.json
|
|
176
|
+
if (!timing) {
|
|
177
|
+
const timingPath = `${file.path}.timing.json`;
|
|
178
|
+
const timingData = await container.readFile(timingPath);
|
|
179
|
+
if (timingData) {
|
|
180
|
+
timing = parseTimingJson(timingData);
|
|
181
|
+
}
|
|
144
182
|
}
|
|
145
183
|
|
|
146
184
|
results.push({ path: file.path, filename, timing });
|
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
|
+
}
|
|
@@ -25,8 +25,8 @@ import { mapAmbientMotion } from './accentImage.js';
|
|
|
25
25
|
* Input for coverBlock template - matches StartBlockConfig
|
|
26
26
|
*/
|
|
27
27
|
export interface CoverBlockInput {
|
|
28
|
-
/** Path to hero image */
|
|
29
|
-
heroSrc
|
|
28
|
+
/** Path to hero image (omit for theme-driven background) */
|
|
29
|
+
heroSrc?: string;
|
|
30
30
|
/** Alt text for the hero image */
|
|
31
31
|
heroAlt?: string;
|
|
32
32
|
/** Title to display over the hero */
|
|
@@ -48,63 +48,93 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
|
|
|
48
48
|
const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
|
|
49
49
|
const { theme, layout } = context;
|
|
50
50
|
|
|
51
|
-
// Scale font sizes for viewport -
|
|
52
|
-
const titleFontSize = scaledFontSize(
|
|
53
|
-
const subtitleFontSize = scaledFontSize(
|
|
51
|
+
// Scale font sizes for viewport - cover titles are larger than regular title blocks
|
|
52
|
+
const titleFontSize = scaledFontSize(120, context, true);
|
|
53
|
+
const subtitleFontSize = scaledFontSize(40, context, false);
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
const imageAnimation = mapAmbientMotion(ambientMotion);
|
|
55
|
+
const layers: Layer[] = [];
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
57
|
+
if (heroSrc) {
|
|
58
|
+
// Hero image path: full-screen image with gradient overlay for text readability
|
|
59
|
+
const imageAnimation = mapAmbientMotion(ambientMotion);
|
|
60
|
+
|
|
61
|
+
layers.push(
|
|
62
|
+
{
|
|
63
|
+
type: 'image',
|
|
64
|
+
id: 'cover-hero',
|
|
65
|
+
content: {
|
|
66
|
+
src: heroSrc,
|
|
67
|
+
alt: heroAlt || title,
|
|
68
|
+
fit: 'cover',
|
|
69
|
+
credit: heroCredit,
|
|
70
|
+
license: heroLicense,
|
|
71
|
+
},
|
|
72
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
73
|
+
animation: imageAnimation,
|
|
69
74
|
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
{
|
|
76
|
+
type: 'shape',
|
|
77
|
+
id: 'cover-gradient',
|
|
78
|
+
content: {
|
|
79
|
+
shape: 'rect',
|
|
80
|
+
fill: 'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 70%, transparent 100%)',
|
|
81
|
+
},
|
|
82
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
// No hero image: use a rich theme-driven background with radial gradient
|
|
87
|
+
layers.push({
|
|
75
88
|
type: 'shape',
|
|
76
|
-
id: 'cover-
|
|
89
|
+
id: 'cover-bg',
|
|
77
90
|
content: {
|
|
78
91
|
shape: 'rect',
|
|
79
|
-
fill:
|
|
92
|
+
fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
|
|
80
93
|
},
|
|
81
94
|
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Subtle decorative accent line below title
|
|
98
|
+
layers.push({
|
|
99
|
+
type: 'shape',
|
|
100
|
+
id: 'cover-accent',
|
|
87
101
|
content: {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
fontSize: titleFontSize,
|
|
91
|
-
fontFamily: getThemeFont(context, 'title'),
|
|
92
|
-
fontWeight: 'bold',
|
|
93
|
-
color: theme.colors.text,
|
|
94
|
-
textAlign: 'center',
|
|
95
|
-
shadow: true,
|
|
96
|
-
},
|
|
102
|
+
shape: 'rect',
|
|
103
|
+
fill: 'rgba(255, 255, 255, 0.2)',
|
|
97
104
|
},
|
|
98
105
|
position: {
|
|
99
|
-
x: '
|
|
100
|
-
y: subtitle ? '
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
x: '35%',
|
|
107
|
+
y: subtitle ? '42%' : '58%',
|
|
108
|
+
width: '30%',
|
|
109
|
+
height: '2px',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Title - positioned lower when over hero, centered when over solid background
|
|
115
|
+
layers.push({
|
|
116
|
+
type: 'text',
|
|
117
|
+
id: 'cover-title',
|
|
118
|
+
content: {
|
|
119
|
+
text: title,
|
|
120
|
+
style: {
|
|
121
|
+
fontSize: titleFontSize,
|
|
122
|
+
fontFamily: getThemeFont(context, 'title'),
|
|
123
|
+
fontWeight: 'bold',
|
|
124
|
+
color: theme.colors.text,
|
|
125
|
+
textAlign: 'center',
|
|
126
|
+
shadow: true,
|
|
103
127
|
},
|
|
104
|
-
// No animation delay - shown immediately at rest
|
|
105
|
-
animation: { type: 'fadeIn', duration: 0.8 },
|
|
106
128
|
},
|
|
107
|
-
|
|
129
|
+
position: {
|
|
130
|
+
x: '50%',
|
|
131
|
+
y: heroSrc ? (subtitle ? '70%' : '75%') : subtitle ? layout.primaryY : '50%',
|
|
132
|
+
anchor: 'center',
|
|
133
|
+
width: layout.maxTextWidth,
|
|
134
|
+
},
|
|
135
|
+
// No animation delay - shown immediately at rest
|
|
136
|
+
animation: { type: 'fadeIn', duration: 0.8 },
|
|
137
|
+
});
|
|
108
138
|
|
|
109
139
|
// Add subtitle if provided
|
|
110
140
|
if (subtitle) {
|
|
@@ -123,7 +153,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
|
|
|
123
153
|
},
|
|
124
154
|
position: {
|
|
125
155
|
x: '50%',
|
|
126
|
-
y: '82%',
|
|
156
|
+
y: heroSrc ? '82%' : layout.secondaryY,
|
|
127
157
|
anchor: 'center',
|
|
128
158
|
width: layout.maxTextWidth,
|
|
129
159
|
},
|