@bendyline/squisq 1.4.1 → 1.5.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-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
- package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
- package/dist/chunk-2OIBZYKP.js.map +1 -0
- package/dist/chunk-2UQOHYE2.js +45 -0
- package/dist/chunk-2UQOHYE2.js.map +1 -0
- package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
- package/dist/chunk-4AKUQPEE.js.map +1 -0
- package/dist/chunk-4ZFRZBU2.js +6582 -0
- package/dist/chunk-4ZFRZBU2.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
- package/dist/chunk-7MQGNR76.js.map +1 -0
- package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
- package/dist/chunk-DLXZMS5K.js.map +1 -0
- package/dist/{chunk-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
- package/dist/chunk-FBKP5CAO.js.map +1 -0
- package/dist/chunk-KZNXZ5SM.js +333 -0
- package/dist/chunk-KZNXZ5SM.js.map +1 -0
- package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
- package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
- package/dist/chunk-OH43RPTP.js.map +1 -0
- package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
- package/dist/chunk-OKJOQXK4.js.map +1 -0
- package/dist/chunk-PGGGKYWG.js +1 -0
- package/dist/chunk-PGGGKYWG.js.map +1 -0
- package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
- package/dist/chunk-Q3ROPT5H.js.map +1 -0
- package/dist/chunk-QE7TWGVT.js +178 -0
- package/dist/chunk-QE7TWGVT.js.map +1 -0
- package/dist/chunk-RUDYOTA6.js +345 -0
- package/dist/chunk-RUDYOTA6.js.map +1 -0
- package/dist/chunk-TVSGLUID.js +596 -0
- package/dist/chunk-TVSGLUID.js.map +1 -0
- package/dist/chunk-ZLQKNOZQ.js +1 -0
- package/dist/chunk-ZLQKNOZQ.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +124 -6
- package/dist/generate/index.d.ts +9 -2
- package/dist/generate/index.js +1 -1
- package/dist/icons/index.d.ts +3 -26
- package/dist/icons/index.js +13 -0
- package/dist/icons/inlineIconMarker.d.ts +1 -0
- package/dist/icons/inlineIconMarker.js +15 -0
- package/dist/icons/inlineIconMarker.js.map +1 -0
- package/dist/imageEdit/index.d.ts +11 -5
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.js +243 -24
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/markdown/index.d.ts +212 -3
- package/dist/markdown/index.js +40 -7
- package/dist/recommend/index.d.ts +1 -1
- package/dist/recommend/index.js +3 -2
- package/dist/schemas/index.d.ts +28 -8
- package/dist/schemas/index.js +35 -5
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/story/index.d.ts +906 -40
- package/dist/story/index.js +124 -6
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
- package/dist/transform/index.d.ts +48 -10
- package/dist/transform/index.js +17 -481
- package/dist/transform/index.js.map +1 -1
- package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
- package/package.json +6 -1
- package/src/__tests__/animationUtils.test.ts +40 -0
- package/src/__tests__/annotationCoercion.test.ts +243 -0
- package/src/__tests__/applyRenderStyle.test.ts +146 -0
- package/src/__tests__/atmosphereLayers.test.ts +108 -0
- package/src/__tests__/attrTokens.test.ts +162 -0
- package/src/__tests__/customTemplateSample.test.ts +83 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
- package/src/__tests__/diagramBlock.test.ts +122 -0
- package/src/__tests__/diagramLayout.test.ts +134 -0
- package/src/__tests__/drawing.test.ts +109 -0
- package/src/__tests__/drawingBlock.test.ts +124 -0
- package/src/__tests__/drawingLayout.test.ts +171 -0
- package/src/__tests__/geohash.test.ts +6 -5
- package/src/__tests__/getLayers.test.ts +6 -2
- package/src/__tests__/getLayersFallback.test.ts +103 -0
- package/src/__tests__/imageEditExportSvg.test.ts +70 -0
- package/src/__tests__/imageEditVersions.test.ts +72 -0
- package/src/__tests__/inlineIconMarker.test.ts +36 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +23 -0
- package/src/__tests__/markdownSanitize.test.ts +64 -0
- package/src/__tests__/markdownToDoc.test.ts +223 -2
- package/src/__tests__/mediaAnnotations.test.ts +64 -0
- package/src/__tests__/mediaSchedule.test.ts +118 -0
- package/src/__tests__/pandocAttrParse.test.ts +187 -0
- package/src/__tests__/resolveDocTheme.test.ts +55 -0
- package/src/__tests__/rootExports.test.ts +43 -0
- package/src/__tests__/shapeGeometry.test.ts +89 -0
- package/src/__tests__/shapeVocabulary.test.ts +133 -0
- package/src/__tests__/structuredData.test.ts +179 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +21 -0
- package/src/__tests__/templates.test.ts +145 -4
- package/src/__tests__/themeCompile.test.ts +42 -0
- package/src/__tests__/themeValidator.test.ts +6 -0
- package/src/__tests__/transformV2.test.ts +137 -0
- package/src/__tests__/transitionNormalize.test.ts +61 -0
- package/src/__tests__/validateDoc.test.ts +100 -0
- package/src/doc/customTemplatesFrontmatter.ts +308 -0
- package/src/doc/customThemesFrontmatter.ts +90 -0
- package/src/doc/docToMarkdown.ts +73 -4
- package/src/doc/getLayers.ts +40 -17
- package/src/doc/index.ts +35 -1
- package/src/doc/markdownToDoc.ts +333 -12
- package/src/doc/mediaAnnotations.ts +118 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/structuredData.ts +230 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
- package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
- package/src/doc/templates/accentImage.ts +30 -6
- package/src/doc/templates/captionUtils.ts +23 -0
- package/src/doc/templates/comparisonBar.ts +26 -18
- package/src/doc/templates/coverBlock.ts +7 -4
- package/src/doc/templates/customTemplate.ts +45 -0
- package/src/doc/templates/dataTable.ts +37 -19
- package/src/doc/templates/dateEvent.ts +41 -20
- package/src/doc/templates/definitionCard.ts +26 -18
- package/src/doc/templates/diagramBlock.ts +204 -0
- package/src/doc/templates/diagramLayout.ts +159 -0
- package/src/doc/templates/drawingBlock.ts +325 -0
- package/src/doc/templates/drawingLayout.ts +539 -0
- package/src/doc/templates/factCard.ts +51 -25
- package/src/doc/templates/fallbackBlock.ts +123 -0
- package/src/doc/templates/featureBlock.ts +20 -13
- package/src/doc/templates/fullBleedQuote.ts +18 -8
- package/src/doc/templates/imageWithCaption.ts +22 -9
- package/src/doc/templates/index.ts +215 -46
- package/src/doc/templates/layoutBlock.ts +43 -0
- package/src/doc/templates/layoutLayout.ts +243 -0
- package/src/doc/templates/listBlock.ts +56 -30
- package/src/doc/templates/mapBlock.ts +15 -12
- package/src/doc/templates/metadata.ts +120 -0
- package/src/doc/templates/persistentLayers.ts +129 -11
- package/src/doc/templates/photoGrid.ts +23 -12
- package/src/doc/templates/pullQuote.ts +30 -12
- package/src/doc/templates/quoteBlock.ts +54 -29
- package/src/doc/templates/sectionHeader.ts +25 -11
- package/src/doc/templates/statHighlight.ts +39 -22
- package/src/doc/templates/titleBlock.ts +59 -21
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +13 -8
- package/src/doc/utils/animationUtils.ts +267 -3
- package/src/doc/utils/applyRenderStyle.ts +113 -0
- package/src/doc/utils/imageTreatment.ts +70 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +476 -0
- package/src/generate/slideshowGenerator.ts +7 -0
- package/src/icons/index.ts +8 -0
- package/src/icons/inlineIconMarker.ts +73 -0
- package/src/imageEdit/export.ts +85 -2
- package/src/imageEdit/index.ts +1 -0
- package/src/imageEdit/persistence.ts +51 -0
- package/src/index.ts +2 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +222 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +36 -0
- package/src/markdown/sanitize.ts +298 -0
- package/src/markdown/stringify.ts +84 -3
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +49 -1
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +74 -0
- package/src/schemas/Doc.ts +353 -29
- package/src/schemas/ImageEditDoc.ts +11 -4
- package/src/schemas/Media.ts +169 -0
- package/src/schemas/Theme.ts +23 -1
- package/src/schemas/Transitions.ts +224 -0
- package/src/schemas/colorUtils.ts +33 -0
- package/src/schemas/fontStacks.ts +5 -2
- package/src/schemas/index.ts +3 -0
- package/src/schemas/themeCompile.ts +23 -4
- package/src/schemas/themeValidator.ts +49 -11
- package/src/schemas/themes/cinematic.json +69 -12
- package/src/schemas/themes/documentary.json +58 -11
- package/src/schemas/themes/gezellig.json +60 -11
- package/src/schemas/themes/magazine.json +54 -9
- package/src/schemas/themes/morning-light.json +52 -9
- package/src/schemas/themes/standard-dark.json +48 -13
- package/src/schemas/themes/standard.json +44 -12
- package/src/schemas/themes/tech-dark.json +65 -12
- package/src/spatial/Geohash.ts +52 -21
- package/src/transform/applyTransform.ts +43 -4
- package/src/transform/index.ts +1 -0
- package/src/transform/registry.ts +44 -13
- package/src/transform/styles/dataDriven.ts +2 -1
- package/src/transform/styles/documentary.ts +2 -1
- package/src/transform/styles/magazine.ts +3 -1
- package/src/transform/styles/minimal.ts +2 -1
- package/src/transform/styles/narrative.ts +3 -1
- package/src/transform/templateSelector.ts +87 -11
- package/src/transform/types.ts +35 -2
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-57CVQCFX.js.map +0 -1
- package/dist/chunk-5WFRKURX.js.map +0 -1
- package/dist/chunk-7UDSRZKG.js.map +0 -1
- package/dist/chunk-B4EEON3N.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
package/src/doc/markdownToDoc.ts
CHANGED
|
@@ -22,7 +22,16 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
Doc,
|
|
27
|
+
Block,
|
|
28
|
+
CaptionTrack,
|
|
29
|
+
CaptionPhrase,
|
|
30
|
+
StartBlockConfig,
|
|
31
|
+
DocDiagnostic,
|
|
32
|
+
} from '../schemas/Doc.js';
|
|
33
|
+
import { readCustomTemplatesFromFrontmatter } from './customTemplatesFrontmatter.js';
|
|
34
|
+
import { readCustomThemesFromFrontmatter } from './customThemesFrontmatter.js';
|
|
26
35
|
import type {
|
|
27
36
|
MarkdownDocument,
|
|
28
37
|
MarkdownBlockNode,
|
|
@@ -31,8 +40,14 @@ import type {
|
|
|
31
40
|
HtmlNode,
|
|
32
41
|
} from '../markdown/types.js';
|
|
33
42
|
import { extractPlainText } from '../markdown/utils.js';
|
|
43
|
+
import { coerceAnnotationValues, type CoercedBlockMeta } from '../markdown/annotationCoercion.js';
|
|
34
44
|
import { estimateReadingTime } from '../timing/readingTime.js';
|
|
35
|
-
import { resolveTemplateName } from './templates/index.js';
|
|
45
|
+
import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
|
|
46
|
+
import { isDataFence, parseDataFence, findFirstTable, extractTableData } from './structuredData.js';
|
|
47
|
+
import { extractMediaFromContents } from './mediaAnnotations.js';
|
|
48
|
+
import { profileBlockContents, pickAutoTemplate } from '../recommend/templates.js';
|
|
49
|
+
import { deriveTemplateInputs } from './templateInputs.js';
|
|
50
|
+
import type { MediaClip } from '../schemas/Media.js';
|
|
36
51
|
|
|
37
52
|
// ============================================
|
|
38
53
|
// Options
|
|
@@ -61,6 +76,25 @@ export interface MarkdownToDocOptions {
|
|
|
61
76
|
* is used as the hero. Set to false to suppress automatic cover generation.
|
|
62
77
|
*/
|
|
63
78
|
generateCoverBlock?: boolean;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Timestamp recorded as `captions.generatedAt`. When omitted, the field
|
|
82
|
+
* is left unset so that conversion is fully deterministic — the same
|
|
83
|
+
* markdown always converts to an identical Doc (important for snapshot
|
|
84
|
+
* tests, caching, and agents that verify their output by re-converting).
|
|
85
|
+
*/
|
|
86
|
+
captionsGeneratedAt?: string;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Content-aware template auto-picking for unannotated headings
|
|
90
|
+
* (default: true). When a heading block carries a strong content signal
|
|
91
|
+
* — a table, images, a blockquote, a list, a stat-looking line — the
|
|
92
|
+
* matching template is applied (with inputs derived from the body)
|
|
93
|
+
* instead of the structural `defaultTemplate`. Explicit `{[template]}`
|
|
94
|
+
* annotations always win. Authors can also disable per document with
|
|
95
|
+
* frontmatter `squisq-auto-templates: false`.
|
|
96
|
+
*/
|
|
97
|
+
autoTemplates?: boolean;
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
// ============================================
|
|
@@ -90,12 +124,14 @@ function slugify(text: string): string {
|
|
|
90
124
|
|
|
91
125
|
/**
|
|
92
126
|
* Creates an ID generator that produces unique slugified IDs.
|
|
93
|
-
* Appends -2, -3, etc. for duplicate headings.
|
|
127
|
+
* Appends -2, -3, etc. for duplicate headings. `reserve()` registers an
|
|
128
|
+
* author-pinned `{#id}` so a later heading whose slug matches doesn't
|
|
129
|
+
* collide with it.
|
|
94
130
|
*/
|
|
95
131
|
function createIdGenerator() {
|
|
96
132
|
const used = new Map<string, number>();
|
|
97
133
|
|
|
98
|
-
|
|
134
|
+
const generate = (heading: MarkdownHeading, _index: number): string => {
|
|
99
135
|
const text = extractPlainText(heading);
|
|
100
136
|
const base = slugify(text);
|
|
101
137
|
const count = used.get(base) ?? 0;
|
|
@@ -104,6 +140,12 @@ function createIdGenerator() {
|
|
|
104
140
|
if (count === 0) return base;
|
|
105
141
|
return `${base}-${count + 1}`;
|
|
106
142
|
};
|
|
143
|
+
|
|
144
|
+
generate.reserve = (id: string): void => {
|
|
145
|
+
used.set(id, Math.max(used.get(id) ?? 0, 1));
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return generate;
|
|
107
149
|
}
|
|
108
150
|
|
|
109
151
|
// ============================================
|
|
@@ -125,13 +167,44 @@ function createIdGenerator() {
|
|
|
125
167
|
* @param options - Conversion options
|
|
126
168
|
* @returns A Doc whose blocks mirror the markdown heading structure
|
|
127
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* Apply coerced block-meta values onto a block's typed fields. Only keys
|
|
172
|
+
* actually present are written, so a later caller (the Pandoc attribute
|
|
173
|
+
* block) can override values supplied by the squiggly `{[…]}` annotation.
|
|
174
|
+
*/
|
|
175
|
+
function applyBlockMeta(block: Block, m: CoercedBlockMeta): void {
|
|
176
|
+
if (m.x != null) block.x = m.x;
|
|
177
|
+
if (m.y != null) block.y = m.y;
|
|
178
|
+
if (m.startTime != null) block.startTime = m.startTime;
|
|
179
|
+
if (m.duration != null) block.duration = m.duration;
|
|
180
|
+
if (m.connectsTo) block.connectsTo = m.connectsTo;
|
|
181
|
+
if (m.transition) block.transition = m.transition;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The block-meta an author pinned on a heading, drawn from either the
|
|
186
|
+
* Pandoc `{key=value}` block or the squiggly `{[key=value]}` annotation
|
|
187
|
+
* (Pandoc wins on conflict — same precedence as {@link makeBlock}). Used by
|
|
188
|
+
* the timing passes to tell "author pinned this" from "derive it".
|
|
189
|
+
*/
|
|
190
|
+
function pinnedHeadingMeta(heading: MarkdownHeading | undefined): CoercedBlockMeta {
|
|
191
|
+
if (!heading) return {};
|
|
192
|
+
const fromAnnotation = heading.templateAnnotation?.params
|
|
193
|
+
? coerceAnnotationValues(heading.templateAnnotation.params).blockMeta
|
|
194
|
+
: {};
|
|
195
|
+
const fromPandoc = heading.attributes?.blockMeta ?? {};
|
|
196
|
+
return { ...fromAnnotation, ...fromPandoc };
|
|
197
|
+
}
|
|
198
|
+
|
|
128
199
|
export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
|
|
129
200
|
const articleId = options?.articleId ?? 'markdown-doc';
|
|
130
201
|
const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
|
|
131
202
|
const defaultDuration = options?.defaultDuration ?? 5;
|
|
132
|
-
const
|
|
203
|
+
const idGenerator = options?.generateId ? null : createIdGenerator();
|
|
204
|
+
const generateId = options?.generateId ?? idGenerator!;
|
|
133
205
|
|
|
134
206
|
const rootBlocks: Block[] = [];
|
|
207
|
+
const diagnostics: DocDiagnostic[] = [];
|
|
135
208
|
let headingIndex = 0;
|
|
136
209
|
|
|
137
210
|
// Stack tracks the nesting context: each entry is a block and its heading depth.
|
|
@@ -151,7 +224,14 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
151
224
|
}
|
|
152
225
|
|
|
153
226
|
function makeBlock(heading: MarkdownHeading | null): Block {
|
|
154
|
-
|
|
227
|
+
// Pandoc `{#id}` overrides slug-from-heading when present. Pinned ids
|
|
228
|
+
// are reserved in the slug generator so a later un-pinned heading with
|
|
229
|
+
// the same slugified text doesn't collide.
|
|
230
|
+
const pandocId = heading?.attributes?.id;
|
|
231
|
+
const id = pandocId ? pandocId : heading ? generateId(heading, headingIndex++) : 'preamble';
|
|
232
|
+
if (pandocId && idGenerator) {
|
|
233
|
+
idGenerator.reserve(pandocId);
|
|
234
|
+
}
|
|
155
235
|
|
|
156
236
|
// Use template from annotation if present, otherwise fall back to default.
|
|
157
237
|
// Legacy template ids (e.g. `titleBlock`) are normalized to their canonical
|
|
@@ -175,9 +255,29 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
175
255
|
...(title ? { title } : {}),
|
|
176
256
|
};
|
|
177
257
|
|
|
178
|
-
//
|
|
258
|
+
// {[…]} template params → templateOverrides (template-specific overrides).
|
|
259
|
+
// Known block-meta keys (duration, startTime, x, y, connectsTo) are *also*
|
|
260
|
+
// honored in the squiggly form, so an author — or the timeline editor —
|
|
261
|
+
// can write `{[duration=8]}` instead of the Pandoc `{duration=8}` block.
|
|
262
|
+
// The raw params still flow to `templateOverrides` for round-tripping; the
|
|
263
|
+
// Pandoc attribute block applied below wins when a key appears in both.
|
|
179
264
|
if (annotation?.params) {
|
|
180
265
|
block.templateOverrides = annotation.params;
|
|
266
|
+
applyBlockMeta(block, coerceAnnotationValues(annotation.params).blockMeta);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Pandoc {#id .class key=value} attributes → block-level fields.
|
|
270
|
+
const attrs = heading?.attributes;
|
|
271
|
+
if (attrs) {
|
|
272
|
+
if (attrs.blockMeta) {
|
|
273
|
+
applyBlockMeta(block, attrs.blockMeta);
|
|
274
|
+
}
|
|
275
|
+
if (attrs.classes && attrs.classes.length > 0) {
|
|
276
|
+
block.classes = attrs.classes;
|
|
277
|
+
}
|
|
278
|
+
if (attrs.metadata && Object.keys(attrs.metadata).length > 0) {
|
|
279
|
+
block.metadata = attrs.metadata;
|
|
280
|
+
}
|
|
181
281
|
}
|
|
182
282
|
|
|
183
283
|
return block;
|
|
@@ -242,13 +342,100 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
242
342
|
rootBlocks.push(currentBlock);
|
|
243
343
|
}
|
|
244
344
|
|
|
245
|
-
//
|
|
345
|
+
// Lift standalone body-level media annotations (`{[audio …]}` / `{[video …]}`)
|
|
346
|
+
// into typed clips: block-anchored clips ride on `block.media`; clips flagged
|
|
347
|
+
// `anchor=document` collect into the doc-level `documentMedia`. Done before
|
|
348
|
+
// the reading-time pass so annotation text doesn't inflate block durations.
|
|
349
|
+
const documentMedia: MediaClip[] = [];
|
|
350
|
+
for (const block of flattenBlocks(rootBlocks)) {
|
|
351
|
+
const {
|
|
352
|
+
media,
|
|
353
|
+
documentMedia: docMedia,
|
|
354
|
+
remaining,
|
|
355
|
+
} = extractMediaFromContents(
|
|
356
|
+
block.contents,
|
|
357
|
+
(src, i) => `${block.id}-media-${i}-${src.replace(/[^a-zA-Z0-9]+/g, '-')}`,
|
|
358
|
+
);
|
|
359
|
+
if (media.length > 0) block.media = media;
|
|
360
|
+
if (docMedia.length > 0) documentMedia.push(...docMedia);
|
|
361
|
+
if (block.contents) block.contents = remaining;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// A preamble block that held only document-media annotations is now empty —
|
|
365
|
+
// drop it so a doc-spanning narration doesn't leave a phantom 5s block at the
|
|
366
|
+
// top of the timeline. Only the heading-less preamble (rootBlocks[0]) can be
|
|
367
|
+
// emptied this way.
|
|
368
|
+
if (
|
|
369
|
+
rootBlocks.length > 0 &&
|
|
370
|
+
!rootBlocks[0].sourceHeading &&
|
|
371
|
+
(rootBlocks[0].contents?.length ?? 0) === 0 &&
|
|
372
|
+
(rootBlocks[0].children?.length ?? 0) === 0 &&
|
|
373
|
+
!rootBlocks[0].media
|
|
374
|
+
) {
|
|
375
|
+
rootBlocks.shift();
|
|
376
|
+
}
|
|
377
|
+
|
|
246
378
|
const allBlocks = flattenBlocks(rootBlocks);
|
|
379
|
+
|
|
380
|
+
// Structured template data: ```json data / ```yaml data fences in a
|
|
381
|
+
// block's body parse into `templateData`; for dataTable blocks the first
|
|
382
|
+
// GFM table supplies headers/rows when not explicitly provided. Parse
|
|
383
|
+
// failures degrade gracefully (the fence stays visible as code) and are
|
|
384
|
+
// recorded as diagnostics.
|
|
385
|
+
for (const block of allBlocks) {
|
|
386
|
+
applyStructuredData(block, diagnostics);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Content-aware template auto-pick (default on): unannotated heading
|
|
390
|
+
// blocks whose body carries a strong signal get the matching template
|
|
391
|
+
// plus inputs derived from that body. Strict derivation — when the
|
|
392
|
+
// essential input can't be built (e.g. feature without an image src),
|
|
393
|
+
// the block keeps the structural default.
|
|
394
|
+
if ((options?.autoTemplates ?? true) && !frontmatterDisablesAutoTemplates(markdownDoc)) {
|
|
395
|
+
applyAutoTemplates(rootBlocks, resolveTemplateName(defaultTemplate), { featureIndex: 0 });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Duplicate ids make connections and navigation ambiguous. Generated
|
|
399
|
+
// slugs are already deduped; this catches author-pinned `{#id}` clashes.
|
|
400
|
+
const seenIds = new Map<string, Block>();
|
|
401
|
+
for (const block of allBlocks) {
|
|
402
|
+
if (seenIds.has(block.id)) {
|
|
403
|
+
diagnostics.push({
|
|
404
|
+
severity: 'error',
|
|
405
|
+
code: 'duplicate-id',
|
|
406
|
+
message: `Duplicate block id "${block.id}" — later connections and links are ambiguous`,
|
|
407
|
+
blockId: block.id,
|
|
408
|
+
...lineOf(block),
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
seenIds.set(block.id, block);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// connectsTo targets must resolve to a block id in this doc.
|
|
416
|
+
for (const block of allBlocks) {
|
|
417
|
+
for (const conn of block.connectsTo ?? []) {
|
|
418
|
+
if (!seenIds.has(conn.target)) {
|
|
419
|
+
diagnostics.push({
|
|
420
|
+
severity: 'warning',
|
|
421
|
+
code: 'unresolved-connection',
|
|
422
|
+
message: `Block "${block.id}" connects to unknown target "${conn.target}"`,
|
|
423
|
+
blockId: block.id,
|
|
424
|
+
...lineOf(block),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Calculate reading-time-based durations and generate captions
|
|
247
431
|
const minDuration = 3; // seconds — minimum for blocks with little/no text
|
|
248
432
|
const phrases: CaptionPhrase[] = [];
|
|
249
433
|
|
|
250
|
-
// First pass: compute duration from body-content reading time
|
|
434
|
+
// First pass: compute duration from body-content reading time.
|
|
435
|
+
// Skip blocks that pinned an explicit duration via a heading attribute —
|
|
436
|
+
// author intent wins over reading-time heuristics.
|
|
251
437
|
for (const block of allBlocks) {
|
|
438
|
+
if (pinnedHeadingMeta(block.sourceHeading).duration != null) continue;
|
|
252
439
|
const bodyText = getBlockBodyText(block);
|
|
253
440
|
if (bodyText.length > 0) {
|
|
254
441
|
const estimate = estimateReadingTime(bodyText);
|
|
@@ -258,10 +445,16 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
258
445
|
}
|
|
259
446
|
}
|
|
260
447
|
|
|
261
|
-
// Second pass: assign start times sequentially and build caption phrases
|
|
448
|
+
// Second pass: assign start times sequentially and build caption phrases.
|
|
449
|
+
// Blocks with an explicit `startTime` attribute keep their pinned value;
|
|
450
|
+
// the running cursor still advances by their duration so following blocks
|
|
451
|
+
// sequence after them.
|
|
262
452
|
let currentTime = 0;
|
|
263
453
|
for (const block of allBlocks) {
|
|
264
|
-
block.startTime
|
|
454
|
+
const explicitStart = pinnedHeadingMeta(block.sourceHeading).startTime;
|
|
455
|
+
if (explicitStart == null) {
|
|
456
|
+
block.startTime = currentTime;
|
|
457
|
+
}
|
|
265
458
|
|
|
266
459
|
// Generate caption phrases from the block's body content
|
|
267
460
|
const bodyText = getBlockBodyText(block);
|
|
@@ -283,9 +476,20 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
283
476
|
currentTime += block.duration;
|
|
284
477
|
}
|
|
285
478
|
|
|
479
|
+
// `generatedAt` is only stamped when the caller provides a timestamp —
|
|
480
|
+
// conversion itself never reads the clock, so identical markdown always
|
|
481
|
+
// converts to an identical Doc.
|
|
286
482
|
const captions: CaptionTrack | undefined =
|
|
287
|
-
phrases.length > 0
|
|
483
|
+
phrases.length > 0
|
|
484
|
+
? {
|
|
485
|
+
phrases,
|
|
486
|
+
...(options?.captionsGeneratedAt ? { generatedAt: options.captionsGeneratedAt } : {}),
|
|
487
|
+
version: 1,
|
|
488
|
+
}
|
|
489
|
+
: undefined;
|
|
288
490
|
|
|
491
|
+
const customTemplates = readCustomTemplatesFromFrontmatter(markdownDoc.frontmatter);
|
|
492
|
+
const customThemes = readCustomThemesFromFrontmatter(markdownDoc.frontmatter);
|
|
289
493
|
const doc: Doc = {
|
|
290
494
|
articleId,
|
|
291
495
|
duration: currentTime,
|
|
@@ -295,6 +499,10 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
295
499
|
},
|
|
296
500
|
...(captions ? { captions } : {}),
|
|
297
501
|
...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
|
|
502
|
+
...(customTemplates ? { customTemplates } : {}),
|
|
503
|
+
...(customThemes ? { customThemes } : {}),
|
|
504
|
+
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
505
|
+
...(documentMedia.length > 0 ? { documentMedia } : {}),
|
|
298
506
|
};
|
|
299
507
|
|
|
300
508
|
// Auto-generate cover startBlock from the first H1 heading
|
|
@@ -327,6 +535,29 @@ export function flattenBlocks(blocks: Block[]): Block[] {
|
|
|
327
535
|
return result;
|
|
328
536
|
}
|
|
329
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Flatten the block tree into the list of *independently renderable*
|
|
540
|
+
* blocks — like {@link flattenBlocks}, but it does NOT descend into the
|
|
541
|
+
* children of a container template (`diagram`, `drawing`; see
|
|
542
|
+
* `isContainerTemplate`). Those children are consumed by the parent's
|
|
543
|
+
* render (as nodes / shapes), so they must not also surface as their own
|
|
544
|
+
* slides or sections.
|
|
545
|
+
*
|
|
546
|
+
* Use this anywhere a doc is flattened for rendering (slideshow, video,
|
|
547
|
+
* static pages). Use {@link flattenBlocks} when you genuinely need every
|
|
548
|
+
* block regardless of role — validation, timing, duplicate-id checks.
|
|
549
|
+
*/
|
|
550
|
+
export function flattenRenderableBlocks(blocks: Block[]): Block[] {
|
|
551
|
+
const result: Block[] = [];
|
|
552
|
+
for (const block of blocks) {
|
|
553
|
+
result.push(block);
|
|
554
|
+
if (block.children && !isContainerTemplate(block.template)) {
|
|
555
|
+
result.push(...flattenRenderableBlocks(block.children));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
|
|
330
561
|
/**
|
|
331
562
|
* Count the total number of blocks in a nested tree (including children at all levels).
|
|
332
563
|
*/
|
|
@@ -352,6 +583,96 @@ export function getBlockDepth(block: Block): number {
|
|
|
352
583
|
// Internal helpers
|
|
353
584
|
// ============================================
|
|
354
585
|
|
|
586
|
+
/** Source line of a block's heading, when position info is available. */
|
|
587
|
+
function lineOf(block: Block): { line: number } | Record<string, never> {
|
|
588
|
+
const line = block.sourceHeading?.position?.start.line;
|
|
589
|
+
return line != null ? { line } : {};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Populate `block.templateData` from structured body content:
|
|
594
|
+
* 1. Every ```json data / ```yaml data fence in the body merges its parsed
|
|
595
|
+
* object in (later fences override earlier keys).
|
|
596
|
+
* 2. For `dataTable` blocks, the first GFM table supplies headers/rows/align
|
|
597
|
+
* unless the author already provided them via a fence or `{[…]}` params.
|
|
598
|
+
* Parse failures are recorded on `diagnostics` and skip the fence.
|
|
599
|
+
*/
|
|
600
|
+
function applyStructuredData(block: Block, diagnostics: DocDiagnostic[]): void {
|
|
601
|
+
let data: Record<string, unknown> | undefined;
|
|
602
|
+
|
|
603
|
+
for (const node of block.contents ?? []) {
|
|
604
|
+
if (node.type !== 'code' || !isDataFence(node)) continue;
|
|
605
|
+
const result = parseDataFence(node);
|
|
606
|
+
if (result.error) {
|
|
607
|
+
diagnostics.push({
|
|
608
|
+
severity: 'error',
|
|
609
|
+
code: 'data-fence-parse',
|
|
610
|
+
message: `Data fence in block "${block.id}": ${result.error}`,
|
|
611
|
+
blockId: block.id,
|
|
612
|
+
...(node.position ? { line: node.position.start.line } : lineOf(block)),
|
|
613
|
+
});
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
data = { ...data, ...result.data };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (block.template === 'dataTable') {
|
|
620
|
+
const provided = (key: string) =>
|
|
621
|
+
(data && key in data) || (block.templateOverrides && key in block.templateOverrides);
|
|
622
|
+
if (!provided('headers') && !provided('rows')) {
|
|
623
|
+
const table = findFirstTable(block.contents);
|
|
624
|
+
if (table) {
|
|
625
|
+
data = { ...extractTableData(table), ...data };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (data && Object.keys(data).length > 0) {
|
|
631
|
+
block.templateData = data;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Truthiness of the `squisq-auto-templates` frontmatter kill-switch. */
|
|
636
|
+
function frontmatterDisablesAutoTemplates(markdownDoc: MarkdownDocument): boolean {
|
|
637
|
+
const v = markdownDoc.frontmatter?.['squisq-auto-templates'];
|
|
638
|
+
return v === false || v === 'false' || v === 'off' || v === 'no' || v === 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Walk heading blocks and apply content-aware templates to the ones that
|
|
643
|
+
* still hold the structural default. Children of container templates
|
|
644
|
+
* (diagram/drawing/layout) are consumed by their parent and never
|
|
645
|
+
* re-templated. `state.featureIndex` alternates left/right feature
|
|
646
|
+
* composition across the document.
|
|
647
|
+
*/
|
|
648
|
+
function applyAutoTemplates(
|
|
649
|
+
blocks: Block[],
|
|
650
|
+
resolvedDefault: string,
|
|
651
|
+
state: { featureIndex: number },
|
|
652
|
+
): void {
|
|
653
|
+
for (const block of blocks) {
|
|
654
|
+
const annotated = !!block.sourceHeading?.templateAnnotation?.template;
|
|
655
|
+
if (block.sourceHeading && !annotated && block.template === resolvedDefault) {
|
|
656
|
+
const profile = profileBlockContents(block.contents ?? []);
|
|
657
|
+
const picked = pickAutoTemplate(profile, state.featureIndex);
|
|
658
|
+
if (picked) {
|
|
659
|
+
const inputs = deriveTemplateInputs(picked, block.title ?? '', block.contents);
|
|
660
|
+
if (inputs) {
|
|
661
|
+
if (picked === 'leftFeature' || picked === 'rightFeature') state.featureIndex += 1;
|
|
662
|
+
block.template = picked;
|
|
663
|
+
block.autoTemplate = true;
|
|
664
|
+
// Author-provided structured data (```json data fences) wins
|
|
665
|
+
// over derived inputs.
|
|
666
|
+
block.templateData = { ...inputs, ...block.templateData };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (block.children && block.children.length > 0 && !isContainerTemplate(block.template)) {
|
|
671
|
+
applyAutoTemplates(block.children, resolvedDefault, state);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
355
676
|
/**
|
|
356
677
|
* Extract the plain text from a block's body contents (excluding heading text).
|
|
357
678
|
*/
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media-annotation extraction.
|
|
3
|
+
*
|
|
4
|
+
* Lifts standalone body-level media annotations — `{[audio …]}` / `{[video …]}`
|
|
5
|
+
* — out of a block's markdown contents into typed {@link MediaClip}s. A block
|
|
6
|
+
* can hold several clips (unlike the single heading annotation). An annotation
|
|
7
|
+
* flagged `anchor=document` (or `span=document`) becomes a document-spanning
|
|
8
|
+
* clip instead. The annotation grammar is the shared one used for heading
|
|
9
|
+
* annotations, so quoting/escaping behave identically.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
matchTrailingTemplateAnnotation,
|
|
14
|
+
tokenizeAttrTokens,
|
|
15
|
+
splitKeyValueToken,
|
|
16
|
+
} from '../markdown/attrTokens.js';
|
|
17
|
+
import { parseTimeSeconds } from '../markdown/annotationCoercion.js';
|
|
18
|
+
import type { MarkdownBlockNode } from '../markdown/types.js';
|
|
19
|
+
import type { MediaClip } from '../schemas/Media.js';
|
|
20
|
+
|
|
21
|
+
/** Annotation template names that produce a timed media clip. */
|
|
22
|
+
const MEDIA_TEMPLATES = new Set(['audio', 'video', 'media']);
|
|
23
|
+
|
|
24
|
+
interface ParsedAnnotation {
|
|
25
|
+
template?: string;
|
|
26
|
+
params: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a paragraph node that is *exactly* a `{[…]}` annotation (nothing
|
|
31
|
+
* before or after). Returns the template + params, or null when the node
|
|
32
|
+
* isn't a standalone annotation.
|
|
33
|
+
*/
|
|
34
|
+
function parseStandaloneAnnotation(node: MarkdownBlockNode): ParsedAnnotation | null {
|
|
35
|
+
if (node.type !== 'paragraph') return null;
|
|
36
|
+
const children = node.children ?? [];
|
|
37
|
+
if (children.length !== 1 || children[0].type !== 'text') return null;
|
|
38
|
+
const text = children[0].value.trim();
|
|
39
|
+
const match = matchTrailingTemplateAnnotation(text);
|
|
40
|
+
// index 0 means the whole (trimmed) paragraph is the annotation.
|
|
41
|
+
if (!match || match.index !== 0) return null;
|
|
42
|
+
|
|
43
|
+
const tokens = tokenizeAttrTokens(match.inner.trim());
|
|
44
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
45
|
+
const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
|
|
46
|
+
const startIdx = firstIsParam ? 0 : 1;
|
|
47
|
+
const params: Record<string, string> = {};
|
|
48
|
+
for (let i = startIdx; i < tokens.length; i++) {
|
|
49
|
+
const kv = splitKeyValueToken(tokens[i]);
|
|
50
|
+
if (kv) params[kv.key] = kv.value;
|
|
51
|
+
}
|
|
52
|
+
return { template, params };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function time(raw: string | undefined): number | null {
|
|
56
|
+
if (raw == null) return null;
|
|
57
|
+
return parseTimeSeconds(raw);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a {@link MediaClip} from a parsed media annotation, or null if invalid. */
|
|
61
|
+
function toMediaClip(parsed: ParsedAnnotation, id: string): MediaClip | null {
|
|
62
|
+
const { template, params } = parsed;
|
|
63
|
+
if (!template || !MEDIA_TEMPLATES.has(template)) return null;
|
|
64
|
+
const src = params.src;
|
|
65
|
+
if (!src) return null;
|
|
66
|
+
|
|
67
|
+
const kind: MediaClip['kind'] = template === 'video' ? 'video' : 'audio';
|
|
68
|
+
const anchor: MediaClip['anchor'] =
|
|
69
|
+
params.anchor === 'document' || params.span === 'document' ? 'document' : 'block';
|
|
70
|
+
const clip: MediaClip = { id, src, kind, startAt: time(params.startAt) ?? 0, anchor };
|
|
71
|
+
|
|
72
|
+
const clipStart = time(params.clipStart);
|
|
73
|
+
const clipEnd = time(params.clipEnd);
|
|
74
|
+
if (clipStart != null) clip.clipStart = clipStart;
|
|
75
|
+
if (clipEnd != null) clip.clipEnd = clipEnd;
|
|
76
|
+
// `spillover` or `spillover=true` → true; absent/`false` → omitted.
|
|
77
|
+
if (params.spillover === 'true' || params.spillover === '') clip.spillover = true;
|
|
78
|
+
|
|
79
|
+
return clip;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MediaExtraction {
|
|
83
|
+
/** Block-anchored clips (anchor='block'). */
|
|
84
|
+
media: MediaClip[];
|
|
85
|
+
/** Document-spanning clips (anchor='document'). */
|
|
86
|
+
documentMedia: MediaClip[];
|
|
87
|
+
/** Contents with the lifted media-annotation paragraphs removed. */
|
|
88
|
+
remaining: MarkdownBlockNode[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walk `contents`, lifting every standalone media annotation into a
|
|
93
|
+
* {@link MediaClip}. `makeId(src, index)` produces stable clip ids.
|
|
94
|
+
*/
|
|
95
|
+
export function extractMediaFromContents(
|
|
96
|
+
contents: MarkdownBlockNode[] | undefined,
|
|
97
|
+
makeId: (src: string, index: number) => string,
|
|
98
|
+
): MediaExtraction {
|
|
99
|
+
const media: MediaClip[] = [];
|
|
100
|
+
const documentMedia: MediaClip[] = [];
|
|
101
|
+
const remaining: MarkdownBlockNode[] = [];
|
|
102
|
+
let index = 0;
|
|
103
|
+
|
|
104
|
+
for (const node of contents ?? []) {
|
|
105
|
+
const parsed = parseStandaloneAnnotation(node);
|
|
106
|
+
const clip = parsed ? toMediaClip(parsed, '') : null;
|
|
107
|
+
if (parsed && clip) {
|
|
108
|
+
clip.id = makeId(clip.src, index++);
|
|
109
|
+
const line = node.position?.start.line;
|
|
110
|
+
if (line != null) clip.sourceLine = line;
|
|
111
|
+
(clip.anchor === 'document' ? documentMedia : media).push(clip);
|
|
112
|
+
} else {
|
|
113
|
+
remaining.push(node);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { media, documentMedia, remaining };
|
|
118
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc-scoped theme resolution.
|
|
3
|
+
*
|
|
4
|
+
* The theme analog of how `buildRegistry` resolves custom templates: pure and
|
|
5
|
+
* doc-scoped, with no global state. `resolveThemeForDoc` looks up the active
|
|
6
|
+
* theme id in the doc's own custom themes first, then falls back to the
|
|
7
|
+
* built-in / globally-registered themes via `resolveTheme`.
|
|
8
|
+
*
|
|
9
|
+
* It accepts either a squisq `Doc` (which carries a typed `customThemes`
|
|
10
|
+
* array) or anything with raw `frontmatter` — notably a `MarkdownDocument`,
|
|
11
|
+
* which is the shape every export pipeline pivots through. In the latter case
|
|
12
|
+
* the inline themes are decoded from the `squisq-custom-themes` frontmatter
|
|
13
|
+
* payload. Either way, resolution needs no `registerTheme` call — an inline
|
|
14
|
+
* custom theme ships with the doc, exactly like custom templates.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Theme } from '../schemas/Theme.js';
|
|
18
|
+
import { resolveTheme } from '../schemas/themeLibrary.js';
|
|
19
|
+
import { readFrontmatterThemeId } from '../markdown/utils.js';
|
|
20
|
+
import { readCustomThemesFromFrontmatter } from './customThemesFrontmatter.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The minimal shape `resolveThemeForDoc` needs. Satisfied by both the squisq
|
|
24
|
+
* `Doc` (typed `customThemes` + `themeId`) and a `MarkdownDocument` (raw
|
|
25
|
+
* `frontmatter` carrying the encoded payload).
|
|
26
|
+
*/
|
|
27
|
+
export interface ThemeResolvable {
|
|
28
|
+
themeId?: string;
|
|
29
|
+
customThemes?: Theme[];
|
|
30
|
+
frontmatter?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the Theme a document should render with.
|
|
35
|
+
*
|
|
36
|
+
* @param doc - the document (may carry inline custom themes plus a `themeId`
|
|
37
|
+
* / `squisq-theme` frontmatter selector).
|
|
38
|
+
* @param explicitId - an id that overrides the doc's own selection (e.g. the
|
|
39
|
+
* editor's theme dropdown, or an export `--theme` option). When omitted, the
|
|
40
|
+
* doc's `themeId` / frontmatter theme id is used.
|
|
41
|
+
* @returns the matching inline custom theme (doc-scoped) when present, else
|
|
42
|
+
* the built-in / registered theme for that id, else the default theme.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveThemeForDoc(
|
|
45
|
+
doc: ThemeResolvable | null | undefined,
|
|
46
|
+
explicitId?: string,
|
|
47
|
+
): Theme {
|
|
48
|
+
const id = explicitId ?? doc?.themeId ?? readFrontmatterThemeId(doc?.frontmatter);
|
|
49
|
+
// Prefer the already-parsed list on a squisq Doc; otherwise decode the
|
|
50
|
+
// frontmatter payload (the shape exports see on a MarkdownDocument).
|
|
51
|
+
const inline = doc?.customThemes ?? readCustomThemesFromFrontmatter(doc?.frontmatter);
|
|
52
|
+
const custom = id ? inline?.find((t) => t.id === id) : undefined;
|
|
53
|
+
if (custom) return custom;
|
|
54
|
+
return resolveTheme(id);
|
|
55
|
+
}
|