@classic-homes/slides 0.2.0 → 0.3.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,38 +63,82 @@ 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
 
93
135
  The package ships an MCP server that edits deck files slide by slide and
94
- validates before every save, so an agent cannot leave a deck broken.
136
+ validates before every save, so an agent cannot leave a deck broken. It needs
137
+ `@modelcontextprotocol/sdk` (an optional peer, so apps that only render decks
138
+ don't install it).
95
139
 
96
140
  ```bash
97
- claude mcp add classic-slides -- npx -y @classic-homes/slides mcp --root ./decks
141
+ claude mcp add classic-slides -- npx -y -p @classic-homes/slides -p @modelcontextprotocol/sdk classic-slides mcp --root ./decks
98
142
  ```
99
143
 
100
144
  Then ask: _"Build a 10-slide deck in closing-day.yaml for first-time buyers
@@ -115,8 +159,13 @@ import { buildDeckFile, fileImageResolver } from '@classic-homes/slides/node';
115
159
  const { html } = await renderDeck(deck, { resolveImage: (src) => lookUp(src) });
116
160
  ```
117
161
 
118
- The core entry has no Node dependencies (it runs in Workers); `/node` adds
119
- 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.
120
169
 
121
170
  Rendered HTML embeds the deck source (minus image data), so
122
171
  `extractDeckSource(html)` recovers the editable deck.
@@ -2,11 +2,38 @@ import {
2
2
  parseDeckSource,
3
3
  renderDeck,
4
4
  stringifyDeck
5
- } from "./chunk-TM3TNVHB.js";
5
+ } from "./chunk-GXGDPBAR.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-CKVKX2ZD.js.map
106
+ //# sourceMappingURL=chunk-2IPLZ3SA.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"]}