@bendyline/squisq 1.5.0 → 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-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
- package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
- package/dist/{chunk-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
- package/dist/chunk-6ATE2PSM.js.map +1 -0
- package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
- package/dist/chunk-A4O7GIWE.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
- 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-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
- package/dist/{chunk-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
- package/dist/chunk-LH7I6SH7.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-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
- 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-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +16 -7
- package/dist/generate/index.d.ts +2 -80
- package/dist/generate/index.js +2 -7
- package/dist/imageEdit/index.d.ts +4 -4
- package/dist/index.d.ts +8 -8
- package/dist/index.js +32 -23
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +18 -2
- package/dist/markdown/index.js +5 -3
- package/dist/recommend/index.js +2 -2
- package/dist/schemas/index.d.ts +4 -4
- package/dist/schemas/index.js +3 -5
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +125 -11
- package/dist/story/index.js +16 -7
- package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- package/dist/transform/index.d.ts +2 -2
- package/dist/transform/index.js +3 -3
- package/dist/versions/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +46 -1
- package/src/__tests__/customTemplateSample.test.ts +63 -2
- package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
- package/src/__tests__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/markdown.test.ts +64 -0
- package/src/__tests__/markdownSanitize.test.ts +15 -0
- package/src/__tests__/markdownToDoc.test.ts +168 -0
- package/src/__tests__/mediaAnnotations.test.ts +27 -0
- package/src/__tests__/structuredData.test.ts +73 -3
- package/src/__tests__/templateMetadata.test.ts +11 -1
- package/src/__tests__/templates.test.ts +112 -0
- package/src/__tests__/validateDoc.test.ts +160 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +7 -1
- package/src/doc/customThemesFrontmatter.ts +6 -1
- package/src/doc/docToMarkdown.ts +31 -1
- package/src/doc/getLayers.ts +11 -2
- package/src/doc/index.ts +12 -0
- package/src/doc/markdownToDoc.ts +80 -3
- package/src/doc/mediaAnnotations.ts +15 -33
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +92 -21
- package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
- package/src/doc/templates/comparisonBar.ts +8 -1
- package/src/doc/templates/coverBlock.ts +29 -2
- package/src/doc/templates/customTemplate.ts +10 -4
- package/src/doc/templates/factCard.ts +4 -2
- package/src/doc/templates/imageWithCaption.ts +10 -8
- package/src/doc/templates/index.ts +12 -2
- package/src/doc/templates/inputDescriptors.ts +432 -0
- package/src/doc/templates/photoGrid.ts +16 -39
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/videoWithCaption.ts +15 -14
- package/src/doc/utils/nearest.ts +59 -0
- package/src/doc/validate.ts +153 -54
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- package/src/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +1 -1
- package/src/markdown/attrTokens.ts +19 -0
- package/src/markdown/index.ts +2 -1
- package/src/markdown/sanitize.ts +22 -2
- package/src/markdown/stringify.ts +12 -3
- package/src/markdown/utils.ts +62 -1
- package/src/schemas/CustomTemplates.ts +56 -4
- package/src/schemas/Doc.ts +39 -41
- package/src/schemas/themeCompile.ts +1 -1
- package/src/storage/ContentContainer.ts +6 -2
- package/src/transform/types.ts +1 -1
- package/dist/chunk-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-4AKUQPEE.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.js.map +0 -1
- package/dist/chunk-4ZFRZBU2.js.map +0 -1
- package/dist/chunk-7MQGNR76.js.map +0 -1
- package/dist/chunk-FBKP5CAO.js +0 -145
- package/dist/chunk-FBKP5CAO.js.map +0 -1
- package/dist/chunk-FR2RBTKO.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KZNXZ5SM.js.map +0 -1
- package/dist/chunk-OH43RPTP.js.map +0 -1
- package/dist/chunk-OKJOQXK4.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -280
- /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
- /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
package/src/doc/markdownToDoc.ts
CHANGED
|
@@ -45,6 +45,8 @@ import { estimateReadingTime } from '../timing/readingTime.js';
|
|
|
45
45
|
import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
|
|
46
46
|
import { isDataFence, parseDataFence, findFirstTable, extractTableData } from './structuredData.js';
|
|
47
47
|
import { extractMediaFromContents } from './mediaAnnotations.js';
|
|
48
|
+
import { extractTemplateBlocksFromContents } from './annotationBlocks.js';
|
|
49
|
+
import type { ParsedAnnotation } from './standaloneAnnotation.js';
|
|
48
50
|
import { profileBlockContents, pickAutoTemplate } from '../recommend/templates.js';
|
|
49
51
|
import { deriveTemplateInputs } from './templateInputs.js';
|
|
50
52
|
import type { MediaClip } from '../schemas/Media.js';
|
|
@@ -145,6 +147,16 @@ function createIdGenerator() {
|
|
|
145
147
|
used.set(id, Math.max(used.get(id) ?? 0, 1));
|
|
146
148
|
};
|
|
147
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
|
+
|
|
148
160
|
return generate;
|
|
149
161
|
}
|
|
150
162
|
|
|
@@ -196,6 +208,47 @@ function pinnedHeadingMeta(heading: MarkdownHeading | undefined): CoercedBlockMe
|
|
|
196
208
|
return { ...fromAnnotation, ...fromPandoc };
|
|
197
209
|
}
|
|
198
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
|
+
|
|
199
252
|
export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
|
|
200
253
|
const articleId = options?.articleId ?? 'markdown-doc';
|
|
201
254
|
const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
|
|
@@ -203,7 +256,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
203
256
|
const idGenerator = options?.generateId ? null : createIdGenerator();
|
|
204
257
|
const generateId = options?.generateId ?? idGenerator!;
|
|
205
258
|
|
|
206
|
-
|
|
259
|
+
let rootBlocks: Block[] = [];
|
|
207
260
|
const diagnostics: DocDiagnostic[] = [];
|
|
208
261
|
let headingIndex = 0;
|
|
209
262
|
|
|
@@ -375,6 +428,30 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
375
428
|
rootBlocks.shift();
|
|
376
429
|
}
|
|
377
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
|
+
|
|
378
455
|
const allBlocks = flattenBlocks(rootBlocks);
|
|
379
456
|
|
|
380
457
|
// Structured template data: ```json data / ```yaml data fences in a
|
|
@@ -435,7 +512,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
435
512
|
// Skip blocks that pinned an explicit duration via a heading attribute —
|
|
436
513
|
// author intent wins over reading-time heuristics.
|
|
437
514
|
for (const block of allBlocks) {
|
|
438
|
-
if (
|
|
515
|
+
if (pinnedMeta(block).duration != null) continue;
|
|
439
516
|
const bodyText = getBlockBodyText(block);
|
|
440
517
|
if (bodyText.length > 0) {
|
|
441
518
|
const estimate = estimateReadingTime(bodyText);
|
|
@@ -451,7 +528,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
|
|
|
451
528
|
// sequence after them.
|
|
452
529
|
let currentTime = 0;
|
|
453
530
|
for (const block of allBlocks) {
|
|
454
|
-
const explicitStart =
|
|
531
|
+
const explicitStart = pinnedMeta(block).startTime;
|
|
455
532
|
if (explicitStart == null) {
|
|
456
533
|
block.startTime = currentTime;
|
|
457
534
|
}
|
|
@@ -9,47 +9,21 @@
|
|
|
9
9
|
* annotations, so quoting/escaping behave identically.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
matchTrailingTemplateAnnotation,
|
|
14
|
-
tokenizeAttrTokens,
|
|
15
|
-
splitKeyValueToken,
|
|
16
|
-
} from '../markdown/attrTokens.js';
|
|
17
12
|
import { parseTimeSeconds } from '../markdown/annotationCoercion.js';
|
|
18
13
|
import type { MarkdownBlockNode } from '../markdown/types.js';
|
|
19
14
|
import type { MediaClip } from '../schemas/Media.js';
|
|
15
|
+
import { parseStandaloneAnnotation, type ParsedAnnotation } from './standaloneAnnotation.js';
|
|
20
16
|
|
|
21
17
|
/** Annotation template names that produce a timed media clip. */
|
|
22
18
|
const MEDIA_TEMPLATES = new Set(['audio', 'video', 'media']);
|
|
23
19
|
|
|
24
|
-
interface ParsedAnnotation {
|
|
25
|
-
template?: string;
|
|
26
|
-
params: Record<string, string>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
20
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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}.
|
|
33
24
|
*/
|
|
34
|
-
function
|
|
35
|
-
|
|
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 };
|
|
25
|
+
export function isMediaAnnotationName(name: string | undefined): boolean {
|
|
26
|
+
return name != null && MEDIA_TEMPLATES.has(name);
|
|
53
27
|
}
|
|
54
28
|
|
|
55
29
|
function time(raw: string | undefined): number | null {
|
|
@@ -67,7 +41,15 @@ function toMediaClip(parsed: ParsedAnnotation, id: string): MediaClip | null {
|
|
|
67
41
|
const kind: MediaClip['kind'] = template === 'video' ? 'video' : 'audio';
|
|
68
42
|
const anchor: MediaClip['anchor'] =
|
|
69
43
|
params.anchor === 'document' || params.span === 'document' ? 'document' : 'block';
|
|
70
|
-
|
|
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
|
+
};
|
|
71
53
|
|
|
72
54
|
const clipStart = time(params.clipStart);
|
|
73
55
|
const clipEnd = time(params.clipEnd);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone-paragraph annotation parser.
|
|
3
|
+
*
|
|
4
|
+
* A paragraph whose entire trimmed text is exactly a `{[…]}` annotation
|
|
5
|
+
* (nothing before or after) is a *standalone annotation*. Two features share
|
|
6
|
+
* this shape:
|
|
7
|
+
*
|
|
8
|
+
* - **Media annotations** (`{[audio …]}` / `{[video …]}`) — lifted into typed
|
|
9
|
+
* {@link import('../schemas/Media.js').MediaClip}s by `mediaAnnotations.ts`.
|
|
10
|
+
* - **Standalone template blocks** (`{[quote]}`, `{[statHighlight …]}`, …) —
|
|
11
|
+
* turned into heading-less {@link import('../schemas/Doc.js').Block}s by
|
|
12
|
+
* `annotationBlocks.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Both call the single parser here so the annotation grammar (quoting,
|
|
15
|
+
* escaping, first-token-is-template) stays identical to the heading form.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
matchTrailingTemplateAnnotation,
|
|
20
|
+
tokenizeAttrTokens,
|
|
21
|
+
splitKeyValueToken,
|
|
22
|
+
} from '../markdown/attrTokens.js';
|
|
23
|
+
import type { MarkdownBlockNode } from '../markdown/types.js';
|
|
24
|
+
|
|
25
|
+
/** The template name + params parsed out of a standalone `{[…]}` paragraph. */
|
|
26
|
+
export interface ParsedAnnotation {
|
|
27
|
+
/** First bare token, when the annotation leads with one (e.g. `quote`). */
|
|
28
|
+
template?: string;
|
|
29
|
+
/** Remaining `key=value` tokens, unquoted/unescaped. */
|
|
30
|
+
params: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a paragraph node that is *exactly* a `{[…]}` annotation (nothing
|
|
35
|
+
* before or after). Returns the template + params, or null when the node
|
|
36
|
+
* isn't a standalone annotation (not a paragraph, more than one child, a
|
|
37
|
+
* non-text child such as a resolved inline icon, or trailing text).
|
|
38
|
+
*/
|
|
39
|
+
export function parseStandaloneAnnotation(node: MarkdownBlockNode): ParsedAnnotation | null {
|
|
40
|
+
if (node.type !== 'paragraph') return null;
|
|
41
|
+
const children = node.children ?? [];
|
|
42
|
+
if (children.length !== 1 || children[0].type !== 'text') return null;
|
|
43
|
+
const text = children[0].value.trim();
|
|
44
|
+
const match = matchTrailingTemplateAnnotation(text);
|
|
45
|
+
// index 0 means the whole (trimmed) paragraph is the annotation.
|
|
46
|
+
if (!match || match.index !== 0) return null;
|
|
47
|
+
|
|
48
|
+
const tokens = tokenizeAttrTokens(match.inner.trim());
|
|
49
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
50
|
+
const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
|
|
51
|
+
const startIdx = firstIsParam ? 0 : 1;
|
|
52
|
+
const params: Record<string, string> = {};
|
|
53
|
+
for (let i = startIdx; i < tokens.length; i++) {
|
|
54
|
+
const kv = splitKeyValueToken(tokens[i]);
|
|
55
|
+
if (kv) params[kv.key] = kv.value;
|
|
56
|
+
}
|
|
57
|
+
return { template, params };
|
|
58
|
+
}
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
* didn't provide them explicitly.
|
|
28
28
|
*
|
|
29
29
|
* YAML support is a documented subset (this package has no YAML
|
|
30
|
-
* dependency): top-level `key: scalar`, `key: [inline, array]`,
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* dependency): top-level `key: scalar`, `key: [inline, array]`, `key:`
|
|
31
|
+
* followed by an indented `- item` block sequence (scalars or inline
|
|
32
|
+
* arrays), and `key:` followed by *one level* of indented `subkey: scalar`
|
|
33
|
+
* lines (a nested map, e.g. `center:` + indented `lat:` / `lng:`). Deeper
|
|
34
|
+
* nesting, and mixing list items with mapping keys under one key, are
|
|
35
|
+
* rejected with a clear error — use a `json data` fence for those.
|
|
34
36
|
*/
|
|
35
37
|
|
|
36
38
|
import type {
|
|
@@ -79,17 +81,42 @@ export function parseDataFence(node: MarkdownCodeBlock): DataFenceParseResult {
|
|
|
79
81
|
return { error: 'JSON data fence must contain a top-level object ({"key": …})' };
|
|
80
82
|
}
|
|
81
83
|
return { data: parsed as Record<string, unknown> };
|
|
82
|
-
} catch (err) {
|
|
84
|
+
} catch (err: unknown) {
|
|
83
85
|
return { error: `invalid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
try {
|
|
87
89
|
return { data: parseYamlSubset(node.value) };
|
|
88
|
-
} catch (err) {
|
|
90
|
+
} catch (err: unknown) {
|
|
89
91
|
return { error: `invalid YAML: ${err instanceof Error ? err.message : String(err)}` };
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Return a new contents array with the first data fence's code content
|
|
97
|
+
* replaced by `data` (pretty-printed JSON). The fence node keeps its
|
|
98
|
+
* language and `data` marker; only `value` changes. When no data fence
|
|
99
|
+
* exists, a new ` ```json data ` fence is appended instead.
|
|
100
|
+
*
|
|
101
|
+
* Pure: neither `contents` nor its nodes are mutated. This is the write
|
|
102
|
+
* half of the data-fence channel — editors update `block.templateData`
|
|
103
|
+
* and call this to reflect the change back into `block.contents`.
|
|
104
|
+
*/
|
|
105
|
+
export function replaceDataFence(
|
|
106
|
+
contents: MarkdownBlockNode[],
|
|
107
|
+
data: Record<string, unknown>,
|
|
108
|
+
): MarkdownBlockNode[] {
|
|
109
|
+
const value = JSON.stringify(data, null, 2);
|
|
110
|
+
const index = contents.findIndex((node) => node.type === 'code' && isDataFence(node));
|
|
111
|
+
if (index < 0) {
|
|
112
|
+
const fence: MarkdownCodeBlock = { type: 'code', lang: 'json', meta: 'data', value };
|
|
113
|
+
return [...contents, fence];
|
|
114
|
+
}
|
|
115
|
+
const next = [...contents];
|
|
116
|
+
next[index] = { ...(contents[index] as MarkdownCodeBlock), value };
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
|
|
93
120
|
// ============================================
|
|
94
121
|
// YAML subset parser
|
|
95
122
|
// ============================================
|
|
@@ -153,30 +180,72 @@ function parseInlineValue(raw: string): unknown {
|
|
|
153
180
|
export function parseYamlSubset(src: string): Record<string, unknown> {
|
|
154
181
|
const result: Record<string, unknown> = {};
|
|
155
182
|
const lines = src.split('\n');
|
|
156
|
-
|
|
183
|
+
|
|
184
|
+
// Open block under the most recent `key:` line: whether its children are a
|
|
185
|
+
// block sequence (`- …`) or a nested map (`subkey: …`) is decided by the
|
|
186
|
+
// first indented child; `childIndent` pins the one allowed child depth.
|
|
187
|
+
let pendingKey: string | null = null;
|
|
188
|
+
let pendingKind: 'list' | 'map' | null = null;
|
|
189
|
+
let childIndent = -1;
|
|
157
190
|
|
|
158
191
|
for (let i = 0; i < lines.length; i++) {
|
|
159
192
|
const line = lines[i];
|
|
160
193
|
const trimmed = line.trim();
|
|
161
194
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
162
195
|
|
|
163
|
-
const
|
|
196
|
+
const indent = line.length - line.trimStart().length;
|
|
164
197
|
|
|
165
|
-
if (
|
|
166
|
-
if (!
|
|
167
|
-
throw new Error(`line ${i + 1}:
|
|
198
|
+
if (indent > 0) {
|
|
199
|
+
if (!pendingKey) {
|
|
200
|
+
throw new Error(`line ${i + 1}: unexpected indented line without a preceding "key:" line`);
|
|
201
|
+
}
|
|
202
|
+
if (childIndent < 0) childIndent = indent;
|
|
203
|
+
if (indent > childIndent) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`line ${i + 1}: nesting deeper than one level is not supported — use a \`\`\`json data fence for nested input`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (trimmed.startsWith('- ')) {
|
|
210
|
+
if (pendingKind === 'map') {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`line ${i + 1}: cannot mix list items and mapping keys under "${pendingKey}"`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
pendingKind = 'list';
|
|
216
|
+
(result[pendingKey] as unknown[]).push(parseInlineValue(trimmed.slice(2)));
|
|
217
|
+
continue;
|
|
168
218
|
}
|
|
169
|
-
(result[pendingListKey] as unknown[]).push(parseInlineValue(trimmed.slice(2)));
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
219
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
220
|
+
// Otherwise this is a nested `subkey: value` mapping line.
|
|
221
|
+
if (pendingKind === 'list') {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`line ${i + 1}: cannot mix mapping keys and list items under "${pendingKey}"`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const subColon = trimmed.indexOf(':');
|
|
227
|
+
if (subColon < 1) {
|
|
228
|
+
throw new Error(`line ${i + 1}: expected "key: value"`);
|
|
229
|
+
}
|
|
230
|
+
const subValue = trimmed.slice(subColon + 1).trim();
|
|
231
|
+
if (subValue === '') {
|
|
232
|
+
// A nested `subkey:` opening its own block would be a second level.
|
|
233
|
+
throw new Error(
|
|
234
|
+
`line ${i + 1}: nesting deeper than one level is not supported — use a \`\`\`json data fence for nested input`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (pendingKind !== 'map') {
|
|
238
|
+
pendingKind = 'map';
|
|
239
|
+
result[pendingKey] = {};
|
|
240
|
+
}
|
|
241
|
+
(result[pendingKey] as Record<string, unknown>)[trimmed.slice(0, subColon).trim()] =
|
|
242
|
+
parseInlineValue(subValue);
|
|
243
|
+
continue;
|
|
177
244
|
}
|
|
178
245
|
|
|
179
|
-
|
|
246
|
+
pendingKey = null;
|
|
247
|
+
pendingKind = null;
|
|
248
|
+
childIndent = -1;
|
|
180
249
|
const colonIdx = trimmed.indexOf(':');
|
|
181
250
|
if (colonIdx < 1) {
|
|
182
251
|
throw new Error(`line ${i + 1}: expected "key: value"`);
|
|
@@ -185,9 +254,11 @@ export function parseYamlSubset(src: string): Record<string, unknown> {
|
|
|
185
254
|
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
186
255
|
|
|
187
256
|
if (rawValue === '') {
|
|
188
|
-
// `key:` opens a block
|
|
257
|
+
// `key:` opens a block: an indented `- …` sequence or a `subkey: …`
|
|
258
|
+
// map. Default to an empty list so a `key:` with no children stays
|
|
259
|
+
// back-compatible (`{ key: [] }`); the map branch replaces it wholesale.
|
|
189
260
|
result[key] = [];
|
|
190
|
-
|
|
261
|
+
pendingKey = key;
|
|
191
262
|
} else {
|
|
192
263
|
result[key] = parseInlineValue(rawValue);
|
|
193
264
|
}
|
|
@@ -70,7 +70,7 @@ describe('makeCustomTemplateFn', () => {
|
|
|
70
70
|
expect((layers[0] as TextLayer).content.text).toBe('Hello, World!');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it('
|
|
73
|
+
it('falls back to the input block when context.block is missing', () => {
|
|
74
74
|
const fn = makeCustomTemplateFn(
|
|
75
75
|
defn([
|
|
76
76
|
{
|
|
@@ -82,8 +82,27 @@ describe('makeCustomTemplateFn', () => {
|
|
|
82
82
|
]),
|
|
83
83
|
);
|
|
84
84
|
const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
|
|
85
|
+
// No ctx.block set; the fn resolves against `input` instead. The
|
|
86
|
+
// default input carries no title, so {title} collapses to empty.
|
|
85
87
|
const layers = fn(makeInput(), ctx);
|
|
86
|
-
expect((layers[0] as TextLayer).content.text).toBe('
|
|
88
|
+
expect((layers[0] as TextLayer).content.text).toBe('');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('resolves {attr:key} from the input block when context.block is missing', () => {
|
|
92
|
+
const fn = makeCustomTemplateFn(
|
|
93
|
+
defn([
|
|
94
|
+
{
|
|
95
|
+
id: 't',
|
|
96
|
+
type: 'text',
|
|
97
|
+
position: { x: '0%', y: '0%', width: '100%', height: '10%' },
|
|
98
|
+
content: { text: '{attr:role}', style: { fontSize: 24, color: '#000' } },
|
|
99
|
+
},
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
|
|
103
|
+
const input = { ...makeInput(), templateOverrides: { role: 'lead' } } as RawLayersInput;
|
|
104
|
+
const layers = fn(input, ctx);
|
|
105
|
+
expect((layers[0] as TextLayer).content.text).toBe('lead');
|
|
87
106
|
});
|
|
88
107
|
|
|
89
108
|
it('preserves %-based positions verbatim — they resolve at SSR render time', () => {
|
|
@@ -18,15 +18,22 @@ import {
|
|
|
18
18
|
} from '../utils/themeUtils.js';
|
|
19
19
|
|
|
20
20
|
export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
|
|
21
|
-
const { leftLabel,
|
|
21
|
+
const { leftLabel, rightLabel, unit, colorScheme = 'blue' } = input;
|
|
22
22
|
const { theme, viewport } = context;
|
|
23
23
|
const colors = resolveColorScheme(context, colorScheme);
|
|
24
24
|
|
|
25
|
+
// Defensive: coerce non-finite values to 0. Callers should pass real numbers,
|
|
26
|
+
// but a NaN would otherwise render as literal "NaN" text and collapse every
|
|
27
|
+
// bar/label to a `NaN%` position, cramming the block against its left edge.
|
|
28
|
+
const leftValue = Number.isFinite(input.leftValue) ? input.leftValue : 0;
|
|
29
|
+
const rightValue = Number.isFinite(input.rightValue) ? input.rightValue : 0;
|
|
30
|
+
|
|
25
31
|
const labelFontSize = themedFontSize(28, context, false);
|
|
26
32
|
const valueFontSize = themedFontSize(48, context, true);
|
|
27
33
|
|
|
28
34
|
// Format values for display
|
|
29
35
|
const formatValue = (v: number): string => {
|
|
36
|
+
if (!Number.isFinite(v)) return '—';
|
|
30
37
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
31
38
|
if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}K`;
|
|
32
39
|
return v.toLocaleString();
|
|
@@ -42,6 +42,25 @@ export interface CoverBlockInput {
|
|
|
42
42
|
imageTreatment?: 'none' | 'mono' | 'duotone' | 'warm' | 'cool';
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Base (pre-viewport-scale) font size for a full-size cover title. */
|
|
46
|
+
const COVER_TITLE_BASE_PX = 120;
|
|
47
|
+
/** Title length (chars) that still reads well at the full base size. */
|
|
48
|
+
const COVER_TITLE_COMFORTABLE_CHARS = 26;
|
|
49
|
+
/** Readability floor so very long titles never shrink to nothing. */
|
|
50
|
+
const COVER_TITLE_MIN_BASE_PX = 62;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the base cover-title font size for a given title, shrinking long
|
|
54
|
+
* titles so their wrapped block fits the frame. Returns the pre-viewport-scale
|
|
55
|
+
* value expected by {@link themedFontSize}; short titles get the full base.
|
|
56
|
+
*/
|
|
57
|
+
export function fitCoverTitleSize(title: string): number {
|
|
58
|
+
const len = title.trim().length;
|
|
59
|
+
if (len <= COVER_TITLE_COMFORTABLE_CHARS) return COVER_TITLE_BASE_PX;
|
|
60
|
+
const scaled = COVER_TITLE_BASE_PX * Math.sqrt(COVER_TITLE_COMFORTABLE_CHARS / len);
|
|
61
|
+
return Math.max(COVER_TITLE_MIN_BASE_PX, Math.round(scaled));
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Generate cover block layers from StartBlockConfig.
|
|
47
66
|
*/
|
|
@@ -50,8 +69,16 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
|
|
|
50
69
|
const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
|
|
51
70
|
const { theme, layout } = context;
|
|
52
71
|
|
|
53
|
-
// Scale font sizes for viewport - cover titles are larger than regular title blocks
|
|
54
|
-
|
|
72
|
+
// Scale font sizes for viewport - cover titles are larger than regular title blocks.
|
|
73
|
+
// The cover title wraps within maxTextWidth but does not otherwise shrink, so a long
|
|
74
|
+
// title (e.g. "Seattle: The Emerald City That Rebuilt Itself on Top of Itself") wrapped
|
|
75
|
+
// to three 120px lines overflows the frame and is clipped by the player controls.
|
|
76
|
+
// Auto-fit the base size to title length so long titles stay within the frame. The
|
|
77
|
+
// wrapped title's total height grows with fontSize² × length (line count ∝ length ×
|
|
78
|
+
// fontSize / width, line height ∝ fontSize), so scaling the base by √(threshold/length)
|
|
79
|
+
// keeps the wrapped block's area roughly constant past the threshold. Short titles are
|
|
80
|
+
// unaffected (scale clamps at 1).
|
|
81
|
+
const titleFontSize = themedFontSize(fitCoverTitleSize(title), context, true);
|
|
55
82
|
const subtitleFontSize = themedFontSize(40, context, false);
|
|
56
83
|
|
|
57
84
|
const layers: Layer[] = [];
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* UI and the resolver, not here.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { Layer } from '../../schemas/Doc.js';
|
|
15
|
+
import type { Block, Layer } from '../../schemas/Doc.js';
|
|
16
16
|
import type {
|
|
17
17
|
RawLayersInput,
|
|
18
18
|
TemplateContext,
|
|
@@ -38,8 +38,14 @@ import { resolveTokens } from './tokens/resolveTokens.js';
|
|
|
38
38
|
export function makeCustomTemplateFn(
|
|
39
39
|
def: CustomTemplateDefinition,
|
|
40
40
|
): TemplateFunction<RawLayersInput> {
|
|
41
|
-
return (
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
return (input: RawLayersInput, context: TemplateContext): Layer[] => {
|
|
42
|
+
// `{attr:…}` reads the source block's `templateOverrides` /
|
|
43
|
+
// `templateData` / `metadata`. Prefer `context.block` (the raw
|
|
44
|
+
// block the expander attaches); fall back to `input` (the merged
|
|
45
|
+
// TemplateBlock, itself a Block superset) so attributes still
|
|
46
|
+
// resolve if `context.block` is ever absent.
|
|
47
|
+
const block = context.block ?? (input as unknown as Block | undefined);
|
|
48
|
+
if (!block) return def.layers.slice() as Layer[];
|
|
49
|
+
return resolveTokens(def.layers, block);
|
|
44
50
|
};
|
|
45
51
|
}
|
|
@@ -32,9 +32,11 @@ export function factCard(input: FactCardInput, context: TemplateContext): Layer[
|
|
|
32
32
|
// Get layout adjustments if accent image is present
|
|
33
33
|
const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
|
|
34
34
|
|
|
35
|
-
// Scale font sizes for viewport
|
|
35
|
+
// Scale font sizes for viewport. The explanation sits at ~60% of the fact
|
|
36
|
+
// size — large enough to read comfortably as the secondary line while still
|
|
37
|
+
// clearly subordinate to the headline fact.
|
|
36
38
|
const factFontSize = themedFontSize(56, context, true);
|
|
37
|
-
const explainFontSize = themedFontSize(
|
|
39
|
+
const explainFontSize = themedFontSize(34, context, false);
|
|
38
40
|
const sourceFontSize = themedFontSize(20, context, false);
|
|
39
41
|
|
|
40
42
|
const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 170))];
|
|
@@ -38,7 +38,7 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
|
|
|
38
38
|
const treatment = themedImageTreatment(context, input.imageTreatment);
|
|
39
39
|
|
|
40
40
|
// Scale font sizes for viewport
|
|
41
|
-
const captionFontSize = themedFontSize(
|
|
41
|
+
const captionFontSize = themedFontSize(30, context, false);
|
|
42
42
|
const titleFontSize = themedFontSize(96, context, true);
|
|
43
43
|
const subtitleFontSize = themedFontSize(36, context, false);
|
|
44
44
|
|
|
@@ -140,23 +140,23 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
|
|
|
140
140
|
|
|
141
141
|
// STANDARD MODE: Caption at bottom
|
|
142
142
|
if (caption) {
|
|
143
|
-
//
|
|
143
|
+
// Compact caption band raised above media controls. Long archival captions
|
|
144
|
+
// are clamped so they don't sprawl across the image.
|
|
144
145
|
layers.push({
|
|
145
146
|
type: 'shape',
|
|
146
147
|
id: 'caption-gradient',
|
|
147
148
|
content: {
|
|
148
149
|
shape: 'rect',
|
|
149
|
-
fill: `linear-gradient(
|
|
150
|
+
fill: `linear-gradient(90deg, ${withAlpha(bg, 0)}, ${withAlpha(bg, 0.78)} 16%, ${withAlpha(bg, 0.78)} 84%, ${withAlpha(bg, 0)})`,
|
|
150
151
|
},
|
|
151
152
|
position: {
|
|
152
153
|
x: 0,
|
|
153
|
-
y: '
|
|
154
|
+
y: '68%',
|
|
154
155
|
width: '100%',
|
|
155
|
-
height: '
|
|
156
|
+
height: '16%',
|
|
156
157
|
},
|
|
157
158
|
});
|
|
158
159
|
|
|
159
|
-
// Caption text - raised above media controls
|
|
160
160
|
layers.push({
|
|
161
161
|
type: 'text',
|
|
162
162
|
id: 'caption',
|
|
@@ -168,13 +168,15 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
|
|
|
168
168
|
color: theme.colors.text,
|
|
169
169
|
textAlign: 'center',
|
|
170
170
|
shadow: shouldUseShadow(context),
|
|
171
|
+
lineHeight: 1.18,
|
|
172
|
+
maxLines: 2,
|
|
171
173
|
},
|
|
172
174
|
},
|
|
173
175
|
position: {
|
|
174
176
|
x: '50%',
|
|
175
|
-
y: '
|
|
177
|
+
y: '74%',
|
|
176
178
|
anchor: 'center',
|
|
177
|
-
width:
|
|
179
|
+
width: '78%',
|
|
178
180
|
},
|
|
179
181
|
animation: { type: 'fadeIn', duration: 1.5, delay: 0.5 },
|
|
180
182
|
});
|
|
@@ -27,6 +27,10 @@ import { expandPersistentLayers, wrapWithPersistentLayers } from './persistentLa
|
|
|
27
27
|
import { applyRenderStyleToLayers } from '../utils/applyRenderStyle.js';
|
|
28
28
|
import { resolveBlockTransition } from '../../schemas/Transitions.js';
|
|
29
29
|
import { makeCustomTemplateFn } from './customTemplate.js';
|
|
30
|
+
// Coerces inline `{[…]}` string params into typed template input. inputDescriptors
|
|
31
|
+
// imports `resolveTemplateName` from this module in return; the cycle is benign
|
|
32
|
+
// because every cross-reference lives inside a function body, not module init.
|
|
33
|
+
import { coerceTemplateParams } from './inputDescriptors.js';
|
|
30
34
|
import type { ViewportConfig } from '../../schemas/Viewport.js';
|
|
31
35
|
import { VIEWPORT_PRESETS } from '../../schemas/Viewport.js';
|
|
32
36
|
|
|
@@ -197,11 +201,17 @@ export function expandTemplateBlock(
|
|
|
197
201
|
|
|
198
202
|
// Effective template input: the block's own fields, then structured
|
|
199
203
|
// body data (```json data fences / derived auto-template inputs), then
|
|
200
|
-
// `{[…]}` string overrides — the same merge order getLayers uses.
|
|
204
|
+
// `{[…]}` string overrides — the same merge order getLayers uses. Inline
|
|
205
|
+
// overrides are coerced to their typed shape (e.g. `center="47.6,-122.3"` →
|
|
206
|
+
// `{lat,lng}`); `templateOverrides` itself stays raw for round-tripping.
|
|
201
207
|
const { templateData, templateOverrides } = templateBlock as Block;
|
|
202
208
|
const input =
|
|
203
209
|
templateData || templateOverrides
|
|
204
|
-
? ({
|
|
210
|
+
? ({
|
|
211
|
+
...templateBlock,
|
|
212
|
+
...templateData,
|
|
213
|
+
...coerceTemplateParams(templateBlock.template, templateOverrides ?? {}).input,
|
|
214
|
+
} as TemplateBlock)
|
|
205
215
|
: templateBlock;
|
|
206
216
|
|
|
207
217
|
// Generate layers from template with error handling
|