slidemind 0.1.9 → 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/CLAUDE.md +3 -0
- package/bin/slidemind.mjs +450 -55
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +21 -3
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// In-deck freeform slides for fill-template decks.
|
|
2
|
+
//
|
|
3
|
+
// Contract: a proposal deck's slides/ may carry create-slides freeform JSX
|
|
4
|
+
// (NN-name.jsx composing the design bundle's layouts) directly next to the
|
|
5
|
+
// master-backed slides — no sibling deck. The rendered native artifact lives
|
|
6
|
+
// beside its source as NN-name.pptx; the fill engine inserts that artifact at
|
|
7
|
+
// the filename position like any native slide. This module keeps the
|
|
8
|
+
// artifacts fresh: stale or missing renders go through the add-in's render
|
|
9
|
+
// path (the ONLY host-dependent step of a proposal build) and are folded to
|
|
10
|
+
// pruned one-slide packages. Unchanged freeform slides rebuild fully offline.
|
|
11
|
+
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { unzip, readText } from "../zip.mjs";
|
|
19
|
+
import { slideOrderPaths } from "../ooxml.mjs";
|
|
20
|
+
import { foldPickedSlides } from "../slide-swap.mjs";
|
|
21
|
+
import { layoutNumber } from "./parse-slides.mjs";
|
|
22
|
+
|
|
23
|
+
const SIDE_CAR = ".freeform-renders.json";
|
|
24
|
+
|
|
25
|
+
// The template's Challenge/Solution slides carry FIXED titles; freeform
|
|
26
|
+
// replacements must keep them so the deck reads like the master.
|
|
27
|
+
const FIXED_TITLES = {
|
|
28
|
+
en: [
|
|
29
|
+
{ stem: /challenge/i, title: "Challenge" },
|
|
30
|
+
{ stem: /solution/i, title: "Solution" },
|
|
31
|
+
],
|
|
32
|
+
de: [
|
|
33
|
+
{ stem: /challenge|herausforderung/i, title: "Herausforderung" },
|
|
34
|
+
{ stem: /solution|loesung|lösung/i, title: "Lösung" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function isFreeformSource(src) {
|
|
39
|
+
const comp = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1];
|
|
40
|
+
return Boolean(comp) && layoutNumber(comp, "en") === null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function contentHash(parts) {
|
|
44
|
+
const h = crypto.createHash("sha256");
|
|
45
|
+
for (const p of parts) h.update(p ?? "");
|
|
46
|
+
return h.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function slideText(bytes) {
|
|
50
|
+
const files = unzip(bytes);
|
|
51
|
+
const slide = slideOrderPaths(files)[0];
|
|
52
|
+
const xml = slide ? readText(files, slide) : "";
|
|
53
|
+
return [...(xml ?? "").matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => m[1]).join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fixedTitleWarning(stem, bytes, locale) {
|
|
57
|
+
const rules = FIXED_TITLES[locale] ?? FIXED_TITLES.en;
|
|
58
|
+
const rule = rules.find((r) => r.stem.test(stem));
|
|
59
|
+
if (!rule) return null;
|
|
60
|
+
if (slideText(bytes).includes(rule.title)) return null;
|
|
61
|
+
return (
|
|
62
|
+
`${stem}.pptx: the ${locale.toUpperCase()} template fixes this slide's title to ` +
|
|
63
|
+
`"${rule.title}" — set title="${rule.title}" in ${stem}.jsx and rebuild`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bring every freeform slide's rendered artifact up to date. Returns
|
|
69
|
+
* `{ rendered, warnings }`; throws with the offending file named when a stale
|
|
70
|
+
* render is needed but the paired backend cannot render.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureFreeformRenders({ deckDir, locale = "en", srcDir, onLog = () => {} }) {
|
|
73
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
74
|
+
const jsxFiles = fs.existsSync(slidesDir)
|
|
75
|
+
? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
|
|
76
|
+
: [];
|
|
77
|
+
const items = [];
|
|
78
|
+
for (const file of jsxFiles) {
|
|
79
|
+
const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
|
|
80
|
+
if (isFreeformSource(src)) items.push({ file, stem: file.replace(/\.jsx$/, ""), src });
|
|
81
|
+
}
|
|
82
|
+
const warnings = [];
|
|
83
|
+
if (items.length === 0) return { rendered: 0, warnings };
|
|
84
|
+
|
|
85
|
+
const sideCarPath = path.join(deckDir, SIDE_CAR);
|
|
86
|
+
let sideCar = {};
|
|
87
|
+
try {
|
|
88
|
+
sideCar = JSON.parse(fs.readFileSync(sideCarPath, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
sideCar = {};
|
|
91
|
+
}
|
|
92
|
+
// The render depends on the design bundle, so the deck's bound etag is part
|
|
93
|
+
// of the freshness key — a CD update re-renders the freeform slides.
|
|
94
|
+
let etag = "";
|
|
95
|
+
try {
|
|
96
|
+
const ref = JSON.parse(fs.readFileSync(path.join(deckDir, ".bundle-ref.json"), "utf8"));
|
|
97
|
+
etag = ref?.etag ?? "";
|
|
98
|
+
} catch {
|
|
99
|
+
etag = "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stale = items.filter((it) => {
|
|
103
|
+
const target = path.join(slidesDir, `${it.stem}.pptx`);
|
|
104
|
+
return !fs.existsSync(target) || sideCar[it.stem]?.hash !== contentHash([it.src, etag]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (stale.length > 0) {
|
|
108
|
+
const bp = await import(
|
|
109
|
+
pathToFileURL(path.join(srcDir, "bridge", "build-and-push.mjs")).href
|
|
110
|
+
);
|
|
111
|
+
for (const it of stale) {
|
|
112
|
+
onLog(`rendering freeform slide ${it.file} via the add-in…`);
|
|
113
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-freeform-"));
|
|
114
|
+
try {
|
|
115
|
+
fs.mkdirSync(path.join(tmp, "slides"), { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(tmp, "slides", `01-${it.stem}.jsx`), it.src);
|
|
117
|
+
fs.writeFileSync(path.join(tmp, "plan.md"), `---\nlocale: ${locale}\n---\n`);
|
|
118
|
+
const assetsDir = path.join(deckDir, "assets");
|
|
119
|
+
if (fs.existsSync(assetsDir)) {
|
|
120
|
+
fs.cpSync(assetsDir, path.join(tmp, "assets"), { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
const refPath = path.join(deckDir, ".bundle-ref.json");
|
|
123
|
+
if (fs.existsSync(refPath)) {
|
|
124
|
+
fs.copyFileSync(refPath, path.join(tmp, ".bundle-ref.json"));
|
|
125
|
+
}
|
|
126
|
+
const outPath = path.join(tmp, "render.pptx");
|
|
127
|
+
try {
|
|
128
|
+
await bp.buildPptxFile({ deckDir: tmp, outPath, onProgress: () => {} });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
throw new Error(
|
|
132
|
+
`freeform slide ${it.file} needs a render but the paired add-in could not ` +
|
|
133
|
+
`build it: ${msg}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const folded = foldPickedSlides(fs.readFileSync(outPath), [1], {
|
|
137
|
+
sourceName: it.stem,
|
|
138
|
+
})[0];
|
|
139
|
+
fs.writeFileSync(path.join(slidesDir, `${it.stem}.pptx`), folded.bytes);
|
|
140
|
+
sideCar[it.stem] = { hash: contentHash([it.src, etag]) };
|
|
141
|
+
onLog(` ✓ ${it.stem}.pptx refreshed`);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
fs.writeFileSync(sideCarPath, JSON.stringify(sideCar, null, 2) + "\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const it of items) {
|
|
150
|
+
const target = path.join(slidesDir, `${it.stem}.pptx`);
|
|
151
|
+
if (!fs.existsSync(target)) continue;
|
|
152
|
+
const warn = fixedTitleWarning(it.stem, fs.readFileSync(target), locale);
|
|
153
|
+
if (warn) warnings.push(warn);
|
|
154
|
+
}
|
|
155
|
+
return { rendered: stale.length, warnings };
|
|
156
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Parser for proposal slide files. Proposal JSX is declarative by contract —
|
|
2
|
+
// one default export returning exactly one <SlideProposalLayoutNN …/> element
|
|
3
|
+
// whose props are data literals. That constraint is what makes a real JSX
|
|
4
|
+
// runtime unnecessary: attributes are scanned textually and brace expressions
|
|
5
|
+
// are evaluated as plain JS literals in an empty vm sandbox.
|
|
6
|
+
|
|
7
|
+
import vm from "node:vm";
|
|
8
|
+
|
|
9
|
+
// Accepted component spellings → template slide number.
|
|
10
|
+
// SlideProposalLayout07 (canonical, locale-neutral)
|
|
11
|
+
// SlideProposalEn07Slide7 / SlideProposalDe07Slide7 (generated concrete names)
|
|
12
|
+
export function layoutNumber(component, locale) {
|
|
13
|
+
let m = component.match(/^SlideProposalLayout(\d{2})$/);
|
|
14
|
+
if (m) return { no: Number(m[1]) };
|
|
15
|
+
m = component.match(/^SlideProposal(En|De)(\d{2})Slide\d+$/);
|
|
16
|
+
if (m) {
|
|
17
|
+
const compLocale = m[1].toLowerCase();
|
|
18
|
+
return {
|
|
19
|
+
no: Number(m[2]),
|
|
20
|
+
warn:
|
|
21
|
+
compLocale === locale
|
|
22
|
+
? `use the canonical <SlideProposalLayout${m[2]}> alias`
|
|
23
|
+
: `component locale "${compLocale}" does not match deck locale "${locale}"`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripComments(src) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let i = 0;
|
|
32
|
+
let str = null; // ", ', or `
|
|
33
|
+
while (i < src.length) {
|
|
34
|
+
const c = src[i];
|
|
35
|
+
const c2 = src.slice(i, i + 2);
|
|
36
|
+
if (str) {
|
|
37
|
+
out += c;
|
|
38
|
+
if (c === "\\") { out += src[i + 1] ?? ""; i += 2; continue; }
|
|
39
|
+
if (c === str) str = null;
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (c === '"' || c === "'" || c === "`") { str = c; out += c; i++; continue; }
|
|
44
|
+
if (c2 === "//") { const nl = src.indexOf("\n", i); i = nl < 0 ? src.length : nl; continue; }
|
|
45
|
+
if (c2 === "/*") { const end = src.indexOf("*/", i + 2); i = end < 0 ? src.length : end + 2; continue; }
|
|
46
|
+
out += c;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Scan one balanced {...} starting at src[start] === "{", honoring strings.
|
|
53
|
+
function balancedBraces(src, start) {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let str = null;
|
|
56
|
+
for (let i = start; i < src.length; i++) {
|
|
57
|
+
const c = src[i];
|
|
58
|
+
if (str) {
|
|
59
|
+
if (c === "\\") { i++; continue; }
|
|
60
|
+
if (c === str) str = null;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === '"' || c === "'" || c === "`") { str = c; continue; }
|
|
64
|
+
if (c === "{") depth++;
|
|
65
|
+
if (c === "}") {
|
|
66
|
+
depth--;
|
|
67
|
+
if (depth === 0) return src.slice(start + 1, i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function evalLiteral(expr, file) {
|
|
74
|
+
try {
|
|
75
|
+
const value = vm.runInNewContext(`(${expr})`, Object.create(null), {
|
|
76
|
+
timeout: 250,
|
|
77
|
+
filename: file,
|
|
78
|
+
});
|
|
79
|
+
// Values cross a vm realm boundary — round-trip through JSON so callers
|
|
80
|
+
// get plain host-realm data (and anything non-data fails loudly here).
|
|
81
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`${file}: cannot evaluate prop expression (${err.message}). ` +
|
|
84
|
+
`Proposal props must be data literals — strings, numbers, arrays, objects.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parse one slide file → { component, props, warnings }.
|
|
89
|
+
export function parseSlideFile(source, file) {
|
|
90
|
+
const src = stripComments(source);
|
|
91
|
+
const warnings = [];
|
|
92
|
+
|
|
93
|
+
const open = src.match(/<([A-Z][A-Za-z0-9]*)/);
|
|
94
|
+
if (!open) throw new Error(`${file}: no layout element found`);
|
|
95
|
+
const component = open[1];
|
|
96
|
+
|
|
97
|
+
// Attribute scan from after the component name to the element's close.
|
|
98
|
+
let i = open.index + open[0].length;
|
|
99
|
+
const props = {};
|
|
100
|
+
let selfClosed = false;
|
|
101
|
+
while (i < src.length) {
|
|
102
|
+
while (/\s/.test(src[i])) i++;
|
|
103
|
+
if (src.slice(i, i + 2) === "/>") { selfClosed = true; break; }
|
|
104
|
+
if (src[i] === ">") break;
|
|
105
|
+
const name = src.slice(i).match(/^([A-Za-z_][A-Za-z0-9_]*)/);
|
|
106
|
+
if (!name) throw new Error(`${file}: cannot parse props near "${src.slice(i, i + 30)}"`);
|
|
107
|
+
i += name[1].length;
|
|
108
|
+
if (src[i] !== "=") {
|
|
109
|
+
props[name[1]] = true; // bare boolean prop
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
i++;
|
|
113
|
+
if (src[i] === '"' || src[i] === "'") {
|
|
114
|
+
const q = src[i];
|
|
115
|
+
const end = src.indexOf(q, i + 1);
|
|
116
|
+
if (end < 0) throw new Error(`${file}: unterminated string for prop ${name[1]}`);
|
|
117
|
+
// JSX string attributes are literal text (entities decoded by React; the
|
|
118
|
+
// masters' fields never rely on them, so raw text is taken as-is).
|
|
119
|
+
props[name[1]] = src.slice(i + 1, end);
|
|
120
|
+
i = end + 1;
|
|
121
|
+
} else if (src[i] === "{") {
|
|
122
|
+
const expr = balancedBraces(src, i);
|
|
123
|
+
if (expr === null) throw new Error(`${file}: unbalanced braces for prop ${name[1]}`);
|
|
124
|
+
props[name[1]] = evalLiteral(expr, file);
|
|
125
|
+
i += expr.length + 2;
|
|
126
|
+
} else {
|
|
127
|
+
throw new Error(`${file}: prop ${name[1]} must be "string" or {literal}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!selfClosed) {
|
|
132
|
+
// Children are only legal on freeform slides, which this engine does not
|
|
133
|
+
// build — surface it as a parse error with the actionable rule.
|
|
134
|
+
const closing = `</${component}>`;
|
|
135
|
+
const closeIdx = src.indexOf(closing);
|
|
136
|
+
const inner = closeIdx > 0 ? src.slice(i + 1, closeIdx).trim() : "";
|
|
137
|
+
if (inner) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${file}: <${component}> has children. Master-backed proposal slides are ` +
|
|
140
|
+
`self-closing props-only elements; freeform content belongs to create-slides.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A second element in the same file means the slide is not declarative data.
|
|
146
|
+
const rest = src.slice(i);
|
|
147
|
+
const second = rest.match(/<([A-Z][A-Za-z0-9]*)/);
|
|
148
|
+
if (second) {
|
|
149
|
+
throw new Error(`${file}: more than one component element (<${second[1]}>) — one slide, one element.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
delete props.assetsPath;
|
|
153
|
+
return { component, props, warnings };
|
|
154
|
+
}
|