@classic-homes/slides 0.2.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 ADDED
@@ -0,0 +1,129 @@
1
+ # @classic-homes/slides
2
+
3
+ Branded Classic Homes presentations from a simple deck file. Pick a layout per
4
+ slide, fill in the words and photos, and get one self-contained HTML file that
5
+ opens in any browser: brand fonts, colours and presenter tools included, no
6
+ internet needed.
7
+
8
+ Decks are data, layouts own the styling. There is no way to set colours, fonts
9
+ or sizes on a slide, which is what keeps every deck on-brand whether a person
10
+ or an AI agent wrote it.
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ npx classic-slides new decks/closing-day.yaml # basic starter
16
+ npx classic-slides new decks/orientation.yaml --starter journey
17
+ npx classic-slides dev decks/closing-day.yaml # live preview, reloads on save
18
+ npx classic-slides build decks/closing-day.yaml # → decks/closing-day.html
19
+ ```
20
+
21
+ Present: open the HTML, use arrow keys or Space. **N** shows speaker notes,
22
+ **F** goes fullscreen, `#slide-7` in the URL jumps to a slide. Print to PDF
23
+ gives one 16:9 page per slide.
24
+
25
+ ## A deck file
26
+
27
+ ```yaml
28
+ title: Closing Day
29
+ description: What to bring and expect at your closing appointment.
30
+
31
+ slides:
32
+ - layout: title
33
+ kicker: New Homeowner
34
+ title: Closing day, *simplified*
35
+ lead: Everything you need for a smooth signing.
36
+
37
+ - layout: bullets
38
+ title: Bring these with you
39
+ marker: check
40
+ items:
41
+ - Photo ID for everyone on the loan
42
+ - text: Certified funds
43
+ sub: Never wire money based on an email — call to confirm
44
+ notes: |
45
+ Stress wire fraud. Title companies never change wiring instructions by email.
46
+
47
+ - layout: split
48
+ title: Where to go
49
+ groups:
50
+ - items: [Arrive 10 minutes early, Plan for about an hour]
51
+ images:
52
+ - { src: images/title-office.jpg, alt: Title company front entrance }
53
+ ```
54
+
55
+ Images are paths relative to the deck file (or https URLs); they are inlined
56
+ at build time. Keep photos around 2000px wide — `build` warns about large ones.
57
+
58
+ Inline text supports `**bold**`, `*highlight*` (brand accent), `` `code` `` and
59
+ `[links](https://…)`. HTML is always escaped.
60
+
61
+ ## Layouts
62
+
63
+ Run `classic-slides layouts` for the list, `classic-slides layouts <name>` for
64
+ every field plus an example.
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 |
83
+
84
+ 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`.
87
+
88
+ `classic-slides guide` prints the writing rules; `classic-slides validate`
89
+ flags errors and crowded slides.
90
+
91
+ ## With an AI agent (MCP)
92
+
93
+ 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.
95
+
96
+ ```bash
97
+ claude mcp add classic-slides -- npx -y @classic-homes/slides mcp --root ./decks
98
+ ```
99
+
100
+ Then ask: _"Build a 10-slide deck in closing-day.yaml for first-time buyers
101
+ about closing day, then build it."_
102
+
103
+ Tools: `list_layouts`, `get_layout`, `get_authoring_guide`, `create_deck`,
104
+ `read_deck`, `update_deck`, `add_slide`, `add_slides`, `update_slide`,
105
+ `move_slide`, `delete_slide`, `validate_deck`, `build_deck`. Prompt:
106
+ `build_deck_from_outline`. File access is confined to `--root` (default: the
107
+ working directory).
108
+
109
+ ## As a library
110
+
111
+ ```ts
112
+ import { renderDeck, validateDeck, listLayouts } from '@classic-homes/slides';
113
+ import { buildDeckFile, fileImageResolver } from '@classic-homes/slides/node';
114
+
115
+ const { html } = await renderDeck(deck, { resolveImage: (src) => lookUp(src) });
116
+ ```
117
+
118
+ The core entry has no Node dependencies (it runs in Workers); `/node` adds
119
+ file-based image loading and `buildDeckFile`.
120
+
121
+ Rendered HTML embeds the deck source (minus image data), so
122
+ `extractDeckSource(html)` recovers the editable deck.
123
+
124
+ ## Brand
125
+
126
+ Colours come from `@classic-homes/theme-tokens`. Fonts (Figtree, Spectral) are
127
+ embedded from `assets/fonts`. Styling lives in `src/styles.ts`; add a layout in
128
+ `src/layouts/` and register it in `src/layouts/index.ts` — its example is
129
+ tested automatically.
@@ -0,0 +1,76 @@
1
+ import {
2
+ parseDeckSource,
3
+ renderDeck,
4
+ stringifyDeck
5
+ } from "./chunk-TM3TNVHB.js";
6
+
7
+ // src/node/index.ts
8
+ import { readFile, stat, writeFile } from "fs/promises";
9
+ import { dirname, extname, isAbsolute, join, resolve } from "path";
10
+ var MIME = {
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".png": "image/png",
14
+ ".webp": "image/webp",
15
+ ".gif": "image/gif",
16
+ ".svg": "image/svg+xml",
17
+ ".avif": "image/avif"
18
+ };
19
+ var IMAGE_WARN_BYTES = 800 * 1024;
20
+ function fileImageResolver(baseDir, options = {}) {
21
+ const warn = options.onWarning ?? (() => {
22
+ });
23
+ return async (src) => {
24
+ if (src.startsWith("data:")) return src;
25
+ if (/^https?:/.test(src)) {
26
+ if (options.inlineRemote === false) return src;
27
+ const res = await fetch(src);
28
+ if (!res.ok) throw new Error(`Image ${src} returned HTTP ${res.status}`);
29
+ const type2 = res.headers.get("content-type") ?? "application/octet-stream";
30
+ const buf2 = Buffer.from(await res.arrayBuffer());
31
+ if (buf2.length > IMAGE_WARN_BYTES)
32
+ warn(`${src} is ${(buf2.length / 1024).toFixed(0)} KB; consider a smaller copy.`);
33
+ return `data:${type2};base64,${buf2.toString("base64")}`;
34
+ }
35
+ const path = isAbsolute(src) ? src : join(baseDir, src);
36
+ const type = MIME[extname(path).toLowerCase()];
37
+ if (!type)
38
+ throw new Error(`Unsupported image type for "${src}". Use jpg, png, webp, gif, svg or avif.`);
39
+ let buf;
40
+ try {
41
+ buf = await readFile(path);
42
+ } catch {
43
+ throw new Error(`Image not found: "${src}" (looked in ${path})`);
44
+ }
45
+ if (buf.length > IMAGE_WARN_BYTES)
46
+ warn(`${src} is ${(buf.length / 1024).toFixed(0)} KB; consider resizing to ~2000px wide.`);
47
+ return `data:${type};base64,${buf.toString("base64")}`;
48
+ };
49
+ }
50
+ async function loadDeckFile(path) {
51
+ return parseDeckSource(await readFile(path, "utf8"));
52
+ }
53
+ async function saveDeckFile(path, deck) {
54
+ const text = path.endsWith(".json") ? JSON.stringify(deck, null, 2) + "\n" : stringifyDeck(deck);
55
+ await writeFile(path, text);
56
+ }
57
+ async function buildDeckFile(path, options = {}) {
58
+ const abs = resolve(path);
59
+ const deck = await loadDeckFile(abs);
60
+ const result = await renderDeck(deck, {
61
+ ...options,
62
+ resolveImage: fileImageResolver(dirname(abs), options)
63
+ });
64
+ const out = options.out ? resolve(options.out) : abs.replace(/\.(ya?ml|json)$/i, "") + ".html";
65
+ await writeFile(out, result.html);
66
+ return { ...result, out, bytes: (await stat(out)).size };
67
+ }
68
+
69
+ export {
70
+ IMAGE_WARN_BYTES,
71
+ fileImageResolver,
72
+ loadDeckFile,
73
+ saveDeckFile,
74
+ buildDeckFile
75
+ };
76
+ //# sourceMappingURL=chunk-CKVKX2ZD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/node/index.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 { 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 /** 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 path = isAbsolute(src) ? src : join(baseDir, src);\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 = 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 out = options.out ? resolve(options.out) : abs.replace(/\\.(ya?ml|json)$/i, '') + '.html';\n await writeFile(out, result.html);\n return { ...result, out, bytes: (await stat(out)).size };\n}\n\nexport { renderDeck };\n"],"mappings":";;;;;;;AACA,SAAS,UAAU,MAAM,iBAAiB;AAC1C,SAAS,SAAS,SAAS,YAAY,MAAM,eAAe;AAS5D,IAAM,OAA+B;AAAA,EACnC,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,SAAS;AACX;AAGO,IAAM,mBAAmB,MAAM;AAS/B,SAAS,kBACd,SACA,UAA+B,CAAC,GACjB;AACf,QAAM,OAAO,QAAQ,cAAc,MAAM;AAAA,EAAC;AAC1C,SAAO,OAAO,QAAQ;AACpB,QAAI,IAAI,WAAW,OAAO,EAAG,QAAO;AACpC,QAAI,WAAW,KAAK,GAAG,GAAG;AACxB,UAAI,QAAQ,iBAAiB,MAAO,QAAO;AAC3C,YAAM,MAAM,MAAM,MAAM,GAAG;AAC3B,UAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,SAAS,GAAG,kBAAkB,IAAI,MAAM,EAAE;AACvE,YAAMA,QAAO,IAAI,QAAQ,IAAI,cAAc,KAAK;AAChD,YAAMC,OAAM,OAAO,KAAK,MAAM,IAAI,YAAY,CAAC;AAC/C,UAAIA,KAAI,SAAS;AACf,aAAK,GAAG,GAAG,QAAQA,KAAI,SAAS,MAAM,QAAQ,CAAC,CAAC,+BAA+B;AACjF,aAAO,QAAQD,KAAI,WAAWC,KAAI,SAAS,QAAQ,CAAC;AAAA,IACtD;AACA,UAAM,OAAO,WAAW,GAAG,IAAI,MAAM,KAAK,SAAS,GAAG;AACtD,UAAM,OAAO,KAAK,QAAQ,IAAI,EAAE,YAAY,CAAC;AAC7C,QAAI,CAAC;AACH,YAAM,IAAI,MAAM,+BAA+B,GAAG,0CAA0C;AAC9F,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,SAAS,IAAI;AAAA,IAC3B,QAAQ;AACN,YAAM,IAAI,MAAM,qBAAqB,GAAG,gBAAgB,IAAI,GAAG;AAAA,IACjE;AACA,QAAI,IAAI,SAAS;AACf,WAAK,GAAG,GAAG,QAAQ,IAAI,SAAS,MAAM,QAAQ,CAAC,CAAC,yCAAyC;AAC3F,WAAO,QAAQ,IAAI,WAAW,IAAI,SAAS,QAAQ,CAAC;AAAA,EACtD;AACF;AAEA,eAAsB,aAAa,MAAgC;AACjE,SAAO,gBAAgB,MAAM,SAAS,MAAM,MAAM,CAAC;AACrD;AAEA,eAAsB,aAAa,MAAc,MAA8B;AAC7E,QAAM,OAAO,KAAK,SAAS,OAAO,IAAI,KAAK,UAAU,MAAM,MAAM,CAAC,IAAI,OAAO,cAAc,IAAI;AAC/F,QAAM,UAAU,MAAM,IAAI;AAC5B;AAOA,eAAsB,cACpB,MACA,UAAwB,CAAC,GAC+B;AACxD,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,OAAO,MAAM,aAAa,GAAG;AACnC,QAAM,SAAS,MAAM,WAAW,MAAM;AAAA,IACpC,GAAG;AAAA,IACH,cAAc,kBAAkB,QAAQ,GAAG,GAAG,OAAO;AAAA,EACvD,CAAC;AACD,QAAM,MAAM,QAAQ,MAAM,QAAQ,QAAQ,GAAG,IAAI,IAAI,QAAQ,oBAAoB,EAAE,IAAI;AACvF,QAAM,UAAU,KAAK,OAAO,IAAI;AAChC,SAAO,EAAE,GAAG,QAAQ,KAAK,QAAQ,MAAM,KAAK,GAAG,GAAG,KAAK;AACzD;","names":["type","buf"]}