@bendyline/squisq 1.4.1 → 1.5.1
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/README.md +14 -1
- package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
- package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.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-6ATE2PSM.js +344 -0
- package/dist/chunk-6ATE2PSM.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
- package/dist/chunk-A4O7GIWE.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-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/chunk-HWVFJAAH.js +362 -0
- package/dist/chunk-HWVFJAAH.js.map +1 -0
- package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
- package/dist/chunk-IMSCRLLV.js.map +1 -0
- package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/chunk-LDQ2HJIX.js +596 -0
- package/dist/chunk-LDQ2HJIX.js.map +1 -0
- package/dist/chunk-LH7I6SH7.js +7208 -0
- package/dist/chunk-LH7I6SH7.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-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
- package/dist/chunk-TEEEILMP.js.map +1 -0
- package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
- package/dist/chunk-TQWLI6S2.js.map +1 -0
- package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.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 +5 -5
- package/dist/doc/index.js +135 -8
- package/dist/generate/index.d.ts +3 -74
- package/dist/generate/index.js +2 -7
- 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 +12 -6
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +14 -11
- package/dist/index.js +265 -37
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +228 -3
- package/dist/markdown/index.js +42 -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 +36 -8
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +1021 -41
- package/dist/story/index.js +135 -8
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- 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/dist/versions/index.d.ts +1 -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__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +46 -1
- package/src/__tests__/customTemplateSample.test.ts +144 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +145 -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__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +87 -0
- package/src/__tests__/markdownSanitize.test.ts +79 -0
- package/src/__tests__/markdownToDoc.test.ts +391 -2
- package/src/__tests__/mediaAnnotations.test.ts +91 -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 +249 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +31 -0
- package/src/__tests__/templates.test.ts +257 -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 +260 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +314 -0
- package/src/doc/customThemesFrontmatter.ts +95 -0
- package/src/doc/docToMarkdown.ts +104 -5
- package/src/doc/getLayers.ts +49 -17
- package/src/doc/index.ts +47 -1
- package/src/doc/markdownToDoc.ts +411 -13
- package/src/doc/mediaAnnotations.ts +100 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +301 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +170 -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 +33 -18
- package/src/doc/templates/coverBlock.ts +35 -5
- package/src/doc/templates/customTemplate.ts +51 -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 +54 -26
- 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 +30 -15
- package/src/doc/templates/index.ts +225 -46
- package/src/doc/templates/inputDescriptors.ts +432 -0
- 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 +22 -34
- 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 +487 -0
- package/src/doc/templates/tokens/resolveTokens.ts +453 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +21 -15
- 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/nearest.ts +59 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +575 -0
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- 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/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +241 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +37 -0
- package/src/markdown/sanitize.ts +318 -0
- package/src/markdown/stringify.ts +96 -6
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +111 -2
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +126 -0
- package/src/schemas/Doc.ts +387 -65
- 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 +24 -5
- 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/storage/ContentContainer.ts +6 -2
- 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-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.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-FR2RBTKO.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js +0 -179
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js +0 -145
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -273
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,16 @@ 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 { extractTemplateBlocksFromContents } from './annotationBlocks.js';
|
|
49
|
+
import type { ParsedAnnotation } from './standaloneAnnotation.js';
|
|
50
|
+
import { profileBlockContents, pickAutoTemplate } from '../recommend/templates.js';
|
|
51
|
+
import { deriveTemplateInputs } from './templateInputs.js';
|
|
52
|
+
import type { MediaClip } from '../schemas/Media.js';
|
|
36
53
|
|
|
37
54
|
// ============================================
|
|
38
55
|
// Options
|
|
@@ -61,6 +78,25 @@ export interface MarkdownToDocOptions {
|
|
|
61
78
|
* is used as the hero. Set to false to suppress automatic cover generation.
|
|
62
79
|
*/
|
|
63
80
|
generateCoverBlock?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Timestamp recorded as `captions.generatedAt`. When omitted, the field
|
|
84
|
+
* is left unset so that conversion is fully deterministic — the same
|
|
85
|
+
* markdown always converts to an identical Doc (important for snapshot
|
|
86
|
+
* tests, caching, and agents that verify their output by re-converting).
|
|
87
|
+
*/
|
|
88
|
+
captionsGeneratedAt?: string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Content-aware template auto-picking for unannotated headings
|
|
92
|
+
* (default: true). When a heading block carries a strong content signal
|
|
93
|
+
* — a table, images, a blockquote, a list, a stat-looking line — the
|
|
94
|
+
* matching template is applied (with inputs derived from the body)
|
|
95
|
+
* instead of the structural `defaultTemplate`. Explicit `{[template]}`
|
|
96
|
+
* annotations always win. Authors can also disable per document with
|
|
97
|
+
* frontmatter `squisq-auto-templates: false`.
|
|
98
|
+
*/
|
|
99
|
+
autoTemplates?: boolean;
|
|
64
100
|
}
|
|
65
101
|
|
|
66
102
|
// ============================================
|
|
@@ -90,12 +126,14 @@ function slugify(text: string): string {
|
|
|
90
126
|
|
|
91
127
|
/**
|
|
92
128
|
* Creates an ID generator that produces unique slugified IDs.
|
|
93
|
-
* Appends -2, -3, etc. for duplicate headings.
|
|
129
|
+
* Appends -2, -3, etc. for duplicate headings. `reserve()` registers an
|
|
130
|
+
* author-pinned `{#id}` so a later heading whose slug matches doesn't
|
|
131
|
+
* collide with it.
|
|
94
132
|
*/
|
|
95
133
|
function createIdGenerator() {
|
|
96
134
|
const used = new Map<string, number>();
|
|
97
135
|
|
|
98
|
-
|
|
136
|
+
const generate = (heading: MarkdownHeading, _index: number): string => {
|
|
99
137
|
const text = extractPlainText(heading);
|
|
100
138
|
const base = slugify(text);
|
|
101
139
|
const count = used.get(base) ?? 0;
|
|
@@ -104,6 +142,22 @@ function createIdGenerator() {
|
|
|
104
142
|
if (count === 0) return base;
|
|
105
143
|
return `${base}-${count + 1}`;
|
|
106
144
|
};
|
|
145
|
+
|
|
146
|
+
generate.reserve = (id: string): void => {
|
|
147
|
+
used.set(id, Math.max(used.get(id) ?? 0, 1));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Slug + dedupe from arbitrary text (used for heading-less standalone
|
|
151
|
+
// annotation blocks, which have no MarkdownHeading to slug from). Shares
|
|
152
|
+
// the same `used` map so a standalone id can't collide with a heading id.
|
|
153
|
+
generate.fromText = (text: string): string => {
|
|
154
|
+
const base = slugify(text);
|
|
155
|
+
const count = used.get(base) ?? 0;
|
|
156
|
+
used.set(base, count + 1);
|
|
157
|
+
return count === 0 ? base : `${base}-${count + 1}`;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return generate;
|
|
107
161
|
}
|
|
108
162
|
|
|
109
163
|
// ============================================
|
|
@@ -125,13 +179,85 @@ function createIdGenerator() {
|
|
|
125
179
|
* @param options - Conversion options
|
|
126
180
|
* @returns A Doc whose blocks mirror the markdown heading structure
|
|
127
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* Apply coerced block-meta values onto a block's typed fields. Only keys
|
|
184
|
+
* actually present are written, so a later caller (the Pandoc attribute
|
|
185
|
+
* block) can override values supplied by the squiggly `{[…]}` annotation.
|
|
186
|
+
*/
|
|
187
|
+
function applyBlockMeta(block: Block, m: CoercedBlockMeta): void {
|
|
188
|
+
if (m.x != null) block.x = m.x;
|
|
189
|
+
if (m.y != null) block.y = m.y;
|
|
190
|
+
if (m.startTime != null) block.startTime = m.startTime;
|
|
191
|
+
if (m.duration != null) block.duration = m.duration;
|
|
192
|
+
if (m.connectsTo) block.connectsTo = m.connectsTo;
|
|
193
|
+
if (m.transition) block.transition = m.transition;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The block-meta an author pinned on a heading, drawn from either the
|
|
198
|
+
* Pandoc `{key=value}` block or the squiggly `{[key=value]}` annotation
|
|
199
|
+
* (Pandoc wins on conflict — same precedence as {@link makeBlock}). Used by
|
|
200
|
+
* the timing passes to tell "author pinned this" from "derive it".
|
|
201
|
+
*/
|
|
202
|
+
function pinnedHeadingMeta(heading: MarkdownHeading | undefined): CoercedBlockMeta {
|
|
203
|
+
if (!heading) return {};
|
|
204
|
+
const fromAnnotation = heading.templateAnnotation?.params
|
|
205
|
+
? coerceAnnotationValues(heading.templateAnnotation.params).blockMeta
|
|
206
|
+
: {};
|
|
207
|
+
const fromPandoc = heading.attributes?.blockMeta ?? {};
|
|
208
|
+
return { ...fromAnnotation, ...fromPandoc };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Pinned block-meta for any block: the heading pins ({@link pinnedHeadingMeta})
|
|
213
|
+
* for heading blocks, or the standalone-annotation params for heading-less
|
|
214
|
+
* standalone blocks. Lets a standalone `{[quote duration=8]}` pin timing the
|
|
215
|
+
* same way a heading annotation would.
|
|
216
|
+
*/
|
|
217
|
+
function pinnedMeta(block: Block): CoercedBlockMeta {
|
|
218
|
+
if (block.sourceHeading) return pinnedHeadingMeta(block.sourceHeading);
|
|
219
|
+
if (block.sourceAnnotation?.params) {
|
|
220
|
+
return coerceAnnotationValues(block.sourceAnnotation.params).blockMeta;
|
|
221
|
+
}
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Rebuild a block list, lifting standalone `{[…]}` template annotations out of
|
|
227
|
+
* each block's body into heading-less sibling blocks (recursing into children
|
|
228
|
+
* first). See {@link extractTemplateBlocksFromContents}.
|
|
229
|
+
*/
|
|
230
|
+
function expandStandaloneAnnotations(
|
|
231
|
+
blocks: Block[],
|
|
232
|
+
makeId: (parsed: ParsedAnnotation) => string,
|
|
233
|
+
defaultDuration: number,
|
|
234
|
+
): Block[] {
|
|
235
|
+
const out: Block[] = [];
|
|
236
|
+
for (const block of blocks) {
|
|
237
|
+
if (block.children && block.children.length > 0) {
|
|
238
|
+
block.children = expandStandaloneAnnotations(block.children, makeId, defaultDuration);
|
|
239
|
+
}
|
|
240
|
+
const { leading, blocks: produced } = extractTemplateBlocksFromContents(block.contents, {
|
|
241
|
+
makeId,
|
|
242
|
+
defaultDuration,
|
|
243
|
+
});
|
|
244
|
+
if (produced.length > 0) {
|
|
245
|
+
block.contents = leading;
|
|
246
|
+
}
|
|
247
|
+
out.push(block, ...produced);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
128
252
|
export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
|
|
129
253
|
const articleId = options?.articleId ?? 'markdown-doc';
|
|
130
254
|
const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
|
|
131
255
|
const defaultDuration = options?.defaultDuration ?? 5;
|
|
132
|
-
const
|
|
256
|
+
const idGenerator = options?.generateId ? null : createIdGenerator();
|
|
257
|
+
const generateId = options?.generateId ?? idGenerator!;
|
|
133
258
|
|
|
134
|
-
|
|
259
|
+
let rootBlocks: Block[] = [];
|
|
260
|
+
const diagnostics: DocDiagnostic[] = [];
|
|
135
261
|
let headingIndex = 0;
|
|
136
262
|
|
|
137
263
|
// Stack tracks the nesting context: each entry is a block and its heading depth.
|
|
@@ -151,7 +277,14 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
151
277
|
}
|
|
152
278
|
|
|
153
279
|
function makeBlock(heading: MarkdownHeading | null): Block {
|
|
154
|
-
|
|
280
|
+
// Pandoc `{#id}` overrides slug-from-heading when present. Pinned ids
|
|
281
|
+
// are reserved in the slug generator so a later un-pinned heading with
|
|
282
|
+
// the same slugified text doesn't collide.
|
|
283
|
+
const pandocId = heading?.attributes?.id;
|
|
284
|
+
const id = pandocId ? pandocId : heading ? generateId(heading, headingIndex++) : 'preamble';
|
|
285
|
+
if (pandocId && idGenerator) {
|
|
286
|
+
idGenerator.reserve(pandocId);
|
|
287
|
+
}
|
|
155
288
|
|
|
156
289
|
// Use template from annotation if present, otherwise fall back to default.
|
|
157
290
|
// Legacy template ids (e.g. `titleBlock`) are normalized to their canonical
|
|
@@ -175,9 +308,29 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
175
308
|
...(title ? { title } : {}),
|
|
176
309
|
};
|
|
177
310
|
|
|
178
|
-
//
|
|
311
|
+
// {[…]} template params → templateOverrides (template-specific overrides).
|
|
312
|
+
// Known block-meta keys (duration, startTime, x, y, connectsTo) are *also*
|
|
313
|
+
// honored in the squiggly form, so an author — or the timeline editor —
|
|
314
|
+
// can write `{[duration=8]}` instead of the Pandoc `{duration=8}` block.
|
|
315
|
+
// The raw params still flow to `templateOverrides` for round-tripping; the
|
|
316
|
+
// Pandoc attribute block applied below wins when a key appears in both.
|
|
179
317
|
if (annotation?.params) {
|
|
180
318
|
block.templateOverrides = annotation.params;
|
|
319
|
+
applyBlockMeta(block, coerceAnnotationValues(annotation.params).blockMeta);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Pandoc {#id .class key=value} attributes → block-level fields.
|
|
323
|
+
const attrs = heading?.attributes;
|
|
324
|
+
if (attrs) {
|
|
325
|
+
if (attrs.blockMeta) {
|
|
326
|
+
applyBlockMeta(block, attrs.blockMeta);
|
|
327
|
+
}
|
|
328
|
+
if (attrs.classes && attrs.classes.length > 0) {
|
|
329
|
+
block.classes = attrs.classes;
|
|
330
|
+
}
|
|
331
|
+
if (attrs.metadata && Object.keys(attrs.metadata).length > 0) {
|
|
332
|
+
block.metadata = attrs.metadata;
|
|
333
|
+
}
|
|
181
334
|
}
|
|
182
335
|
|
|
183
336
|
return block;
|
|
@@ -242,13 +395,124 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
242
395
|
rootBlocks.push(currentBlock);
|
|
243
396
|
}
|
|
244
397
|
|
|
245
|
-
//
|
|
398
|
+
// Lift standalone body-level media annotations (`{[audio …]}` / `{[video …]}`)
|
|
399
|
+
// into typed clips: block-anchored clips ride on `block.media`; clips flagged
|
|
400
|
+
// `anchor=document` collect into the doc-level `documentMedia`. Done before
|
|
401
|
+
// the reading-time pass so annotation text doesn't inflate block durations.
|
|
402
|
+
const documentMedia: MediaClip[] = [];
|
|
403
|
+
for (const block of flattenBlocks(rootBlocks)) {
|
|
404
|
+
const {
|
|
405
|
+
media,
|
|
406
|
+
documentMedia: docMedia,
|
|
407
|
+
remaining,
|
|
408
|
+
} = extractMediaFromContents(
|
|
409
|
+
block.contents,
|
|
410
|
+
(src, i) => `${block.id}-media-${i}-${src.replace(/[^a-zA-Z0-9]+/g, '-')}`,
|
|
411
|
+
);
|
|
412
|
+
if (media.length > 0) block.media = media;
|
|
413
|
+
if (docMedia.length > 0) documentMedia.push(...docMedia);
|
|
414
|
+
if (block.contents) block.contents = remaining;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A preamble block that held only document-media annotations is now empty —
|
|
418
|
+
// drop it so a doc-spanning narration doesn't leave a phantom 5s block at the
|
|
419
|
+
// top of the timeline. Only the heading-less preamble (rootBlocks[0]) can be
|
|
420
|
+
// emptied this way.
|
|
421
|
+
if (
|
|
422
|
+
rootBlocks.length > 0 &&
|
|
423
|
+
!rootBlocks[0].sourceHeading &&
|
|
424
|
+
(rootBlocks[0].contents?.length ?? 0) === 0 &&
|
|
425
|
+
(rootBlocks[0].children?.length ?? 0) === 0 &&
|
|
426
|
+
!rootBlocks[0].media
|
|
427
|
+
) {
|
|
428
|
+
rootBlocks.shift();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Standalone `{[templateName …]}` paragraphs (non-media) become heading-less
|
|
432
|
+
// template blocks; the body after each one, up to the next annotation,
|
|
433
|
+
// becomes its `contents`. Done after media extraction (so media names keep
|
|
434
|
+
// their meaning) and before structured-data parsing (so `json data` fences
|
|
435
|
+
// attach to the produced blocks). Produced blocks are inserted as siblings
|
|
436
|
+
// following the block whose body held the annotation.
|
|
437
|
+
const makeStandaloneId = (parsed: ParsedAnnotation): string => {
|
|
438
|
+
const base = parsed.params.title ?? parsed.template ?? 'block';
|
|
439
|
+
return idGenerator ? idGenerator.fromText(base) : slugify(base);
|
|
440
|
+
};
|
|
441
|
+
rootBlocks = expandStandaloneAnnotations(rootBlocks, makeStandaloneId, defaultDuration);
|
|
442
|
+
// A leading preamble whose entire body was consumed into standalone blocks
|
|
443
|
+
// is now empty — drop it so it doesn't leave a phantom slide.
|
|
444
|
+
if (
|
|
445
|
+
rootBlocks.length > 0 &&
|
|
446
|
+
!rootBlocks[0].sourceHeading &&
|
|
447
|
+
!rootBlocks[0].standaloneAnnotation &&
|
|
448
|
+
(rootBlocks[0].contents?.length ?? 0) === 0 &&
|
|
449
|
+
(rootBlocks[0].children?.length ?? 0) === 0 &&
|
|
450
|
+
!rootBlocks[0].media
|
|
451
|
+
) {
|
|
452
|
+
rootBlocks.shift();
|
|
453
|
+
}
|
|
454
|
+
|
|
246
455
|
const allBlocks = flattenBlocks(rootBlocks);
|
|
456
|
+
|
|
457
|
+
// Structured template data: ```json data / ```yaml data fences in a
|
|
458
|
+
// block's body parse into `templateData`; for dataTable blocks the first
|
|
459
|
+
// GFM table supplies headers/rows when not explicitly provided. Parse
|
|
460
|
+
// failures degrade gracefully (the fence stays visible as code) and are
|
|
461
|
+
// recorded as diagnostics.
|
|
462
|
+
for (const block of allBlocks) {
|
|
463
|
+
applyStructuredData(block, diagnostics);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Content-aware template auto-pick (default on): unannotated heading
|
|
467
|
+
// blocks whose body carries a strong signal get the matching template
|
|
468
|
+
// plus inputs derived from that body. Strict derivation — when the
|
|
469
|
+
// essential input can't be built (e.g. feature without an image src),
|
|
470
|
+
// the block keeps the structural default.
|
|
471
|
+
if ((options?.autoTemplates ?? true) && !frontmatterDisablesAutoTemplates(markdownDoc)) {
|
|
472
|
+
applyAutoTemplates(rootBlocks, resolveTemplateName(defaultTemplate), { featureIndex: 0 });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Duplicate ids make connections and navigation ambiguous. Generated
|
|
476
|
+
// slugs are already deduped; this catches author-pinned `{#id}` clashes.
|
|
477
|
+
const seenIds = new Map<string, Block>();
|
|
478
|
+
for (const block of allBlocks) {
|
|
479
|
+
if (seenIds.has(block.id)) {
|
|
480
|
+
diagnostics.push({
|
|
481
|
+
severity: 'error',
|
|
482
|
+
code: 'duplicate-id',
|
|
483
|
+
message: `Duplicate block id "${block.id}" — later connections and links are ambiguous`,
|
|
484
|
+
blockId: block.id,
|
|
485
|
+
...lineOf(block),
|
|
486
|
+
});
|
|
487
|
+
} else {
|
|
488
|
+
seenIds.set(block.id, block);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// connectsTo targets must resolve to a block id in this doc.
|
|
493
|
+
for (const block of allBlocks) {
|
|
494
|
+
for (const conn of block.connectsTo ?? []) {
|
|
495
|
+
if (!seenIds.has(conn.target)) {
|
|
496
|
+
diagnostics.push({
|
|
497
|
+
severity: 'warning',
|
|
498
|
+
code: 'unresolved-connection',
|
|
499
|
+
message: `Block "${block.id}" connects to unknown target "${conn.target}"`,
|
|
500
|
+
blockId: block.id,
|
|
501
|
+
...lineOf(block),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Calculate reading-time-based durations and generate captions
|
|
247
508
|
const minDuration = 3; // seconds — minimum for blocks with little/no text
|
|
248
509
|
const phrases: CaptionPhrase[] = [];
|
|
249
510
|
|
|
250
|
-
// First pass: compute duration from body-content reading time
|
|
511
|
+
// First pass: compute duration from body-content reading time.
|
|
512
|
+
// Skip blocks that pinned an explicit duration via a heading attribute —
|
|
513
|
+
// author intent wins over reading-time heuristics.
|
|
251
514
|
for (const block of allBlocks) {
|
|
515
|
+
if (pinnedMeta(block).duration != null) continue;
|
|
252
516
|
const bodyText = getBlockBodyText(block);
|
|
253
517
|
if (bodyText.length > 0) {
|
|
254
518
|
const estimate = estimateReadingTime(bodyText);
|
|
@@ -258,10 +522,16 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
258
522
|
}
|
|
259
523
|
}
|
|
260
524
|
|
|
261
|
-
// Second pass: assign start times sequentially and build caption phrases
|
|
525
|
+
// Second pass: assign start times sequentially and build caption phrases.
|
|
526
|
+
// Blocks with an explicit `startTime` attribute keep their pinned value;
|
|
527
|
+
// the running cursor still advances by their duration so following blocks
|
|
528
|
+
// sequence after them.
|
|
262
529
|
let currentTime = 0;
|
|
263
530
|
for (const block of allBlocks) {
|
|
264
|
-
block.startTime
|
|
531
|
+
const explicitStart = pinnedMeta(block).startTime;
|
|
532
|
+
if (explicitStart == null) {
|
|
533
|
+
block.startTime = currentTime;
|
|
534
|
+
}
|
|
265
535
|
|
|
266
536
|
// Generate caption phrases from the block's body content
|
|
267
537
|
const bodyText = getBlockBodyText(block);
|
|
@@ -283,9 +553,20 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
283
553
|
currentTime += block.duration;
|
|
284
554
|
}
|
|
285
555
|
|
|
556
|
+
// `generatedAt` is only stamped when the caller provides a timestamp —
|
|
557
|
+
// conversion itself never reads the clock, so identical markdown always
|
|
558
|
+
// converts to an identical Doc.
|
|
286
559
|
const captions: CaptionTrack | undefined =
|
|
287
|
-
phrases.length > 0
|
|
560
|
+
phrases.length > 0
|
|
561
|
+
? {
|
|
562
|
+
phrases,
|
|
563
|
+
...(options?.captionsGeneratedAt ? { generatedAt: options.captionsGeneratedAt } : {}),
|
|
564
|
+
version: 1,
|
|
565
|
+
}
|
|
566
|
+
: undefined;
|
|
288
567
|
|
|
568
|
+
const customTemplates = readCustomTemplatesFromFrontmatter(markdownDoc.frontmatter);
|
|
569
|
+
const customThemes = readCustomThemesFromFrontmatter(markdownDoc.frontmatter);
|
|
289
570
|
const doc: Doc = {
|
|
290
571
|
articleId,
|
|
291
572
|
duration: currentTime,
|
|
@@ -295,6 +576,10 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
295
576
|
},
|
|
296
577
|
...(captions ? { captions } : {}),
|
|
297
578
|
...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
|
|
579
|
+
...(customTemplates ? { customTemplates } : {}),
|
|
580
|
+
...(customThemes ? { customThemes } : {}),
|
|
581
|
+
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
582
|
+
...(documentMedia.length > 0 ? { documentMedia } : {}),
|
|
298
583
|
};
|
|
299
584
|
|
|
300
585
|
// Auto-generate cover startBlock from the first H1 heading
|
|
@@ -327,6 +612,29 @@ export function flattenBlocks(blocks: Block[]): Block[] {
|
|
|
327
612
|
return result;
|
|
328
613
|
}
|
|
329
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Flatten the block tree into the list of *independently renderable*
|
|
617
|
+
* blocks — like {@link flattenBlocks}, but it does NOT descend into the
|
|
618
|
+
* children of a container template (`diagram`, `drawing`; see
|
|
619
|
+
* `isContainerTemplate`). Those children are consumed by the parent's
|
|
620
|
+
* render (as nodes / shapes), so they must not also surface as their own
|
|
621
|
+
* slides or sections.
|
|
622
|
+
*
|
|
623
|
+
* Use this anywhere a doc is flattened for rendering (slideshow, video,
|
|
624
|
+
* static pages). Use {@link flattenBlocks} when you genuinely need every
|
|
625
|
+
* block regardless of role — validation, timing, duplicate-id checks.
|
|
626
|
+
*/
|
|
627
|
+
export function flattenRenderableBlocks(blocks: Block[]): Block[] {
|
|
628
|
+
const result: Block[] = [];
|
|
629
|
+
for (const block of blocks) {
|
|
630
|
+
result.push(block);
|
|
631
|
+
if (block.children && !isContainerTemplate(block.template)) {
|
|
632
|
+
result.push(...flattenRenderableBlocks(block.children));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
|
|
330
638
|
/**
|
|
331
639
|
* Count the total number of blocks in a nested tree (including children at all levels).
|
|
332
640
|
*/
|
|
@@ -352,6 +660,96 @@ export function getBlockDepth(block: Block): number {
|
|
|
352
660
|
// Internal helpers
|
|
353
661
|
// ============================================
|
|
354
662
|
|
|
663
|
+
/** Source line of a block's heading, when position info is available. */
|
|
664
|
+
function lineOf(block: Block): { line: number } | Record<string, never> {
|
|
665
|
+
const line = block.sourceHeading?.position?.start.line;
|
|
666
|
+
return line != null ? { line } : {};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Populate `block.templateData` from structured body content:
|
|
671
|
+
* 1. Every ```json data / ```yaml data fence in the body merges its parsed
|
|
672
|
+
* object in (later fences override earlier keys).
|
|
673
|
+
* 2. For `dataTable` blocks, the first GFM table supplies headers/rows/align
|
|
674
|
+
* unless the author already provided them via a fence or `{[…]}` params.
|
|
675
|
+
* Parse failures are recorded on `diagnostics` and skip the fence.
|
|
676
|
+
*/
|
|
677
|
+
function applyStructuredData(block: Block, diagnostics: DocDiagnostic[]): void {
|
|
678
|
+
let data: Record<string, unknown> | undefined;
|
|
679
|
+
|
|
680
|
+
for (const node of block.contents ?? []) {
|
|
681
|
+
if (node.type !== 'code' || !isDataFence(node)) continue;
|
|
682
|
+
const result = parseDataFence(node);
|
|
683
|
+
if (result.error) {
|
|
684
|
+
diagnostics.push({
|
|
685
|
+
severity: 'error',
|
|
686
|
+
code: 'data-fence-parse',
|
|
687
|
+
message: `Data fence in block "${block.id}": ${result.error}`,
|
|
688
|
+
blockId: block.id,
|
|
689
|
+
...(node.position ? { line: node.position.start.line } : lineOf(block)),
|
|
690
|
+
});
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
data = { ...data, ...result.data };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (block.template === 'dataTable') {
|
|
697
|
+
const provided = (key: string) =>
|
|
698
|
+
(data && key in data) || (block.templateOverrides && key in block.templateOverrides);
|
|
699
|
+
if (!provided('headers') && !provided('rows')) {
|
|
700
|
+
const table = findFirstTable(block.contents);
|
|
701
|
+
if (table) {
|
|
702
|
+
data = { ...extractTableData(table), ...data };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (data && Object.keys(data).length > 0) {
|
|
708
|
+
block.templateData = data;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Truthiness of the `squisq-auto-templates` frontmatter kill-switch. */
|
|
713
|
+
function frontmatterDisablesAutoTemplates(markdownDoc: MarkdownDocument): boolean {
|
|
714
|
+
const v = markdownDoc.frontmatter?.['squisq-auto-templates'];
|
|
715
|
+
return v === false || v === 'false' || v === 'off' || v === 'no' || v === 0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Walk heading blocks and apply content-aware templates to the ones that
|
|
720
|
+
* still hold the structural default. Children of container templates
|
|
721
|
+
* (diagram/drawing/layout) are consumed by their parent and never
|
|
722
|
+
* re-templated. `state.featureIndex` alternates left/right feature
|
|
723
|
+
* composition across the document.
|
|
724
|
+
*/
|
|
725
|
+
function applyAutoTemplates(
|
|
726
|
+
blocks: Block[],
|
|
727
|
+
resolvedDefault: string,
|
|
728
|
+
state: { featureIndex: number },
|
|
729
|
+
): void {
|
|
730
|
+
for (const block of blocks) {
|
|
731
|
+
const annotated = !!block.sourceHeading?.templateAnnotation?.template;
|
|
732
|
+
if (block.sourceHeading && !annotated && block.template === resolvedDefault) {
|
|
733
|
+
const profile = profileBlockContents(block.contents ?? []);
|
|
734
|
+
const picked = pickAutoTemplate(profile, state.featureIndex);
|
|
735
|
+
if (picked) {
|
|
736
|
+
const inputs = deriveTemplateInputs(picked, block.title ?? '', block.contents);
|
|
737
|
+
if (inputs) {
|
|
738
|
+
if (picked === 'leftFeature' || picked === 'rightFeature') state.featureIndex += 1;
|
|
739
|
+
block.template = picked;
|
|
740
|
+
block.autoTemplate = true;
|
|
741
|
+
// Author-provided structured data (```json data fences) wins
|
|
742
|
+
// over derived inputs.
|
|
743
|
+
block.templateData = { ...inputs, ...block.templateData };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (block.children && block.children.length > 0 && !isContainerTemplate(block.template)) {
|
|
748
|
+
applyAutoTemplates(block.children, resolvedDefault, state);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
355
753
|
/**
|
|
356
754
|
* Extract the plain text from a block's body contents (excluding heading text).
|
|
357
755
|
*/
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { parseTimeSeconds } from '../markdown/annotationCoercion.js';
|
|
13
|
+
import type { MarkdownBlockNode } from '../markdown/types.js';
|
|
14
|
+
import type { MediaClip } from '../schemas/Media.js';
|
|
15
|
+
import { parseStandaloneAnnotation, type ParsedAnnotation } from './standaloneAnnotation.js';
|
|
16
|
+
|
|
17
|
+
/** Annotation template names that produce a timed media clip. */
|
|
18
|
+
const MEDIA_TEMPLATES = new Set(['audio', 'video', 'media']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a standalone-annotation template name is a media name
|
|
22
|
+
* (`audio`/`video`/`media`). The standalone template-block extractor uses
|
|
23
|
+
* this to leave media annotations to {@link extractMediaFromContents}.
|
|
24
|
+
*/
|
|
25
|
+
export function isMediaAnnotationName(name: string | undefined): boolean {
|
|
26
|
+
return name != null && MEDIA_TEMPLATES.has(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function time(raw: string | undefined): number | null {
|
|
30
|
+
if (raw == null) return null;
|
|
31
|
+
return parseTimeSeconds(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build a {@link MediaClip} from a parsed media annotation, or null if invalid. */
|
|
35
|
+
function toMediaClip(parsed: ParsedAnnotation, id: string): MediaClip | null {
|
|
36
|
+
const { template, params } = parsed;
|
|
37
|
+
if (!template || !MEDIA_TEMPLATES.has(template)) return null;
|
|
38
|
+
const src = params.src;
|
|
39
|
+
if (!src) return null;
|
|
40
|
+
|
|
41
|
+
const kind: MediaClip['kind'] = template === 'video' ? 'video' : 'audio';
|
|
42
|
+
const anchor: MediaClip['anchor'] =
|
|
43
|
+
params.anchor === 'document' || params.span === 'document' ? 'document' : 'block';
|
|
44
|
+
// `startAt` is canonical; `startTime` is accepted as an alias (it is the
|
|
45
|
+
// heading-attribute spelling, so authors reach for it here too).
|
|
46
|
+
const clip: MediaClip = {
|
|
47
|
+
id,
|
|
48
|
+
src,
|
|
49
|
+
kind,
|
|
50
|
+
startAt: time(params.startAt ?? params.startTime) ?? 0,
|
|
51
|
+
anchor,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const clipStart = time(params.clipStart);
|
|
55
|
+
const clipEnd = time(params.clipEnd);
|
|
56
|
+
if (clipStart != null) clip.clipStart = clipStart;
|
|
57
|
+
if (clipEnd != null) clip.clipEnd = clipEnd;
|
|
58
|
+
// `spillover` or `spillover=true` → true; absent/`false` → omitted.
|
|
59
|
+
if (params.spillover === 'true' || params.spillover === '') clip.spillover = true;
|
|
60
|
+
|
|
61
|
+
return clip;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MediaExtraction {
|
|
65
|
+
/** Block-anchored clips (anchor='block'). */
|
|
66
|
+
media: MediaClip[];
|
|
67
|
+
/** Document-spanning clips (anchor='document'). */
|
|
68
|
+
documentMedia: MediaClip[];
|
|
69
|
+
/** Contents with the lifted media-annotation paragraphs removed. */
|
|
70
|
+
remaining: MarkdownBlockNode[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk `contents`, lifting every standalone media annotation into a
|
|
75
|
+
* {@link MediaClip}. `makeId(src, index)` produces stable clip ids.
|
|
76
|
+
*/
|
|
77
|
+
export function extractMediaFromContents(
|
|
78
|
+
contents: MarkdownBlockNode[] | undefined,
|
|
79
|
+
makeId: (src: string, index: number) => string,
|
|
80
|
+
): MediaExtraction {
|
|
81
|
+
const media: MediaClip[] = [];
|
|
82
|
+
const documentMedia: MediaClip[] = [];
|
|
83
|
+
const remaining: MarkdownBlockNode[] = [];
|
|
84
|
+
let index = 0;
|
|
85
|
+
|
|
86
|
+
for (const node of contents ?? []) {
|
|
87
|
+
const parsed = parseStandaloneAnnotation(node);
|
|
88
|
+
const clip = parsed ? toMediaClip(parsed, '') : null;
|
|
89
|
+
if (parsed && clip) {
|
|
90
|
+
clip.id = makeId(clip.src, index++);
|
|
91
|
+
const line = node.position?.start.line;
|
|
92
|
+
if (line != null) clip.sourceLine = line;
|
|
93
|
+
(clip.anchor === 'document' ? documentMedia : media).push(clip);
|
|
94
|
+
} else {
|
|
95
|
+
remaining.push(node);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { media, documentMedia, remaining };
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|