@classic-homes/slides 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ npx classic-slides new decks/closing-day.yaml # basic starter
16
16
  npx classic-slides new decks/orientation.yaml --starter journey
17
17
  npx classic-slides dev decks/closing-day.yaml # live preview, reloads on save
18
18
  npx classic-slides build decks/closing-day.yaml # → decks/closing-day.html
19
+ npx classic-slides export decks/closing-day.yaml # → decks/closing-day.deck.json
19
20
  ```
20
21
 
21
22
  Present: open the HTML, use arrow keys or Space. **N** shows speaker notes,
@@ -180,6 +181,18 @@ rendering a layout picker's thumbnails in one pass.
180
181
  Rendered HTML embeds the deck source (minus image data), so
181
182
  `extractDeckSource(html)` recovers the editable deck.
182
183
 
184
+ ### Portable decks (import / export)
185
+
186
+ `classic-slides export` (or `exportDeckFile` from `/node`) writes one JSON file
187
+ with every image inlined as a `data:` URI — the format to import into a hosted
188
+ deck editor. Keep it under ~10 MB by resizing photos to ~2000px wide.
189
+
190
+ In code, `inlineDeckImages(deck, resolveImage)` does the export side and
191
+ `externalizeDeckImages(deck, upload)` the import side: it hands each `data:`
192
+ image to `upload` and puts the returned URL in its place. Both build on
193
+ `mapDeckImages(deck, fn)`, which finds images written as plain strings too and
194
+ returns a copy in the author's shape (no schema defaults added).
195
+
183
196
  ## Brand
184
197
 
185
198
  Colours come from `@classic-homes/theme-tokens`. Fonts (Figtree, Spectral) are
@@ -1,8 +1,9 @@
1
1
  import {
2
+ inlineDeckImages,
2
3
  parseDeckSource,
3
4
  renderDeck,
4
5
  stringifyDeck
5
- } from "./chunk-PTTLL6PQ.js";
6
+ } from "./chunk-OUAMH2TP.js";
6
7
 
7
8
  // src/node/index.ts
8
9
  import { readFile, stat, writeFile } from "fs/promises";
@@ -94,6 +95,18 @@ async function buildDeckFile(path, options = {}) {
94
95
  await writeFile(out, result.html);
95
96
  return { ...result, out, bytes: (await stat(out)).size };
96
97
  }
98
+ async function exportDeckFile(path, options = {}) {
99
+ const abs = options.rootDir ? confinedPath(options.rootDir, resolve2(path)) : resolve2(path);
100
+ const result = await inlineDeckImages(
101
+ await loadDeckFile(abs),
102
+ fileImageResolver(dirname2(abs), options)
103
+ );
104
+ const candidate = options.out ? resolve2(options.out) : abs.replace(/\.(ya?ml|json)$/i, "").replace(/\.deck$/i, "") + ".deck.json";
105
+ const out = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;
106
+ if (out === abs) throw new Error(`Refusing to overwrite the source deck ${abs}. Pass --out.`);
107
+ await writeFile(out, JSON.stringify(result.deck) + "\n");
108
+ return { ...result, out, bytes: (await stat(out)).size };
109
+ }
97
110
 
98
111
  export {
99
112
  confinedPath,
@@ -101,6 +114,7 @@ export {
101
114
  fileImageResolver,
102
115
  loadDeckFile,
103
116
  saveDeckFile,
104
- buildDeckFile
117
+ buildDeckFile,
118
+ exportDeckFile
105
119
  };
106
- //# sourceMappingURL=chunk-L7G3VH7K.js.map
120
+ //# sourceMappingURL=chunk-GX6P2SE6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/node/index.ts","../src/node/paths.ts"],"sourcesContent":["/** Node-only helpers: read deck files, inline local images, write HTML. */\nimport { readFile, stat, writeFile } from 'node:fs/promises';\nimport { dirname, extname, isAbsolute, join, resolve } from 'node:path';\nimport { confinedPath } from './paths.js';\nimport { inlineDeckImages, type InlineImagesResult } from '../images.js';\nimport { parseDeckSource, stringifyDeck } from '../parse.js';\nimport {\n renderDeck,\n type ImageResolver,\n type RenderOptions,\n type RenderResult,\n} from '../render.js';\n\nconst MIME: Record<string, string> = {\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.webp': 'image/webp',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.avif': 'image/avif',\n};\n\n/** Largest single image we inline without warning. Big photos bloat the deck file. */\nexport const IMAGE_WARN_BYTES = 800 * 1024;\n\nexport interface FileResolverOptions {\n /** Restrict local image reads and build paths to this root (used by the MCP server). */\n rootDir?: string;\n /** Fetch https images and inline them so the deck works offline. Default true. */\n inlineRemote?: boolean;\n onWarning?: (message: string) => void;\n}\n\n/** Resolves image paths relative to the deck file and inlines them as data URIs. */\nexport function fileImageResolver(\n baseDir: string,\n options: FileResolverOptions = {}\n): ImageResolver {\n const warn = options.onWarning ?? (() => {});\n return async (src) => {\n if (src.startsWith('data:')) return src;\n if (/^https?:/.test(src)) {\n if (options.inlineRemote === false) return src;\n const res = await fetch(src);\n if (!res.ok) throw new Error(`Image ${src} returned HTTP ${res.status}`);\n const type = res.headers.get('content-type') ?? 'application/octet-stream';\n const buf = Buffer.from(await res.arrayBuffer());\n if (buf.length > IMAGE_WARN_BYTES)\n warn(`${src} is ${(buf.length / 1024).toFixed(0)} KB; consider a smaller copy.`);\n return `data:${type};base64,${buf.toString('base64')}`;\n }\n const candidate = isAbsolute(src) ? src : join(baseDir, src);\n const path = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;\n const type = MIME[extname(path).toLowerCase()];\n if (!type)\n throw new Error(`Unsupported image type for \"${src}\". Use jpg, png, webp, gif, svg or avif.`);\n let buf: Buffer;\n try {\n buf = await readFile(path);\n } catch {\n throw new Error(`Image not found: \"${src}\" (looked in ${path})`);\n }\n if (buf.length > IMAGE_WARN_BYTES)\n warn(`${src} is ${(buf.length / 1024).toFixed(0)} KB; consider resizing to ~2000px wide.`);\n return `data:${type};base64,${buf.toString('base64')}`;\n };\n}\n\nexport async function loadDeckFile(path: string): Promise<unknown> {\n return parseDeckSource(await readFile(path, 'utf8'));\n}\n\nexport async function saveDeckFile(path: string, deck: unknown): Promise<void> {\n const text = path.endsWith('.json') ? JSON.stringify(deck, null, 2) + '\\n' : stringifyDeck(deck);\n await writeFile(path, text);\n}\n\nexport interface BuildOptions extends Omit<RenderOptions, 'resolveImage'>, FileResolverOptions {\n out?: string;\n}\n\n/** Renders a deck file to a self-contained HTML file next to it (or at `out`). */\nexport async function buildDeckFile(\n path: string,\n options: BuildOptions = {}\n): Promise<RenderResult & { out: string; bytes: number }> {\n const abs = options.rootDir ? confinedPath(options.rootDir, resolve(path)) : resolve(path);\n const deck = await loadDeckFile(abs);\n const result = await renderDeck(deck, {\n ...options,\n resolveImage: fileImageResolver(dirname(abs), options),\n });\n const candidate = options.out\n ? resolve(options.out)\n : abs.replace(/\\.(ya?ml|json)$/i, '') + '.html';\n const out = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;\n await writeFile(out, result.html);\n return { ...result, out, bytes: (await stat(out)).size };\n}\n\nexport interface ExportOptions extends FileResolverOptions {\n out?: string;\n}\n\n/**\n * Writes a portable copy of a deck file: JSON with every image inlined as a\n * data URI, ready to import into a hosted deck editor. Defaults to\n * `<name>.deck.json` next to the source.\n */\nexport async function exportDeckFile(\n path: string,\n options: ExportOptions = {}\n): Promise<InlineImagesResult & { out: string; bytes: number }> {\n const abs = options.rootDir ? confinedPath(options.rootDir, resolve(path)) : resolve(path);\n const result = await inlineDeckImages(\n await loadDeckFile(abs),\n fileImageResolver(dirname(abs), options)\n );\n const candidate = options.out\n ? resolve(options.out)\n : abs.replace(/\\.(ya?ml|json)$/i, '').replace(/\\.deck$/i, '') + '.deck.json';\n const out = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;\n if (out === abs) throw new Error(`Refusing to overwrite the source deck ${abs}. Pass --out.`);\n await writeFile(out, JSON.stringify(result.deck) + '\\n');\n return { ...result, out, bytes: (await stat(out)).size };\n}\n\nexport { renderDeck };\n","/** Canonical containment for existing files and new output files. */\nimport { lstatSync, realpathSync } from 'node:fs';\nimport { dirname, relative, resolve, sep } from 'node:path';\n\nexport function confinedPath(root: string, path: string): string {\n const canonicalRoot = realpathSync(root);\n const absolute = resolve(path);\n let ancestor = absolute;\n for (;;) {\n try {\n const canonical = resolve(realpathSync(ancestor), relative(ancestor, absolute));\n if (canonical !== canonicalRoot && !canonical.startsWith(canonicalRoot + sep)) {\n throw new Error(`Path \"${path}\" is outside the deck root ${root}.`);\n }\n return canonical;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || ancestor === dirname(ancestor))\n throw error;\n if (lstatSync(ancestor, { throwIfNoEntry: false })?.isSymbolicLink()) {\n throw new Error(`Cannot follow dangling symlink \"${ancestor}\".`);\n }\n ancestor = dirname(ancestor);\n }\n }\n}\n"],"mappings":"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","names":["dirname","resolve","type","buf","resolve","dirname"]}
@@ -3824,6 +3824,73 @@ function stringifyDeck(deck) {
3824
3824
  });
3825
3825
  }
3826
3826
 
3827
+ // src/images.ts
3828
+ function imagePaths(value, path = [], out = []) {
3829
+ if (Array.isArray(value)) value.forEach((v, i) => imagePaths(v, [...path, i], out));
3830
+ else if (value && typeof value === "object") {
3831
+ const obj = value;
3832
+ if (typeof obj.src === "string") out.push(path);
3833
+ for (const [k, v] of Object.entries(obj)) imagePaths(v, [...path, k], out);
3834
+ }
3835
+ return out;
3836
+ }
3837
+ async function mapDeckImages(input, fn, options = {}) {
3838
+ const result = validateDeck(input, options);
3839
+ if (!result.ok || !result.deck) throw new DeckError(result.issues);
3840
+ const paths = imagePaths(result.deck);
3841
+ const deck = structuredClone(input);
3842
+ const read = (path) => path.reduce((node, key) => node[key], deck);
3843
+ const sources = [
3844
+ ...new Set(
3845
+ paths.map((p) => {
3846
+ const node = read(p);
3847
+ return typeof node === "string" ? node : node.src;
3848
+ })
3849
+ )
3850
+ ];
3851
+ const replaced = /* @__PURE__ */ new Map();
3852
+ let next = 0;
3853
+ await Promise.all(
3854
+ Array.from({ length: Math.min(options.concurrency ?? 4, sources.length) }, async () => {
3855
+ while (next < sources.length) {
3856
+ const src = sources[next++];
3857
+ replaced.set(src, await fn(src));
3858
+ }
3859
+ })
3860
+ );
3861
+ for (const path of paths) {
3862
+ const node = read(path);
3863
+ if (typeof node === "string") {
3864
+ const parent = read(path.slice(0, -1));
3865
+ parent[path[path.length - 1]] = replaced.get(node);
3866
+ } else {
3867
+ const img2 = node;
3868
+ img2.src = replaced.get(img2.src);
3869
+ }
3870
+ }
3871
+ return deck;
3872
+ }
3873
+ async function inlineDeckImages(input, resolve, options = {}) {
3874
+ const warnings = [];
3875
+ const deck = await mapDeckImages(
3876
+ input,
3877
+ async (src) => {
3878
+ if (/^data:/i.test(src)) return src;
3879
+ try {
3880
+ return await resolve(src);
3881
+ } catch (e) {
3882
+ warnings.push({ src, message: e instanceof Error ? e.message : String(e) });
3883
+ return src;
3884
+ }
3885
+ },
3886
+ options
3887
+ );
3888
+ return { deck, warnings };
3889
+ }
3890
+ function externalizeDeckImages(input, upload, options = {}) {
3891
+ return mapDeckImages(input, (src) => /^data:/i.test(src) ? upload(src) : src, options);
3892
+ }
3893
+
3827
3894
  export {
3828
3895
  baseSlide,
3829
3896
  BrandPaletteSchema,
@@ -3882,6 +3949,9 @@ export {
3882
3949
  renderDeck,
3883
3950
  extractDeckSource,
3884
3951
  parseDeckSource,
3885
- stringifyDeck
3952
+ stringifyDeck,
3953
+ mapDeckImages,
3954
+ inlineDeckImages,
3955
+ externalizeDeckImages
3886
3956
  };
3887
- //# sourceMappingURL=chunk-PTTLL6PQ.js.map
3957
+ //# sourceMappingURL=chunk-OUAMH2TP.js.map