@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/markdown/index.ts
CHANGED
|
@@ -132,7 +132,7 @@ export type { KnownBlockMetaKey, BlockMetaKeyDescriptor } from './annotationCoer
|
|
|
132
132
|
// HTML sub-DOM utilities
|
|
133
133
|
export { parseHtmlToNodes, stringifyHtmlNodes } from './htmlParse.js';
|
|
134
134
|
export { sanitizeHtmlNodes, sanitizeUrl } from './sanitize.js';
|
|
135
|
-
export type { HtmlPolicy, UrlKind } from './sanitize.js';
|
|
135
|
+
export type { HtmlPolicy, SanitizeUrlOptions, UrlKind } from './sanitize.js';
|
|
136
136
|
|
|
137
137
|
// Tree utilities
|
|
138
138
|
export {
|
|
@@ -143,6 +143,7 @@ export {
|
|
|
143
143
|
countNodes,
|
|
144
144
|
createDocument,
|
|
145
145
|
parseFrontmatter,
|
|
146
|
+
formatBlockScalar,
|
|
146
147
|
setFrontmatterValues,
|
|
147
148
|
inferDocumentTitle,
|
|
148
149
|
readFrontmatterThemeId,
|
package/src/markdown/sanitize.ts
CHANGED
|
@@ -7,6 +7,18 @@ export type UrlKind = 'link' | 'media';
|
|
|
7
7
|
const SAFE_LINK_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
8
8
|
const SAFE_MEDIA_SCHEMES = new Set(['http', 'https', 'blob']);
|
|
9
9
|
|
|
10
|
+
/** Schemes that can execute script — never allowed, even via extraLinkSchemes. */
|
|
11
|
+
const NEVER_ALLOWED_SCHEMES = new Set(['javascript', 'vbscript', 'data']);
|
|
12
|
+
|
|
13
|
+
export interface SanitizeUrlOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Extra lowercase schemes allowed for `kind: 'link'` — the host-app escape
|
|
16
|
+
* hatch for custom link protocols it intercepts itself (e.g. an app-internal
|
|
17
|
+
* navigation scheme). Executable schemes are refused regardless.
|
|
18
|
+
*/
|
|
19
|
+
extraLinkSchemes?: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const SAFE_DATA_MEDIA_RE =
|
|
11
23
|
/^data:(?:image\/(?!svg\+xml)[a-z0-9.+-]+|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+);/i;
|
|
12
24
|
|
|
@@ -135,7 +147,11 @@ const SAFE_TRACK_KINDS = new Set(['subtitles', 'captions', 'descriptions', 'chap
|
|
|
135
147
|
* `blob:` and image/audio/video `data:` URLs because Squisq uses both for
|
|
136
148
|
* browser-local media previews and self-contained exports.
|
|
137
149
|
*/
|
|
138
|
-
export function sanitizeUrl(
|
|
150
|
+
export function sanitizeUrl(
|
|
151
|
+
url: string | null | undefined,
|
|
152
|
+
kind: UrlKind = 'link',
|
|
153
|
+
options?: SanitizeUrlOptions,
|
|
154
|
+
): string | null {
|
|
139
155
|
if (typeof url !== 'string') return null;
|
|
140
156
|
|
|
141
157
|
const trimmed = url.trim();
|
|
@@ -154,7 +170,11 @@ export function sanitizeUrl(url: string | null | undefined, kind: UrlKind = 'lin
|
|
|
154
170
|
return SAFE_MEDIA_SCHEMES.has(scheme) ? trimmed : null;
|
|
155
171
|
}
|
|
156
172
|
|
|
157
|
-
|
|
173
|
+
if (SAFE_LINK_SCHEMES.has(scheme)) return trimmed;
|
|
174
|
+
if (options?.extraLinkSchemes?.includes(scheme) && !NEVER_ALLOWED_SCHEMES.has(scheme)) {
|
|
175
|
+
return trimmed;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
/**
|
|
@@ -12,6 +12,7 @@ import remarkMath from 'remark-math';
|
|
|
12
12
|
import remarkDirective from 'remark-directive';
|
|
13
13
|
import type { MarkdownDocument, StringifyOptions } from './types.js';
|
|
14
14
|
import { toMdast } from './convert.js';
|
|
15
|
+
import { formatBlockScalar } from './utils.js';
|
|
15
16
|
|
|
16
17
|
// Cache the default processor (all extensions, default formatting) to avoid rebuilding on every call.
|
|
17
18
|
let defaultProcessor: any;
|
|
@@ -193,9 +194,17 @@ export function stringifyMarkdown(doc: MarkdownDocument, options?: StringifyOpti
|
|
|
193
194
|
|
|
194
195
|
// Prepend YAML frontmatter if present
|
|
195
196
|
if (doc.frontmatter && Object.keys(doc.frontmatter).length > 0) {
|
|
196
|
-
const yamlLines = Object.entries(doc.frontmatter).map(
|
|
197
|
-
(
|
|
198
|
-
|
|
197
|
+
const yamlLines = Object.entries(doc.frontmatter).map(([k, v]) => {
|
|
198
|
+
if (typeof v === 'string') {
|
|
199
|
+
// Multi-line strings emit as a YAML literal block scalar, matching
|
|
200
|
+
// `formatFrontmatterValue` / `setFrontmatterValues` so both paths
|
|
201
|
+
// agree; single-line strings stay verbatim as before.
|
|
202
|
+
return /[\r\n]/.test(v)
|
|
203
|
+
? `${k}: ${formatBlockScalar(v.replace(/\r\n?/g, '\n'))}`
|
|
204
|
+
: `${k}: ${v}`;
|
|
205
|
+
}
|
|
206
|
+
return `${k}: ${JSON.stringify(v)}`;
|
|
207
|
+
});
|
|
199
208
|
return `---\n${yamlLines.join('\n')}\n---\n\n${cleaned}`;
|
|
200
209
|
}
|
|
201
210
|
|
package/src/markdown/utils.ts
CHANGED
|
@@ -124,8 +124,10 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
|
|
|
124
124
|
if (!yaml || !yaml.trim()) return null;
|
|
125
125
|
|
|
126
126
|
const result: Record<string, unknown> = {};
|
|
127
|
+
const lines = yaml.split('\n');
|
|
127
128
|
|
|
128
|
-
for (
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const line = lines[i];
|
|
129
131
|
const trimmed = line.trim();
|
|
130
132
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
131
133
|
|
|
@@ -135,6 +137,45 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
|
|
|
135
137
|
const key = trimmed.slice(0, colonIdx).trim();
|
|
136
138
|
let value: string | boolean | number = trimmed.slice(colonIdx + 1).trim();
|
|
137
139
|
|
|
140
|
+
// YAML literal block scalar: `key: |` or `key: |-`. Subsequent lines
|
|
141
|
+
// that are more-indented than the key belong to the scalar; they are
|
|
142
|
+
// dedented by their common leading indent and joined with `\n`. A line
|
|
143
|
+
// at the key's indent (or less) ends the scalar. Both `|` and `|-`
|
|
144
|
+
// yield the joined string here (`|-` without a trailing newline); we do
|
|
145
|
+
// not model `|`'s trailing-newline / `+`/`-` chomping nuances beyond
|
|
146
|
+
// stripping trailing blank lines.
|
|
147
|
+
if (value === '|' || value === '|-') {
|
|
148
|
+
const keyIndent = line.length - line.trimStart().length;
|
|
149
|
+
const blockLines: string[] = [];
|
|
150
|
+
let j = i + 1;
|
|
151
|
+
for (; j < lines.length; j++) {
|
|
152
|
+
const bl = lines[j];
|
|
153
|
+
if (bl.trim() === '') {
|
|
154
|
+
blockLines.push('');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const indent = bl.length - bl.trimStart().length;
|
|
158
|
+
if (indent <= keyIndent) break; // dedent ends the scalar
|
|
159
|
+
blockLines.push(bl);
|
|
160
|
+
}
|
|
161
|
+
i = j - 1; // resume the outer walk at the terminating line
|
|
162
|
+
|
|
163
|
+
// Drop trailing blank lines captured inside the scalar.
|
|
164
|
+
while (blockLines.length > 0 && blockLines[blockLines.length - 1] === '') {
|
|
165
|
+
blockLines.pop();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Dedent by the common leading whitespace of the non-blank lines.
|
|
169
|
+
let commonIndent = Infinity;
|
|
170
|
+
for (const bl of blockLines) {
|
|
171
|
+
if (bl === '') continue;
|
|
172
|
+
commonIndent = Math.min(commonIndent, bl.length - bl.trimStart().length);
|
|
173
|
+
}
|
|
174
|
+
if (!isFinite(commonIndent)) commonIndent = 0;
|
|
175
|
+
result[key] = blockLines.map((bl) => (bl === '' ? '' : bl.slice(commonIndent))).join('\n');
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
// Remove surrounding quotes
|
|
139
180
|
if (
|
|
140
181
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
@@ -158,11 +199,31 @@ export function parseFrontmatter(yaml: string): Record<string, unknown> | null {
|
|
|
158
199
|
return Object.keys(result).length > 0 ? result : null;
|
|
159
200
|
}
|
|
160
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Format a multi-line string as a YAML literal block scalar body, i.e.
|
|
204
|
+
* `|-` followed by each source line indented two spaces. Paired with
|
|
205
|
+
* {@link parseFrontmatter}'s block-scalar reader so multi-line frontmatter
|
|
206
|
+
* values (e.g. pretty-printed JSON payloads) round-trip. Blank lines stay
|
|
207
|
+
* blank (no trailing whitespace) so the output diffs cleanly.
|
|
208
|
+
*/
|
|
209
|
+
export function formatBlockScalar(value: string): string {
|
|
210
|
+
const body = value
|
|
211
|
+
.split('\n')
|
|
212
|
+
.map((line) => (line === '' ? '' : ` ${line}`))
|
|
213
|
+
.join('\n');
|
|
214
|
+
return `|-\n${body}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
161
217
|
const FRONTMATTER_BLOCK_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/;
|
|
162
218
|
|
|
163
219
|
/** Quote a frontmatter scalar so it round-trips cleanly through `parseFrontmatter`. */
|
|
164
220
|
function formatFrontmatterValue(value: string | number | boolean): string {
|
|
165
221
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
|
|
222
|
+
// Multi-line strings can't live on one line — emit a YAML literal block
|
|
223
|
+
// scalar (`|-`) that `parseFrontmatter` reads back. This carries pretty-
|
|
224
|
+
// printed JSON payloads (opt-in custom templates/themes). Single-line
|
|
225
|
+
// values keep their exact historical output below.
|
|
226
|
+
if (/[\r\n]/.test(value)) return formatBlockScalar(value.replace(/\r\n?/g, '\n'));
|
|
166
227
|
// A single-line JSON object/array literal round-trips verbatim through
|
|
167
228
|
// the line-based `parseFrontmatter` (no leading quote to strip, no
|
|
168
229
|
// newline to split on), so write it unquoted to keep it human-readable
|
|
@@ -24,6 +24,50 @@
|
|
|
24
24
|
|
|
25
25
|
import type { Layer } from './Doc.js';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Iteration directive for a custom-template layer.
|
|
29
|
+
*
|
|
30
|
+
* A layer that carries a `repeat` is cloned once per item of a source
|
|
31
|
+
* collection at render time (see `resolveTokens`). The clones are laid
|
|
32
|
+
* out in a row or column, offset from the base position by the layer's
|
|
33
|
+
* own width/height plus `gap`, and each clone resolves per-item tokens
|
|
34
|
+
* (`{item}`, `{item:src}`, `{item:label}`, `{index}`) against its item.
|
|
35
|
+
*/
|
|
36
|
+
export interface LayerRepeat {
|
|
37
|
+
/**
|
|
38
|
+
* Which of the source Block's collections to iterate:
|
|
39
|
+
* - `images` — every image in the block body (alt + url)
|
|
40
|
+
* - `children` — child block titles
|
|
41
|
+
* - `listItems` — top-level list-item text in the block body
|
|
42
|
+
*/
|
|
43
|
+
source: 'images' | 'children' | 'listItems';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Layout axis for the clones. `column` (default) stacks them
|
|
47
|
+
* vertically (offsets `position.y`); `row` lays them out horizontally
|
|
48
|
+
* (offsets `position.x`).
|
|
49
|
+
*/
|
|
50
|
+
direction?: 'row' | 'column';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extra spacing added between consecutive clones, in the same unit as
|
|
54
|
+
* the layer's `width`/`height`. Defaults to 24.
|
|
55
|
+
*/
|
|
56
|
+
gap?: number;
|
|
57
|
+
|
|
58
|
+
/** Cap on the number of clones rendered (omit for "all items"). */
|
|
59
|
+
max?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A custom-template layer: a normal render `Layer` that may additionally
|
|
64
|
+
* carry a `repeat` directive. This is a strict superset of the core
|
|
65
|
+
* `Layer` type used only inside `CustomTemplateDefinition.layers`; the
|
|
66
|
+
* resolver strips `repeat` before emitting the final `Layer[]`, so the
|
|
67
|
+
* core `Layer` type is never widened.
|
|
68
|
+
*/
|
|
69
|
+
export type CustomTemplateLayer = Layer & { repeat?: LayerRepeat };
|
|
70
|
+
|
|
27
71
|
/**
|
|
28
72
|
* A single user-defined template. Stored in `Doc.customTemplates`
|
|
29
73
|
* (frontmatter-backed) or in the user's local library.
|
|
@@ -59,12 +103,20 @@ export interface CustomTemplateDefinition {
|
|
|
59
103
|
* `position.width` / `position.height` should be a `%`-string at
|
|
60
104
|
* save time (the designer's normalizePositions step enforces this).
|
|
61
105
|
*
|
|
62
|
-
* Tokens supported
|
|
106
|
+
* Tokens supported (grammar v2):
|
|
63
107
|
* - TextLayer.content.text — `{title}`, `{content}`, `{children}`,
|
|
64
|
-
* `{image:N}` (
|
|
65
|
-
*
|
|
108
|
+
* `{image:N}` (Nth image's alt text), `{attr:key}` (block
|
|
109
|
+
* attribute), plus a pipe default on any token
|
|
110
|
+
* (`{attr:subtitle|Untitled}`).
|
|
111
|
+
* - ImageLayer.content.src — `{image:N}` (the URL) and `{attr:key}`,
|
|
112
|
+
* with pipe defaults (`{image:1|fallback.jpg}`).
|
|
113
|
+
* - Inside a layer with `repeat`, per-item tokens: `{item}`,
|
|
114
|
+
* `{item:src}`, `{item:label}`, `{index}` (1-based).
|
|
115
|
+
*
|
|
116
|
+
* A layer may carry a `repeat` directive (see {@link LayerRepeat}) to
|
|
117
|
+
* be cloned once per item of a source collection.
|
|
66
118
|
*/
|
|
67
|
-
layers:
|
|
119
|
+
layers: CustomTemplateLayer[];
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
/**
|
package/src/schemas/Doc.ts
CHANGED
|
@@ -67,8 +67,9 @@ export interface StartBlockConfig {
|
|
|
67
67
|
export interface DocDiagnostic {
|
|
68
68
|
/** `error` = the author's intent could not be honored (e.g. unparseable
|
|
69
69
|
* data fence); `warning` = something looks wrong but rendering proceeds
|
|
70
|
-
* with a fallback (e.g. unknown template name)
|
|
71
|
-
|
|
70
|
+
* with a fallback (e.g. unknown template name); `info` = nothing is
|
|
71
|
+
* broken, but the author may want to know (e.g. a redundant annotation). */
|
|
72
|
+
severity: 'error' | 'warning' | 'info';
|
|
72
73
|
/** Stable machine-readable code (e.g. `unknown-template`, `duplicate-id`,
|
|
73
74
|
* `unresolved-connection`, `data-fence-parse`, `missing-asset`). */
|
|
74
75
|
code: string;
|
|
@@ -136,9 +137,9 @@ export interface Doc {
|
|
|
136
137
|
*
|
|
137
138
|
* Populated from the markdown frontmatter key
|
|
138
139
|
* `squisq-custom-templates`. The template-expansion pipeline
|
|
139
|
-
* (`expandDocBlocks`) merges these into
|
|
140
|
-
* blocks, so a heading annotated
|
|
141
|
-
* doc-defined template named `myhero`.
|
|
140
|
+
* (`expandDocBlocks` in `doc/templates/index.ts`) merges these into
|
|
141
|
+
* the registry before walking blocks, so a heading annotated
|
|
142
|
+
* `{[myhero]}` resolves against a doc-defined template named `myhero`.
|
|
142
143
|
*
|
|
143
144
|
* Library templates (stored in localStorage on the editor side) are
|
|
144
145
|
* NOT auto-loaded here — applying a library template to a block
|
|
@@ -186,6 +187,16 @@ export interface Doc {
|
|
|
186
187
|
* - `sourceHeading` references the original MarkdownHeading node
|
|
187
188
|
* - `contents` holds the body markdown between this heading and the next
|
|
188
189
|
* - `children` holds sub-heading blocks (e.g., H2s under an H1)
|
|
190
|
+
*
|
|
191
|
+
* Four adjacent fields carry template information — do not confuse them:
|
|
192
|
+
* - `template` — the *resolved* template id this block renders with.
|
|
193
|
+
* - `templateOverrides` — raw `{[…]}` inline params (always strings).
|
|
194
|
+
* - `templateData` — structured inputs from a data fence / GFM table
|
|
195
|
+
* (parsed types, not strings).
|
|
196
|
+
* - `sourceAnnotation.template` — the raw *requested* template name for a
|
|
197
|
+
* standalone-annotation block (before alias resolution).
|
|
198
|
+
* Merge order at render time: template defaults → `templateData` →
|
|
199
|
+
* `templateOverrides`.
|
|
189
200
|
*/
|
|
190
201
|
export interface Block {
|
|
191
202
|
/** Unique identifier for this block.
|
|
@@ -216,7 +227,7 @@ export interface Block {
|
|
|
216
227
|
/** Entry transition from previous block */
|
|
217
228
|
transition?: Transition;
|
|
218
229
|
|
|
219
|
-
/**
|
|
230
|
+
/** Resolved template id this block renders with (see the quartet note above). */
|
|
220
231
|
template?: string;
|
|
221
232
|
|
|
222
233
|
/**
|
|
@@ -227,6 +238,28 @@ export interface Block {
|
|
|
227
238
|
*/
|
|
228
239
|
autoTemplate?: boolean;
|
|
229
240
|
|
|
241
|
+
/**
|
|
242
|
+
* True when this block was produced from a *standalone* `{[templateName …]}`
|
|
243
|
+
* annotation paragraph in another block's body (rather than from a heading).
|
|
244
|
+
* Such blocks have no `sourceHeading`; `docToMarkdown` re-emits their
|
|
245
|
+
* annotation as a synthesized paragraph before their `contents` so the
|
|
246
|
+
* markdown round-trips. See `annotationBlocks.ts`.
|
|
247
|
+
*
|
|
248
|
+
* Presence-marker: only ever `true` (never `false`). Test with
|
|
249
|
+
* `block.standaloneAnnotation` presence, not as a boolean toggle.
|
|
250
|
+
*/
|
|
251
|
+
standaloneAnnotation?: true;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The annotation a standalone block was built from — the raw (un-resolved)
|
|
255
|
+
* template name and its params, in author order. Round-tripped by
|
|
256
|
+
* `docToMarkdown` (via `serializeAnnotation`) and read by validation as the
|
|
257
|
+
* raw requested template name (symmetric with
|
|
258
|
+
* `sourceHeading.templateAnnotation`). Present only when
|
|
259
|
+
* `standaloneAnnotation` is true.
|
|
260
|
+
*/
|
|
261
|
+
sourceAnnotation?: { template?: string; params?: Record<string, string> };
|
|
262
|
+
|
|
230
263
|
/**
|
|
231
264
|
* Display title for template rendering.
|
|
232
265
|
* Extracted from the sourceHeading when the block is created by markdownToDoc().
|
|
@@ -954,38 +987,3 @@ export function getCaptionAtTime(
|
|
|
954
987
|
}
|
|
955
988
|
return null;
|
|
956
989
|
}
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Validate a Doc for completeness.
|
|
960
|
-
*/
|
|
961
|
-
export function validateDoc(script: Doc): string[] {
|
|
962
|
-
const errors: string[] = [];
|
|
963
|
-
|
|
964
|
-
if (!script.articleId) {
|
|
965
|
-
errors.push('Missing articleId');
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
if (!script.blocks || script.blocks.length === 0) {
|
|
969
|
-
errors.push('No blocks defined');
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
if (!script.audio || !script.audio.segments || script.audio.segments.length === 0) {
|
|
973
|
-
errors.push('No audio segments defined');
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// Check for gaps in block coverage
|
|
977
|
-
const totalDuration = script.duration;
|
|
978
|
-
let covered = 0;
|
|
979
|
-
for (const block of script.blocks) {
|
|
980
|
-
if (block.startTime > covered + 0.1) {
|
|
981
|
-
errors.push(`Gap in blocks: ${covered}s to ${block.startTime}s`);
|
|
982
|
-
}
|
|
983
|
-
covered = Math.max(covered, block.startTime + block.duration);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
if (covered < totalDuration - 0.1) {
|
|
987
|
-
errors.push(`Blocks end at ${covered}s but audio is ${totalDuration}s`);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
return errors;
|
|
991
|
-
}
|
|
@@ -206,7 +206,7 @@ export function parseTheme(json: string): Theme {
|
|
|
206
206
|
let parsed: unknown;
|
|
207
207
|
try {
|
|
208
208
|
parsed = JSON.parse(json);
|
|
209
|
-
} catch (err) {
|
|
209
|
+
} catch (err: unknown) {
|
|
210
210
|
const msg = err instanceof Error ? err.message : String(err);
|
|
211
211
|
throw new Error(`Invalid theme JSON: ${msg}`);
|
|
212
212
|
}
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* Paths are forward-slash separated strings relative to the container root
|
|
10
10
|
* (e.g., 'images/hero.jpg', 'index.md'). No leading slash.
|
|
11
11
|
*
|
|
12
|
-
* Implementations:
|
|
12
|
+
* Implementations (in core):
|
|
13
13
|
* - MemoryContentContainer — in-memory Map (for zip import, tests, transient use)
|
|
14
|
-
* -
|
|
14
|
+
* - ScopedContentContainer — a prefix-scoped view onto a parent container
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -44,6 +44,10 @@ export interface ContentContainer {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* List files in the container.
|
|
47
|
+
*
|
|
48
|
+
* Returns file **entries** (`ContentEntry[]` with `.path`/`.mimeType`/`.size`),
|
|
49
|
+
* NOT bare path strings — despite the name. Read `entry.path` for the path;
|
|
50
|
+
* calling string methods (e.g. `.startsWith`) directly on an entry will throw.
|
|
47
51
|
* @param prefix — Optional path prefix to filter by (e.g., 'images/')
|
|
48
52
|
*/
|
|
49
53
|
listFiles(prefix?: string): Promise<ContentEntry[]>;
|
package/src/transform/types.ts
CHANGED
|
@@ -86,7 +86,7 @@ export interface TransformStyleConfig {
|
|
|
86
86
|
};
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Duration-based slide budget (folded in from the
|
|
89
|
+
* Duration-based slide budget (folded in from the since-removed
|
|
90
90
|
* `generateSlideshow`): caps promoted template blocks at roughly
|
|
91
91
|
* `slidesPerMinute` of doc duration (default: uncapped — only
|
|
92
92
|
* `transformRatio` applies).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/storage/MemoryStorageAdapter.ts","../src/storage/LocalStorageAdapter.ts","../src/storage/LocalForageAdapter.ts","../src/storage/ContentContainer.ts","../src/storage/ScopedContentContainer.ts","../src/storage/MediaProviderFromContainer.ts"],"sourcesContent":["/**\n * In-memory storage adapter for testing and SSR environments.\n * Data does not persist across page reloads.\n */\n\nimport type { StorageAdapter } from './Storage.js';\n\nexport class MemoryStorageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private store = new Map<string, string>();\n\n async get<T>(key: string): Promise<T | null> {\n const val = this.store.get(key);\n if (val === undefined) return null;\n try {\n return JSON.parse(val) as T;\n } catch {\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n this.store.set(key, JSON.stringify(value));\n }\n\n async remove(key: string): Promise<void> {\n this.store.delete(key);\n }\n\n async clear(): Promise<void> {\n this.store.clear();\n }\n\n async keys(): Promise<string[]> {\n return Array.from(this.store.keys());\n }\n}\n","/**\n * localStorage adapter for web browsers.\n * Supports a configurable key prefix to namespace stored data.\n */\n\nimport type { StorageAdapter } from './Storage.js';\n\nexport class LocalStorageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private prefix: string;\n\n constructor(prefix = '') {\n this.prefix = prefix;\n }\n\n async get<T>(key: string): Promise<T | null> {\n try {\n const raw = localStorage.getItem(this.prefix + key);\n if (raw === null) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n localStorage.setItem(this.prefix + key, JSON.stringify(value));\n }\n\n async remove(key: string): Promise<void> {\n localStorage.removeItem(this.prefix + key);\n }\n\n async clear(): Promise<void> {\n const keysToRemove: string[] = [];\n for (let i = 0; i < localStorage.length; i++) {\n const k = localStorage.key(i);\n if (k && k.startsWith(this.prefix)) {\n keysToRemove.push(k);\n }\n }\n for (const k of keysToRemove) {\n localStorage.removeItem(k);\n }\n }\n\n async keys(): Promise<string[]> {\n const result: string[] = [];\n for (let i = 0; i < localStorage.length; i++) {\n const k = localStorage.key(i);\n if (k && k.startsWith(this.prefix)) {\n result.push(k.slice(this.prefix.length));\n }\n }\n return result;\n }\n}\n","/**\n * LocalForage adapter - IndexedDB storage with automatic fallbacks.\n *\n * Uses localforage which provides IndexedDB storage with automatic\n * fallbacks to WebSQL and localStorage. This allows storing much more\n * data than raw localStorage (which is limited to ~5MB).\n *\n * Unlike LocalStorageAdapter, this adapter can store binary data\n * (ArrayBuffer, Blob, Uint8Array) natively without JSON serialization.\n *\n * Ported from qualla-internal/shared/services/StorageAdapters.ts with\n * configurable store name and key prefix.\n */\n\nimport localforage from 'localforage';\nimport type { StorageAdapter } from './Storage.js';\n\nexport interface LocalForageAdapterOptions {\n /** IndexedDB database name (default: 'squisq') */\n name?: string;\n /** IndexedDB object store name (default: 'data') */\n storeName?: string;\n /** Key prefix for namespacing (default: '') */\n prefix?: string;\n /** Human-readable description (default: 'Squisq storage') */\n description?: string;\n}\n\nexport class LocalForageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private store: LocalForage;\n private prefix: string;\n\n constructor(options: LocalForageAdapterOptions = {}) {\n const {\n name = 'squisq',\n storeName = 'data',\n prefix = '',\n description = 'Squisq storage',\n } = options;\n\n this.prefix = prefix;\n this.store = localforage.createInstance({ name, storeName, description });\n }\n\n async get<T>(key: string): Promise<T | null> {\n try {\n const value = await this.store.getItem<T>(this.prefix + key);\n return value;\n } catch (e: unknown) {\n console.warn('[Storage] get (IndexedDB) failed:', key, e);\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n try {\n await this.store.setItem(this.prefix + key, value);\n } catch (e: unknown) {\n console.error('[Storage] set (IndexedDB) failed:', key, e);\n throw e;\n }\n }\n\n async remove(key: string): Promise<void> {\n try {\n await this.store.removeItem(this.prefix + key);\n } catch (e: unknown) {\n console.warn('[Storage] remove (IndexedDB) failed:', key, e);\n }\n }\n\n async clear(): Promise<void> {\n try {\n if (this.prefix) {\n // Only remove keys with our prefix\n const keys = await this.store.keys();\n const keysToRemove = keys.filter((k) => k.startsWith(this.prefix));\n await Promise.all(keysToRemove.map((k) => this.store.removeItem(k)));\n } else {\n // No prefix — clear the entire store\n await this.store.clear();\n }\n } catch (e: unknown) {\n console.error('[Storage] clear (IndexedDB) failed:', e);\n }\n }\n\n async keys(): Promise<string[]> {\n try {\n const allKeys = await this.store.keys();\n if (this.prefix) {\n return allKeys\n .filter((k) => k.startsWith(this.prefix))\n .map((k) => k.slice(this.prefix.length));\n }\n return allKeys;\n } catch (e: unknown) {\n console.warn('[Storage] keys (IndexedDB) failed:', e);\n return [];\n }\n }\n}\n","/**\n * ContentContainer — virtual file system for document containers.\n *\n * A Squisq document is more than just markdown: it's a container of files\n * including the primary markdown document, images, audio, timing data, and\n * other media. ContentContainer provides an abstract async file system\n * interface for reading, writing, and listing these files.\n *\n * Paths are forward-slash separated strings relative to the container root\n * (e.g., 'images/hero.jpg', 'index.md'). No leading slash.\n *\n * Implementations:\n * - MemoryContentContainer — in-memory Map (for zip import, tests, transient use)\n * - SlotContentContainer — backed by IndexedDB slot storage (in the site package)\n */\n\n/**\n * Metadata about a file in a ContentContainer.\n */\nexport interface ContentEntry {\n /** Relative path within the container (e.g., 'images/hero.jpg') */\n path: string;\n /** MIME type (e.g., 'image/jpeg') */\n mimeType: string;\n /** File size in bytes */\n size: number;\n}\n\n/**\n * Abstract async file system for document containers.\n *\n * All paths are forward-slash separated, relative to the container root,\n * with no leading slash. Example: 'images/hero.jpg', 'index.md'.\n */\nexport interface ContentContainer {\n /** Read a file's binary content. Returns null if the file does not exist. */\n readFile(path: string): Promise<ArrayBuffer | null>;\n\n /** Write a file. Creates or overwrites. */\n writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void>;\n\n /** Remove a file. No-op if the file does not exist. */\n removeFile(path: string): Promise<void>;\n\n /**\n * List files in the container.\n * @param prefix — Optional path prefix to filter by (e.g., 'images/')\n */\n listFiles(prefix?: string): Promise<ContentEntry[]>;\n\n /** Check whether a file exists. */\n exists(path: string): Promise<boolean>;\n\n /**\n * Find the primary markdown document path.\n *\n * Discovery order: index.md → doc.md → document.md → first *.md at root.\n * Returns null if no markdown file is found at the root level.\n */\n getDocumentPath(): Promise<string | null>;\n\n /**\n * Convenience: read the primary markdown document as a UTF-8 string.\n * Returns null if no markdown file is found.\n */\n readDocument(): Promise<string | null>;\n\n /**\n * Convenience: write a markdown document.\n * @param markdown — The markdown content\n * @param filename — Filename to use (defaults to 'index.md')\n */\n writeDocument(markdown: string, filename?: string): Promise<void>;\n}\n\n// ============================================\n// Well-known markdown filenames in priority order\n// ============================================\n\nconst MARKDOWN_PRIORITY = ['index.md', 'doc.md', 'document.md'];\n\n/**\n * Find the primary markdown path from a list of file entries.\n * Exported for reuse by other ContentContainer implementations.\n */\nexport function findDocumentPath(entries: ContentEntry[]): string | null {\n // Only consider root-level files (no '/' in path)\n const rootFiles = entries.filter((e) => !e.path.includes('/'));\n\n for (const name of MARKDOWN_PRIORITY) {\n if (rootFiles.some((e) => e.path.toLowerCase() === name)) {\n return name;\n }\n }\n\n // Fallback: first .md file at root\n const firstMd = rootFiles.find((e) => e.path.toLowerCase().endsWith('.md'));\n return firstMd?.path ?? null;\n}\n\n// ============================================\n// MemoryContentContainer\n// ============================================\n\ninterface MemoryFile {\n data: ArrayBuffer;\n mimeType: string;\n}\n\n/**\n * In-memory ContentContainer backed by a Map.\n *\n * Used for zip import (deserialize into memory), tests, and transient operations.\n */\nexport class MemoryContentContainer implements ContentContainer {\n private files = new Map<string, MemoryFile>();\n\n async readFile(path: string): Promise<ArrayBuffer | null> {\n return this.files.get(path)?.data ?? null;\n }\n\n async writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void> {\n // Copy to a standalone ArrayBuffer. Uint8Array/Buffer may share a larger\n // backing buffer (e.g., Node's buffer pool), so we must slice to the\n // exact byte range to avoid storing stale pool data.\n let buffer: ArrayBuffer;\n if (data instanceof ArrayBuffer) {\n buffer = data;\n } else {\n buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;\n }\n this.files.set(path, {\n data: buffer,\n mimeType: mimeType ?? guessMimeType(path),\n });\n }\n\n async removeFile(path: string): Promise<void> {\n this.files.delete(path);\n }\n\n async listFiles(prefix?: string): Promise<ContentEntry[]> {\n const entries: ContentEntry[] = [];\n for (const [path, file] of this.files) {\n if (prefix && !path.startsWith(prefix)) continue;\n entries.push({\n path,\n mimeType: file.mimeType,\n size: file.data.byteLength,\n });\n }\n return entries;\n }\n\n async exists(path: string): Promise<boolean> {\n return this.files.has(path);\n }\n\n async getDocumentPath(): Promise<string | null> {\n return findDocumentPath(await this.listFiles());\n }\n\n async readDocument(): Promise<string | null> {\n const docPath = await this.getDocumentPath();\n if (!docPath) return null;\n const data = await this.readFile(docPath);\n if (!data) return null;\n return new TextDecoder().decode(data);\n }\n\n async writeDocument(markdown: string, filename?: string): Promise<void> {\n const name = filename ?? 'index.md';\n const data = new TextEncoder().encode(markdown);\n await this.writeFile(name, data, 'text/markdown');\n }\n}\n\n// ============================================\n// MIME type guessing\n// ============================================\n\nconst EXTENSION_MIME_MAP: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'application/javascript',\n};\n\nfunction guessMimeType(path: string): string {\n const dot = path.lastIndexOf('.');\n if (dot === -1) return 'application/octet-stream';\n const ext = path.slice(dot).toLowerCase();\n return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream';\n}\n","/**\n * ScopedContentContainer\n *\n * Wraps a parent {@link ContentContainer} and prefixes every path with a\n * fixed subfolder, presenting that subfolder as the root. Useful for\n * sidecar folders (e.g. `<imagebasename>_files/`) where a component\n * should be able to read/write/list files relative to its own root\n * without knowing where it lives in the parent tree.\n *\n * Scopes nest: `scopeContainer(scopeContainer(parent, 'a'), 'b')` is\n * equivalent to `scopeContainer(parent, 'a/b')`.\n *\n * `getDocumentPath()` always returns `null` for scoped sidecars — they\n * are not document roots.\n */\n\nimport type { ContentContainer, ContentEntry } from './ContentContainer.js';\n\n/** Normalize a prefix: strip leading/trailing slashes, ensure no `..` segments. */\nfunction normalizePrefix(prefix: string): string {\n let p = prefix.trim();\n while (p.startsWith('/')) p = p.slice(1);\n while (p.endsWith('/')) p = p.slice(0, -1);\n if (p.length === 0) {\n throw new Error('ScopedContentContainer: prefix must not be empty');\n }\n if (p.split('/').some((seg) => seg === '..' || seg === '.')) {\n throw new Error(\n `ScopedContentContainer: prefix must not contain '.' or '..' segments: ${prefix}`,\n );\n }\n return p;\n}\n\n/**\n * A ContentContainer view rooted at a subfolder of a parent container.\n * All paths passed to / returned from this container are relative to\n * the subfolder; the parent never sees an unprefixed path.\n */\nexport class ScopedContentContainer implements ContentContainer {\n private readonly parent: ContentContainer;\n /** Prefix without trailing slash. */\n readonly prefix: string;\n /** Prefix with trailing slash. */\n private readonly prefixSlash: string;\n\n constructor(parent: ContentContainer, prefix: string) {\n this.parent = parent;\n this.prefix = normalizePrefix(prefix);\n this.prefixSlash = `${this.prefix}/`;\n }\n\n private toParent(path: string): string {\n let p = path;\n while (p.startsWith('/')) p = p.slice(1);\n return this.prefixSlash + p;\n }\n\n /** Strip the prefix from a parent-space path. Returns null if the path is outside the scope. */\n private fromParent(parentPath: string): string | null {\n if (!parentPath.startsWith(this.prefixSlash)) return null;\n return parentPath.slice(this.prefixSlash.length);\n }\n\n readFile(path: string): Promise<ArrayBuffer | null> {\n return this.parent.readFile(this.toParent(path));\n }\n\n writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void> {\n return this.parent.writeFile(this.toParent(path), data, mimeType);\n }\n\n removeFile(path: string): Promise<void> {\n return this.parent.removeFile(this.toParent(path));\n }\n\n async listFiles(prefix?: string): Promise<ContentEntry[]> {\n const parentPrefix = prefix ? this.toParent(prefix) : this.prefixSlash;\n const entries = await this.parent.listFiles(parentPrefix);\n const out: ContentEntry[] = [];\n for (const e of entries) {\n const local = this.fromParent(e.path);\n if (local === null) continue;\n out.push({ path: local, mimeType: e.mimeType, size: e.size });\n }\n return out;\n }\n\n exists(path: string): Promise<boolean> {\n return this.parent.exists(this.toParent(path));\n }\n\n /** Sidecars are not document roots. */\n async getDocumentPath(): Promise<string | null> {\n return null;\n }\n\n async readDocument(): Promise<string | null> {\n return null;\n }\n\n async writeDocument(): Promise<void> {\n throw new Error(\n 'ScopedContentContainer is a sidecar view and does not support writeDocument(). ' +\n 'Write to a specific path with writeFile() instead.',\n );\n }\n}\n\n/** Factory: convenience wrapper for `new ScopedContentContainer(parent, prefix)`. */\nexport function scopeContainer(parent: ContentContainer, prefix: string): ScopedContentContainer {\n return new ScopedContentContainer(parent, prefix);\n}\n","/**\n * MediaProviderFromContainer — bridges ContentContainer to MediaProvider.\n *\n * Creates a MediaProvider that resolves relative paths by reading binary data\n * from a ContentContainer and generating blob URLs. Blob URLs are cached and\n * revoked on dispose().\n *\n * This allows any ContentContainer (memory, slot-backed, zip-loaded) to be\n * used with existing rendering components (DocPlayer, ImageLayer, VideoLayer)\n * that consume MediaProvider.\n */\n\nimport type { MediaProvider, MediaEntry } from '../schemas/MediaProvider.js';\nimport type { ContentContainer } from './ContentContainer.js';\n\n/**\n * Create a MediaProvider backed by a ContentContainer.\n *\n * @param container — The ContentContainer to read/write media from\n * @returns A MediaProvider that resolves paths to blob URLs\n */\nexport function createMediaProviderFromContainer(container: ContentContainer): MediaProvider {\n const blobUrlCache = new Map<string, string>();\n\n return {\n async resolveUrl(relativePath: string): Promise<string> {\n const cached = blobUrlCache.get(relativePath);\n if (cached) return cached;\n\n const data = await container.readFile(relativePath);\n if (!data) return relativePath;\n\n const entries = await container.listFiles();\n const entry = entries.find((e) => e.path === relativePath);\n const mimeType = entry?.mimeType ?? 'application/octet-stream';\n\n const blob = new Blob([data], { type: mimeType });\n const url = URL.createObjectURL(blob);\n blobUrlCache.set(relativePath, url);\n return url;\n },\n\n async listMedia(): Promise<MediaEntry[]> {\n const entries = await container.listFiles();\n return entries\n .filter((e) => !e.path.toLowerCase().endsWith('.md'))\n .map((e) => ({\n name: e.path,\n mimeType: e.mimeType,\n size: e.size,\n }));\n },\n\n async addMedia(\n name: string,\n data: ArrayBuffer | Blob | Uint8Array,\n mimeType: string,\n ): Promise<string> {\n // Invalidate any cached blob URL for this path before overwriting\n const cached = blobUrlCache.get(name);\n if (cached) {\n URL.revokeObjectURL(cached);\n blobUrlCache.delete(name);\n }\n\n let buffer: ArrayBuffer | Uint8Array;\n if (data instanceof Blob) {\n buffer = await data.arrayBuffer();\n } else {\n buffer = data;\n }\n await container.writeFile(name, buffer, mimeType);\n return name;\n },\n\n async removeMedia(relativePath: string): Promise<void> {\n const cached = blobUrlCache.get(relativePath);\n if (cached) {\n URL.revokeObjectURL(cached);\n blobUrlCache.delete(relativePath);\n }\n await container.removeFile(relativePath);\n },\n\n dispose(): void {\n for (const url of blobUrlCache.values()) {\n URL.revokeObjectURL(url);\n }\n blobUrlCache.clear();\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/timing/narrationTiming.ts"],"sourcesContent":["/**\n * Narration timing estimation.\n *\n * Estimates how long text takes to speak aloud, accounting for:\n * - Numbers spoken as multiple words (e.g., \"1910\" → \"nineteen ten\")\n * - Pauses at commas\n * - Year pronunciation conventions (1000–2099)\n *\n * Ported from qualla-internal's AudioTiming.ts (pure functions, no Node.js deps).\n */\n\n/** Numbers are spoken more slowly than regular words (1.3× multiplier). */\nconst NUMBER_SPEAKING_MULTIPLIER = 1.3;\n\n/** Comma pause in \"word equivalents\" — speakers pause ~0.3 words at commas. */\nconst COMMA_PAUSE_PENALTY = 0.3;\n\n/** Default speaking rate in words per second (~150 WPM). */\nexport const DEFAULT_WORDS_PER_SECOND = 2.5;\n\n/**\n * Estimate how many \"spoken word equivalents\" a single token represents.\n * Numbers get expanded (e.g., \"175,000\" → ~5 words × 1.3 multiplier).\n */\nexport function estimateSpokenWordCount(token: string): number {\n const cleaned = token.replace(/,/g, '');\n const num = parseFloat(cleaned);\n\n if (isNaN(num) || !isFinite(num)) {\n return 1; // Not a number — count as 1 word\n }\n\n return estimateNumberWordCount(cleaned, num) * NUMBER_SPEAKING_MULTIPLIER;\n}\n\n/**\n * Raw word count for a number (before the speaking multiplier).\n */\nfunction estimateNumberWordCount(cleaned: string, num: number): number {\n // Handle decimals: \"3.14\" → words(\"3\") + \"point\" + 2 digits\n if (cleaned.includes('.')) {\n const [whole, decimal] = cleaned.split('.');\n const wholeNum = parseFloat(whole) || 0;\n return estimateNumberWordCount(whole, wholeNum) + 1 + decimal.length;\n }\n\n const absNum = Math.abs(num);\n let words = num < 0 ? 1 : 0; // \"negative\" / \"minus\"\n\n if (absNum === 0) return 1; // \"zero\"\n\n // Years (1000–2099) are spoken as two two-digit groups:\n // \"1910\" → \"nineteen ten\", \"2024\" → \"twenty twenty-four\"\n if (absNum >= 1000 && absNum <= 2099 && Number.isInteger(absNum)) {\n const yearStr = absNum.toString();\n if (yearStr.length === 4) {\n const firstTwo = parseInt(yearStr.slice(0, 2));\n const lastTwo = parseInt(yearStr.slice(2));\n\n // First part\n if (firstTwo >= 10 && firstTwo <= 19) {\n words += 1;\n } else if (firstTwo >= 20 && firstTwo <= 99) {\n words += firstTwo % 10 === 0 ? 1 : 2;\n } else {\n words += 2; // \"two thousand\"\n }\n\n // Second part\n if (lastTwo === 0) {\n if (firstTwo >= 10 && firstTwo <= 99 && firstTwo !== 20) {\n words += 1; // \"hundred\"\n }\n } else if (lastTwo >= 1 && lastTwo <= 9) {\n words += 2; // \"oh five\"\n } else if (lastTwo >= 10 && lastTwo <= 19) {\n words += 1;\n } else {\n words += lastTwo % 10 === 0 ? 1 : 2;\n }\n\n return Math.max(words, 2);\n }\n }\n\n // General number handling — each magnitude group adds words\n let remaining = absNum;\n\n if (remaining >= 1_000_000_000) {\n const billions = Math.floor(remaining / 1_000_000_000);\n words += estimateNumberWordCount(String(billions), billions);\n words += 1; // \"billion\"\n remaining %= 1_000_000_000;\n }\n\n if (remaining >= 1_000_000) {\n const millions = Math.floor(remaining / 1_000_000);\n words += estimateNumberWordCount(String(millions), millions);\n words += 1; // \"million\"\n remaining %= 1_000_000;\n }\n\n if (remaining >= 1_000) {\n const thousands = Math.floor(remaining / 1_000);\n words += estimateNumberWordCount(String(thousands), thousands);\n words += 1; // \"thousand\"\n remaining %= 1_000;\n }\n\n if (remaining >= 100) {\n words += 2; // digit + \"hundred\"\n remaining %= 100;\n }\n\n if (remaining >= 1) {\n if (remaining >= 20) {\n words += remaining % 10 === 0 ? 1 : 2;\n } else {\n words += 1; // 1–19\n }\n }\n\n return Math.max(words, 1);\n}\n\n/**\n * Count \"spoken word equivalents\" in a block of text,\n * expanding numbers and adding comma pauses.\n */\nexport function countSpokenWords(text: string): number {\n const tokens = text.split(/\\s+/).filter((w) => w.length > 0);\n let count = 0;\n\n for (const token of tokens) {\n // Strip punctuation only at token boundaries so internal punctuation\n // (e.g., decimals like \"3.14\" or digit-grouping commas \"1,000\") is preserved.\n const stripped = token.replace(/^[.,!?;:'\"()[\\]{}]+|[.,!?;:'\"()[\\]{}]+$/g, '');\n count += estimateSpokenWordCount(stripped);\n\n // Count only punctuation commas for pauses: exclude digit-grouping commas\n // (i.e., commas with digits on both sides, like in \"1,234\").\n let punctuationCommaCount = 0;\n for (let i = 0; i < token.length; i++) {\n if (token[i] === ',') {\n const prev = i > 0 ? token[i - 1] : '';\n const next = i < token.length - 1 ? token[i + 1] : '';\n const prevIsDigit = prev >= '0' && prev <= '9';\n const nextIsDigit = next >= '0' && next <= '9';\n if (!(prevIsDigit && nextIsDigit)) {\n punctuationCommaCount++;\n }\n }\n }\n count += punctuationCommaCount * COMMA_PAUSE_PENALTY;\n }\n\n return count;\n}\n\n/**\n * Estimate audio time at a character offset using word-proportion mapping.\n *\n * Given the full source text and a character position, returns the\n * estimated time (in seconds) at which that position would be spoken.\n *\n * @param text - Full source text\n * @param charOffset - Character offset to estimate timing for\n * @param totalDuration - Total audio duration in seconds\n * @returns Estimated time in seconds\n */\nexport function estimateTimeFromText(\n text: string,\n charOffset: number,\n totalDuration: number,\n): number {\n const textBefore = text.slice(0, charOffset);\n const spokenWordsBefore = countSpokenWords(textBefore);\n const totalSpokenWords = countSpokenWords(text);\n\n if (totalSpokenWords === 0) return 0;\n return (spokenWordsBefore / totalSpokenWords) * totalDuration;\n}\n\n/**\n * Estimate how long a spoken prefix adds to audio duration.\n *\n * When audio includes a spoken title or header before the main content,\n * this calculates the extra duration to offset timing calculations.\n *\n * @param prefix - Prefix text (e.g., an article title spoken before the body)\n * @param wordsPerSecond - Speaking rate (default: 2.5)\n * @returns Estimated duration in seconds\n */\nexport function calculatePrefixDuration(\n prefix: string,\n wordsPerSecond: number = DEFAULT_WORDS_PER_SECOND,\n): number {\n if (!prefix) return 0;\n\n const words = prefix.split(/\\s+/).filter((w) => w.length > 0);\n const newlineCount = (prefix.match(/\\n\\n/g) || []).length;\n const pauseTime = newlineCount * 0.5;\n\n return words.length / wordsPerSecond + pauseTime;\n}\n\n/**\n * Estimate narration duration for a block of text.\n *\n * @param text - The text to estimate\n * @param wordsPerSecond - Speaking rate (default: 2.5, i.e. ~150 WPM)\n * @returns Duration in seconds\n */\nexport function estimateNarrationDuration(\n text: string,\n wordsPerSecond: number = DEFAULT_WORDS_PER_SECOND,\n): number {\n const spokenWords = countSpokenWords(text);\n return spokenWords / wordsPerSecond;\n}\n"],"mappings":"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","names":[]}
|