@classic-homes/slides 0.2.1 → 0.4.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
@@ -63,30 +63,72 @@ Inline text supports `**bold**`, `*highlight*` (brand accent), `` `code` `` and
63
63
  Run `classic-slides layouts` for the list, `classic-slides layouts <name>` for
64
64
  every field plus an example.
65
65
 
66
- | Layout | Use for |
67
- | ------------- | ----------------------------------------------------- |
68
- | `title` | First slide |
69
- | `section` | Chapter divider on a solid colour, optional photo row |
70
- | `statement` | One big idea, a transition, a closing line |
71
- | `bullets` | Short checklist, one or two columns |
72
- | `split` | Text beside 1–4 photos, optional callout badge |
73
- | `cards` | 3–8 peers: services, options, people |
74
- | `stats` | 2–6 numbers worth remembering |
75
- | `comparison` | 2–3 columns side by side |
76
- | `journey` | Numbered agenda map (uses `journey.steps`) |
77
- | `photos` | A grid of photos: team, products, homes |
78
- | `image` | One big image or diagram |
79
- | `walkthrough` | Ordered stages, one slide each with a progress rail |
80
- | `quote` | A testimonial or pull quote |
81
- | `qr` | Send people to a URL with a scannable code |
82
- | `contacts` | Who to call |
66
+ | Group | Layouts |
67
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | Openers & dividers | `title`, `section`, `statement`, `journey` |
69
+ | Text | `bullets`, `cards`, `comparison`, `quote` |
70
+ | Data | `stats` (with trends + sparklines), `chart`, `table` |
71
+ | Diagrams | `flow`, `timeline`, `cycle`, `hierarchy`, `matrix`, `tiers` (funnel / pyramid) |
72
+ | Media | `split`, `photos`, `image` (with numbered hotspots), `compare` (before/after), `walkthrough`, `video` (YouTube/Vimeo), `embed` (any https page) |
73
+ | People & places | `qr`, `contacts` |
83
74
 
84
75
  Every slide also accepts `track` (value/red, build/teal, neutral/navy),
85
- `background` (paper or a solid brand colour), `notes`, `time`, `id`, and — in
86
- decks with `journey.steps` — `step`, `stepDone` and `showJourney`.
76
+ `background` (paper or a solid brand colour), `backgroundImage` + `overlay`,
77
+ `notes`, `time`, `id`, `hidden`, and — in decks with `journey.steps` — `step`,
78
+ `stepDone` and `showJourney`. `bullets`, `cards`, `flow` and `timeline` take
79
+ `build: true` to reveal one item per click.
87
80
 
88
- `classic-slides guide` prints the writing rules; `classic-slides validate`
89
- flags errors and crowded slides.
81
+ ### Charts
82
+
83
+ ```yaml
84
+ - layout: chart
85
+ title: Closings *doubled* since spring
86
+ chart:
87
+ type: column # bar, stacked-column, stacked-bar, line, area, pie, donut, funnel, gauge, scatter, radar
88
+ labels: [Q1, Q2, Q3, Q4]
89
+ series: [{ name: Closings, values: [42, 61, 88, 94] }]
90
+ unit: homes
91
+ highlight: Q4
92
+ insights: [Q4 best quarter on record]
93
+ source: Sales ops
94
+ ```
95
+
96
+ Charts are rendered to static SVG with ECharts (server-side rendering, themed
97
+ from `@classic-homes/charts-core`), so they work offline and in print. ECharts
98
+ is loaded only when a deck has charts.
99
+
100
+ ### Video and embeds
101
+
102
+ `video` takes YouTube/Vimeo links (privacy-enhanced players); `embed` takes any
103
+ https page (maps, dashboards, 3D tours, forms) in a sandboxed frame. Both load
104
+ only while their slide is shown, and fall back to a poster/screenshot with a QR
105
+ code offline, in print and in PDF.
106
+
107
+ ## Brand profiles and deck settings
108
+
109
+ ```yaml
110
+ brand:
111
+ profile: pentrust # admin-managed profile; default classic-homes
112
+ name: PenTrust Lending # optional overrides: name, tagline, logo, logoOnDark, favicon, footer
113
+ settings:
114
+ slideNumbers: true
115
+ logo: all # title | all | none
116
+ confidential: Internal
117
+ autoAdvance: 15 # kiosk mode (also ?kiosk=15 on the URL)
118
+ loop: true
119
+ ```
120
+
121
+ Profiles (`BrandProfile`) carry name, logos, favicon, footer and an optional
122
+ palette; `validateBrandProfile()` enforces WCAG AA contrast. Pass them to
123
+ `renderDeck(deck, { brands })` / `validateDeck(deck, { brands })`. Authors can
124
+ override words and images, never colours.
125
+
126
+ ## Presenting
127
+
128
+ Arrow keys / Space advance (and step through reveals) · **N** notes · **P**
129
+ presenter window (current + next slide, notes, timer, clock) · **O** overview ·
130
+ **F** fullscreen · `#slide-7` deep links · `?kiosk=15` loops unattended. Print
131
+ to PDF gives one 16:9 page per slide with media fallbacks.
90
132
 
91
133
  ## With an AI agent (MCP)
92
134
 
@@ -117,8 +159,23 @@ import { buildDeckFile, fileImageResolver } from '@classic-homes/slides/node';
117
159
  const { html } = await renderDeck(deck, { resolveImage: (src) => lookUp(src) });
118
160
  ```
119
161
 
120
- The core entry has no Node dependencies (it runs in Workers); `/node` adds
121
- file-based image loading and `buildDeckFile`.
162
+ The core entry has no Node dependencies (it runs in Workers and browsers);
163
+ `/node` adds file-based image loading and `buildDeckFile`. For editors,
164
+ `layoutEditorInfo()` / `deckEditorFields()` describe every field as a widget
165
+ (with `hint`s for chart, grid, url, video-url and point pickers),
166
+ `blankSlide(layout)` gives a valid starting slide, and `renderDeck(deck,
167
+ { includeHidden: true, initialSlide })` plus the `postMessage` API drive a
168
+ live preview. Fields flagged `advanced` (slide id, speaking time, accent
169
+ colour) belong behind an "Advanced" section.
170
+
171
+ Click-to-edit: render the preview with `editorMarkers: true` and, inside an
172
+ iframe, clicking a title, bullet or picture posts
173
+ `{ type: 'classic-slides:select', index, path }` to the parent (`index` is the
174
+ deck slide, `path` the field such as `['items', 2, 'text']`, or `null` for the
175
+ slide itself). Send `{ type: 'classic-slides:highlight', index, path }` to
176
+ outline the field being edited. Never turn it on for shared or exported decks.
177
+ `layoutGalleryDeck()` returns one deck with every layout's starting slide, for
178
+ rendering a layout picker's thumbnails in one pass.
122
179
 
123
180
  Rendered HTML embeds the deck source (minus image data), so
124
181
  `extractDeckSource(html)` recovers the editable deck.
@@ -0,0 +1,107 @@
1
+ import {
2
+ DeckSchema,
3
+ LAYOUTS,
4
+ LAYOUT_CATEGORIES,
5
+ baseSlide,
6
+ getLayout
7
+ } from "./chunk-PTTLL6PQ.js";
8
+
9
+ // src/catalog.ts
10
+ import { z } from "zod";
11
+ import { zodToJsonSchema } from "zod-to-json-schema";
12
+ function listLayouts() {
13
+ return LAYOUTS.map((l) => ({
14
+ name: l.name,
15
+ category: LAYOUT_CATEGORIES[l.name],
16
+ description: l.description,
17
+ whenToUse: l.whenToUse
18
+ }));
19
+ }
20
+ function describeLayout(name) {
21
+ const layout = getLayout(name);
22
+ if (!layout) return void 0;
23
+ const schema = z.object({ layout: z.literal(layout.name), ...layout.shape, ...baseSlide });
24
+ return {
25
+ name: layout.name,
26
+ description: layout.description,
27
+ whenToUse: layout.whenToUse,
28
+ defaults: layout.defaults ?? {},
29
+ schema: zodToJsonSchema(schema, { $refStrategy: "none" }),
30
+ example: layout.example
31
+ };
32
+ }
33
+ function deckJsonSchema() {
34
+ return zodToJsonSchema(DeckSchema, { name: "ClassicDeck", $refStrategy: "none" });
35
+ }
36
+ var AUTHORING_GUIDE = `# Writing a Classic Homes deck
37
+
38
+ A deck is a YAML (or JSON) file: deck-level fields plus a list of slides. Each slide
39
+ picks a **layout** and fills its fields. Layouts own all styling \u2014 there is no way to
40
+ set colours, fonts or sizes directly, which is what keeps every deck on-brand.
41
+
42
+ ## Principles
43
+ - **One idea per slide.** The headline states the point. Detail goes in \`notes\`.
44
+ - **Short copy.** Titles under ~70 characters, bullets a few words, at most 6 bullets.
45
+ \`validate\` warns when a slide gets crowded \u2014 split it rather than shrinking text.
46
+ - **Speaker notes on every content slide.** Presenters press N (or P for presenter view).
47
+ - **Real photos over decoration.** Every image needs \`alt\` text.
48
+ - **Tracks** colour the accent: \`value\` (red, default) for the homeowner/value story,
49
+ \`build\` (teal) for construction/process, \`neutral\` (navy) for framing slides.
50
+ - **Backgrounds**: \`paper\` (default) for content; solid \`navy\`/\`red\`/\`teal\`/\`plum\`/\`grey\`/\`stone\`
51
+ for dividers. \`backgroundImage\` + \`overlay\` puts a photo behind title/section/statement slides.
52
+
53
+ ## Picking a layout
54
+ - **Numbers:** \`stats\` (2\u20136 headline figures, optional trend + sparkline), \`chart\` (a real chart \u2014
55
+ put the takeaway in the title), \`table\` (exact figures people compare).
56
+ - **Processes & structure:** \`flow\` (ordered steps with arrows), \`timeline\` (dated milestones, phases),
57
+ \`cycle\` (things that repeat), \`hierarchy\` (org chart / tree), \`matrix\` (2\xD72), \`tiers\`
58
+ (funnel or pyramid), \`walkthrough\` (photo per stage), \`journey\` (deck agenda).
59
+ - **Media:** \`split\` (text + photos), \`photos\`, \`image\` (with numbered \`hotspots\` for floor plans
60
+ and site maps), \`compare\` (before/after slider), \`video\` (YouTube/Vimeo), \`embed\` (any https page:
61
+ maps, dashboards, 3D tours, forms).
62
+ - **Words:** \`statement\`, \`bullets\`, \`cards\`, \`comparison\`, \`quote\`.
63
+ - **Wrap-up:** \`qr\` (send people somewhere), \`contacts\`, closing \`statement\`.
64
+
65
+ ## Charts
66
+ \`chart.labels\` are the categories; each series has one value per label. Pie, donut, funnel and
67
+ gauge use the first series; gauge needs \`max\`; scatter uses two series as x and y. Use
68
+ \`highlight\` to emphasise one category and \`format\`/\`unit\` instead of typing symbols into numbers.
69
+
70
+ ## Video & embeds
71
+ They need internet while presenting. Offline, in print and in PDF they show a poster (or
72
+ \`fallback\` screenshot) with a QR code. Some sites refuse to be framed \u2014 the "Open \u2197" link
73
+ always works; add a \`fallback\` image for anything critical.
74
+
75
+ ## Brand
76
+ \`brand.profile\` picks who the deck speaks for (default \`classic-homes\`). You may override
77
+ \`name\`, \`tagline\`, \`logo\`, \`logoOnDark\`, \`favicon\` and \`footer\`; colours come from the profile.
78
+ \`settings\` controls slide numbers, logo placement, a confidential label and kiosk auto-advance.
79
+
80
+ ## Presenting
81
+ Arrow keys / Space advance (and step through \`build: true\` reveals). N notes \xB7 P presenter window
82
+ (current + next slide, notes, timer) \xB7 O overview \xB7 F fullscreen \xB7 \`?kiosk=15\` on the URL loops
83
+ unattended. \`hidden: true\` skips a slide without deleting it.
84
+
85
+ ## Inline text
86
+ \`**bold**\`, \`*highlight*\` (accent colour), \`\\\`code\\\`\`, \`[label](https://\u2026)\`. No HTML.
87
+
88
+ ## Journey decks
89
+ Set \`journey.steps\` at deck level to get a numbered agenda. Then:
90
+ - a \`journey\` slide shows the full map,
91
+ - \`step: 3\` on any slide draws the vertical STEP rail,
92
+ - \`showJourney: true\` adds the compact "you are here" strip (great on \`section\` slides).
93
+
94
+ ## Typical shape
95
+ 1. \`title\`
96
+ 2. \`statement\` or \`journey\` (why we're here / the agenda)
97
+ 3. per part: \`section\` \u2192 2\u20135 content slides
98
+ 4. \`qr\` (where to go next) \u2192 \`contacts\` \u2192 closing \`statement\`
99
+ `;
100
+
101
+ export {
102
+ listLayouts,
103
+ describeLayout,
104
+ deckJsonSchema,
105
+ AUTHORING_GUIDE
106
+ };
107
+ //# sourceMappingURL=chunk-JFUJ47TP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/catalog.ts"],"sourcesContent":["import { z } from 'zod';\nimport { zodToJsonSchema } from 'zod-to-json-schema';\nimport {\n LAYOUT_CATEGORIES,\n LAYOUTS,\n getLayout,\n type LayoutCategory,\n type LayoutName,\n} from './layouts/index.js';\nimport { baseSlide } from './schema/common.js';\nimport { DeckSchema } from './schema/deck.js';\n\nexport interface LayoutSummary {\n name: string;\n category: LayoutCategory;\n description: string;\n whenToUse: string;\n}\n\nexport function listLayouts(): LayoutSummary[] {\n return LAYOUTS.map((l) => ({\n name: l.name,\n category: LAYOUT_CATEGORIES[l.name as LayoutName],\n description: l.description,\n whenToUse: l.whenToUse,\n }));\n}\n\n/** Full description of one layout: fields as JSON Schema plus a working example. */\nexport function describeLayout(name: string) {\n const layout = getLayout(name);\n if (!layout) return undefined;\n const schema = z.object({ layout: z.literal(layout.name), ...layout.shape, ...baseSlide });\n return {\n name: layout.name,\n description: layout.description,\n whenToUse: layout.whenToUse,\n defaults: layout.defaults ?? {},\n schema: zodToJsonSchema(schema, { $refStrategy: 'none' }),\n example: layout.example,\n };\n}\n\n/** JSON Schema for a whole deck (editor tooling, `$schema` in deck files). */\nexport function deckJsonSchema() {\n return zodToJsonSchema(DeckSchema, { name: 'ClassicDeck', $refStrategy: 'none' });\n}\n\n/** Authoring guidance shared by the CLI, MCP server and docs. */\nexport const AUTHORING_GUIDE = `# Writing a Classic Homes deck\n\nA deck is a YAML (or JSON) file: deck-level fields plus a list of slides. Each slide\npicks a **layout** and fills its fields. Layouts own all styling — there is no way to\nset colours, fonts or sizes directly, which is what keeps every deck on-brand.\n\n## Principles\n- **One idea per slide.** The headline states the point. Detail goes in \\`notes\\`.\n- **Short copy.** Titles under ~70 characters, bullets a few words, at most 6 bullets.\n \\`validate\\` warns when a slide gets crowded — split it rather than shrinking text.\n- **Speaker notes on every content slide.** Presenters press N (or P for presenter view).\n- **Real photos over decoration.** Every image needs \\`alt\\` text.\n- **Tracks** colour the accent: \\`value\\` (red, default) for the homeowner/value story,\n \\`build\\` (teal) for construction/process, \\`neutral\\` (navy) for framing slides.\n- **Backgrounds**: \\`paper\\` (default) for content; solid \\`navy\\`/\\`red\\`/\\`teal\\`/\\`plum\\`/\\`grey\\`/\\`stone\\`\n for dividers. \\`backgroundImage\\` + \\`overlay\\` puts a photo behind title/section/statement slides.\n\n## Picking a layout\n- **Numbers:** \\`stats\\` (2–6 headline figures, optional trend + sparkline), \\`chart\\` (a real chart —\n put the takeaway in the title), \\`table\\` (exact figures people compare).\n- **Processes & structure:** \\`flow\\` (ordered steps with arrows), \\`timeline\\` (dated milestones, phases),\n \\`cycle\\` (things that repeat), \\`hierarchy\\` (org chart / tree), \\`matrix\\` (2×2), \\`tiers\\`\n (funnel or pyramid), \\`walkthrough\\` (photo per stage), \\`journey\\` (deck agenda).\n- **Media:** \\`split\\` (text + photos), \\`photos\\`, \\`image\\` (with numbered \\`hotspots\\` for floor plans\n and site maps), \\`compare\\` (before/after slider), \\`video\\` (YouTube/Vimeo), \\`embed\\` (any https page:\n maps, dashboards, 3D tours, forms).\n- **Words:** \\`statement\\`, \\`bullets\\`, \\`cards\\`, \\`comparison\\`, \\`quote\\`.\n- **Wrap-up:** \\`qr\\` (send people somewhere), \\`contacts\\`, closing \\`statement\\`.\n\n## Charts\n\\`chart.labels\\` are the categories; each series has one value per label. Pie, donut, funnel and\ngauge use the first series; gauge needs \\`max\\`; scatter uses two series as x and y. Use\n\\`highlight\\` to emphasise one category and \\`format\\`/\\`unit\\` instead of typing symbols into numbers.\n\n## Video & embeds\nThey need internet while presenting. Offline, in print and in PDF they show a poster (or\n\\`fallback\\` screenshot) with a QR code. Some sites refuse to be framed — the \"Open ↗\" link\nalways works; add a \\`fallback\\` image for anything critical.\n\n## Brand\n\\`brand.profile\\` picks who the deck speaks for (default \\`classic-homes\\`). You may override\n\\`name\\`, \\`tagline\\`, \\`logo\\`, \\`logoOnDark\\`, \\`favicon\\` and \\`footer\\`; colours come from the profile.\n\\`settings\\` controls slide numbers, logo placement, a confidential label and kiosk auto-advance.\n\n## Presenting\nArrow keys / Space advance (and step through \\`build: true\\` reveals). N notes · P presenter window\n(current + next slide, notes, timer) · O overview · F fullscreen · \\`?kiosk=15\\` on the URL loops\nunattended. \\`hidden: true\\` skips a slide without deleting it.\n\n## Inline text\n\\`**bold**\\`, \\`*highlight*\\` (accent colour), \\`\\\\\\`code\\\\\\`\\`, \\`[label](https://…)\\`. No HTML.\n\n## Journey decks\nSet \\`journey.steps\\` at deck level to get a numbered agenda. Then:\n- a \\`journey\\` slide shows the full map,\n- \\`step: 3\\` on any slide draws the vertical STEP rail,\n- \\`showJourney: true\\` adds the compact \"you are here\" strip (great on \\`section\\` slides).\n\n## Typical shape\n1. \\`title\\`\n2. \\`statement\\` or \\`journey\\` (why we're here / the agenda)\n3. per part: \\`section\\` → 2–5 content slides\n4. \\`qr\\` (where to go next) → \\`contacts\\` → closing \\`statement\\`\n`;\n"],"mappings":";;;;;;;;;AAAA,SAAS,SAAS;AAClB,SAAS,uBAAuB;AAkBzB,SAAS,cAA+B;AAC7C,SAAO,QAAQ,IAAI,CAAC,OAAO;AAAA,IACzB,MAAM,EAAE;AAAA,IACR,UAAU,kBAAkB,EAAE,IAAkB;AAAA,IAChD,aAAa,EAAE;AAAA,IACf,WAAW,EAAE;AAAA,EACf,EAAE;AACJ;AAGO,SAAS,eAAe,MAAc;AAC3C,QAAM,SAAS,UAAU,IAAI;AAC7B,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,OAAO,IAAI,GAAG,GAAG,OAAO,OAAO,GAAG,UAAU,CAAC;AACzF,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,aAAa,OAAO;AAAA,IACpB,WAAW,OAAO;AAAA,IAClB,UAAU,OAAO,YAAY,CAAC;AAAA,IAC9B,QAAQ,gBAAgB,QAAQ,EAAE,cAAc,OAAO,CAAC;AAAA,IACxD,SAAS,OAAO;AAAA,EAClB;AACF;AAGO,SAAS,iBAAiB;AAC/B,SAAO,gBAAgB,YAAY,EAAE,MAAM,eAAe,cAAc,OAAO,CAAC;AAClF;AAGO,IAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;","names":[]}
@@ -2,11 +2,38 @@ import {
2
2
  parseDeckSource,
3
3
  renderDeck,
4
4
  stringifyDeck
5
- } from "./chunk-7FROOJCY.js";
5
+ } from "./chunk-PTTLL6PQ.js";
6
6
 
7
7
  // src/node/index.ts
8
8
  import { readFile, stat, writeFile } from "fs/promises";
9
- import { dirname, extname, isAbsolute, join, resolve } from "path";
9
+ import { dirname as dirname2, extname, isAbsolute, join, resolve as resolve2 } from "path";
10
+
11
+ // src/node/paths.ts
12
+ import { lstatSync, realpathSync } from "fs";
13
+ import { dirname, relative, resolve, sep } from "path";
14
+ function confinedPath(root, path) {
15
+ const canonicalRoot = realpathSync(root);
16
+ const absolute = resolve(path);
17
+ let ancestor = absolute;
18
+ for (; ; ) {
19
+ try {
20
+ const canonical = resolve(realpathSync(ancestor), relative(ancestor, absolute));
21
+ if (canonical !== canonicalRoot && !canonical.startsWith(canonicalRoot + sep)) {
22
+ throw new Error(`Path "${path}" is outside the deck root ${root}.`);
23
+ }
24
+ return canonical;
25
+ } catch (error) {
26
+ if (error.code !== "ENOENT" || ancestor === dirname(ancestor))
27
+ throw error;
28
+ if (lstatSync(ancestor, { throwIfNoEntry: false })?.isSymbolicLink()) {
29
+ throw new Error(`Cannot follow dangling symlink "${ancestor}".`);
30
+ }
31
+ ancestor = dirname(ancestor);
32
+ }
33
+ }
34
+ }
35
+
36
+ // src/node/index.ts
10
37
  var MIME = {
11
38
  ".jpg": "image/jpeg",
12
39
  ".jpeg": "image/jpeg",
@@ -32,7 +59,8 @@ function fileImageResolver(baseDir, options = {}) {
32
59
  warn(`${src} is ${(buf2.length / 1024).toFixed(0)} KB; consider a smaller copy.`);
33
60
  return `data:${type2};base64,${buf2.toString("base64")}`;
34
61
  }
35
- const path = isAbsolute(src) ? src : join(baseDir, src);
62
+ const candidate = isAbsolute(src) ? src : join(baseDir, src);
63
+ const path = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;
36
64
  const type = MIME[extname(path).toLowerCase()];
37
65
  if (!type)
38
66
  throw new Error(`Unsupported image type for "${src}". Use jpg, png, webp, gif, svg or avif.`);
@@ -55,22 +83,24 @@ async function saveDeckFile(path, deck) {
55
83
  await writeFile(path, text);
56
84
  }
57
85
  async function buildDeckFile(path, options = {}) {
58
- const abs = resolve(path);
86
+ const abs = options.rootDir ? confinedPath(options.rootDir, resolve2(path)) : resolve2(path);
59
87
  const deck = await loadDeckFile(abs);
60
88
  const result = await renderDeck(deck, {
61
89
  ...options,
62
- resolveImage: fileImageResolver(dirname(abs), options)
90
+ resolveImage: fileImageResolver(dirname2(abs), options)
63
91
  });
64
- const out = options.out ? resolve(options.out) : abs.replace(/\.(ya?ml|json)$/i, "") + ".html";
92
+ const candidate = options.out ? resolve2(options.out) : abs.replace(/\.(ya?ml|json)$/i, "") + ".html";
93
+ const out = options.rootDir ? confinedPath(options.rootDir, candidate) : candidate;
65
94
  await writeFile(out, result.html);
66
95
  return { ...result, out, bytes: (await stat(out)).size };
67
96
  }
68
97
 
69
98
  export {
99
+ confinedPath,
70
100
  IMAGE_WARN_BYTES,
71
101
  fileImageResolver,
72
102
  loadDeckFile,
73
103
  saveDeckFile,
74
104
  buildDeckFile
75
105
  };
76
- //# sourceMappingURL=chunk-MYY5GZC2.js.map
106
+ //# sourceMappingURL=chunk-L7G3VH7K.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 { 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 { 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"]}