slidemind 0.2.2 → 0.2.4
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-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +8 -1
- package/AGENTS.md +88 -21
- package/README.md +82 -34
- package/assets/app-icon.png +0 -0
- package/bin/slidemind.mjs +1497 -298
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +32 -28
- package/skills/search/SKILL.md +28 -22
- package/src/bridge/build-and-push.mjs +19 -6
- package/src/bridge/client.mjs +897 -107
- package/src/bridge/config.mjs +2 -2
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/{pair.mjs → http-json.mjs} +9 -24
- package/src/bridge/setup-claim.mjs +127 -0
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/installers/claude.mjs +24 -0
- package/src/installers/codex.mjs +60 -4
- package/src/installers/lib.mjs +49 -19
- package/src/installers/opencode.mjs +16 -0
- package/src/installers/tui.mjs +21 -10
- package/src/pptx/fill/engine.mjs +112 -134
package/src/installers/lib.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const CURSOR_SHOW = "\x1b[?25h";
|
|
|
113
113
|
// uniform.
|
|
114
114
|
function startWordmarkAnimation() {
|
|
115
115
|
const lines = wordmarkFrame(0);
|
|
116
|
-
|
|
116
|
+
tui.out("\n" + lines.join("\n") + "\n\n");
|
|
117
117
|
return () => {};
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -125,6 +125,30 @@ export function isForce(flags) {
|
|
|
125
125
|
return flags.includes("--force") || flags.includes("-F");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Is `name` an executable on the user's PATH? Used as the second half of
|
|
129
|
+
// harness detection: a coding agent's config dir (~/.claude, ~/.codex,
|
|
130
|
+
// ~/.config/opencode) is created by USE, not by install, so a freshly installed
|
|
131
|
+
// agent that has never been launched has no directory at all — its binary is
|
|
132
|
+
// the only evidence it exists. Resolved by walking PATH rather than shelling
|
|
133
|
+
// out to which/where, so it costs nothing and behaves the same on Windows.
|
|
134
|
+
export function binaryOnPath(name) {
|
|
135
|
+
const exts =
|
|
136
|
+
process.platform === "win32"
|
|
137
|
+
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
|
|
138
|
+
: [""];
|
|
139
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
140
|
+
if (!dir) continue;
|
|
141
|
+
for (const ext of exts) {
|
|
142
|
+
try {
|
|
143
|
+
if (fs.statSync(path.join(dir, name + ext)).isFile()) return true;
|
|
144
|
+
} catch {
|
|
145
|
+
/* not there / unreadable — keep looking */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
export function expandHome(p) {
|
|
129
153
|
if (p.startsWith("~/")) return path.join(homeDir(), p.slice(2));
|
|
130
154
|
return p;
|
|
@@ -234,6 +258,12 @@ let _startedAt = 0;
|
|
|
234
258
|
let _stopWordmark = null;
|
|
235
259
|
|
|
236
260
|
export const ui = {
|
|
261
|
+
// Suppress ALL decorative output. `slidemind setup --json` runs the real
|
|
262
|
+
// installers but owns stdout for its machine-readable payload, so the rail
|
|
263
|
+
// would corrupt the very thing the caller is parsing.
|
|
264
|
+
silence(value = true) {
|
|
265
|
+
tui.setSilent(value);
|
|
266
|
+
},
|
|
237
267
|
// `wordmark: false` skips painting the wordmark — used when the caller
|
|
238
268
|
// (e.g. the install.sh bootstrap) already printed it, so we only emit the
|
|
239
269
|
// `v… · action` intro line and continue the rail.
|
|
@@ -276,12 +306,12 @@ export const ui = {
|
|
|
276
306
|
// Manual emission so we can animate the title in place. Symbol
|
|
277
307
|
// sits at col 1; " " padding lands the title at col 4.
|
|
278
308
|
tui.bumpRow(1);
|
|
279
|
-
|
|
309
|
+
tui.out(tui.rail() + "\n");
|
|
280
310
|
tui.bumpRow(1);
|
|
281
|
-
|
|
311
|
+
tui.out(SYM.step + " ");
|
|
282
312
|
await tui.shimmer(title, { startCol: 4, restColor: (s) => s });
|
|
283
|
-
if (sub)
|
|
284
|
-
|
|
313
|
+
if (sub) tui.out(" " + brand.slate(tildify(sub)));
|
|
314
|
+
tui.out("\n");
|
|
285
315
|
},
|
|
286
316
|
step({ status = "ok", label, value, note }) {
|
|
287
317
|
const symbol =
|
|
@@ -328,15 +358,15 @@ export const ui = {
|
|
|
328
358
|
|
|
329
359
|
const boxLine = (content) => {
|
|
330
360
|
const pad = Math.max(0, inner - visibleLength(content));
|
|
331
|
-
|
|
361
|
+
tui.out(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
|
|
332
362
|
};
|
|
333
363
|
|
|
334
|
-
|
|
364
|
+
tui.out("\n");
|
|
335
365
|
|
|
336
366
|
// Top: ╭─ installed ────╮
|
|
337
367
|
const labelText = " installed ";
|
|
338
368
|
const fillerLen = Math.max(2, inner - 1 - labelText.length);
|
|
339
|
-
|
|
369
|
+
tui.out(
|
|
340
370
|
slate("╭─") +
|
|
341
371
|
dim(labelText) +
|
|
342
372
|
slate("─".repeat(fillerLen)) +
|
|
@@ -359,10 +389,10 @@ export const ui = {
|
|
|
359
389
|
boxLine(" " + dim(stats));
|
|
360
390
|
|
|
361
391
|
// Bottom: ╰────────╯
|
|
362
|
-
|
|
392
|
+
tui.out(slate("╰" + "─".repeat(inner) + "╯") + "\n");
|
|
363
393
|
|
|
364
|
-
|
|
365
|
-
|
|
394
|
+
tui.out("\n");
|
|
395
|
+
tui.out(`${SYM.success} ${brand.bold(action)}\n`);
|
|
366
396
|
|
|
367
397
|
// Stop the wordmark animation last — that resets the scroll region
|
|
368
398
|
// and shows the cursor. The wordmark itself stays painted at the
|
|
@@ -401,16 +431,16 @@ export const ui = {
|
|
|
401
431
|
// Reserve two rows: rail spacer + the active spinner line. Both
|
|
402
432
|
// count toward the gradient row counter.
|
|
403
433
|
tui.bumpRow(1);
|
|
404
|
-
|
|
434
|
+
tui.out(tui.rail() + "\n");
|
|
405
435
|
tui.bumpRow(1);
|
|
406
|
-
|
|
436
|
+
tui.out(
|
|
407
437
|
`${brand.techBlue40(SPINNER_FRAMES[0])} ${fmtStep({ label, value, note })}`,
|
|
408
438
|
);
|
|
409
439
|
let frame = 0;
|
|
410
440
|
const timer = setInterval(() => {
|
|
411
441
|
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
412
|
-
|
|
413
|
-
|
|
442
|
+
tui.out("\r\x1b[2K");
|
|
443
|
+
tui.out(
|
|
414
444
|
`${brand.techBlue40(SPINNER_FRAMES[frame])} ${fmtStep({ label, value, note })}`,
|
|
415
445
|
);
|
|
416
446
|
}, 80);
|
|
@@ -418,15 +448,15 @@ export const ui = {
|
|
|
418
448
|
const result = await fn();
|
|
419
449
|
const time = Date.now() - start;
|
|
420
450
|
clearInterval(timer);
|
|
421
|
-
|
|
422
|
-
|
|
451
|
+
tui.out("\r\x1b[2K");
|
|
452
|
+
tui.out(
|
|
423
453
|
`${SYM.success} ${fmtStep({ label, value, note: finalNote ?? note, time })}\n`,
|
|
424
454
|
);
|
|
425
455
|
return result;
|
|
426
456
|
} catch (err) {
|
|
427
457
|
clearInterval(timer);
|
|
428
|
-
|
|
429
|
-
|
|
458
|
+
tui.out("\r\x1b[2K");
|
|
459
|
+
tui.out(
|
|
430
460
|
`${SYM.error} ${fmtStep({ label, value, note: "failed" })}\n`,
|
|
431
461
|
);
|
|
432
462
|
throw err;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import {
|
|
20
|
+
binaryOnPath,
|
|
20
21
|
expandHome,
|
|
21
22
|
installDir,
|
|
22
23
|
isForce,
|
|
@@ -117,6 +118,21 @@ function removeLegacyArtifacts() {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// See claude.mjs `detect()`. opencode has no slidemind plugin channel, so the
|
|
122
|
+
// skills can only ever come from here.
|
|
123
|
+
export function detect() {
|
|
124
|
+
const root = opencodeRoot();
|
|
125
|
+
const configured = fs.existsSync(root);
|
|
126
|
+
const onPath = binaryOnPath("opencode");
|
|
127
|
+
return {
|
|
128
|
+
root,
|
|
129
|
+
configured,
|
|
130
|
+
onPath,
|
|
131
|
+
present: configured || onPath,
|
|
132
|
+
pluginInstalled: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
export async function install({ packageRoot, flags }) {
|
|
121
137
|
const link = isLinkMode(flags);
|
|
122
138
|
const force = isForce(flags);
|
package/src/installers/tui.mjs
CHANGED
|
@@ -23,6 +23,17 @@ const FADE_ROWS = 28; // rail finishes fading after this many emitted rows
|
|
|
23
23
|
|
|
24
24
|
let _row = 0;
|
|
25
25
|
|
|
26
|
+
// Every terminal write in the installer UI funnels through here, so a caller
|
|
27
|
+
// whose stdout carries machine-readable output (`slidemind setup --json`) can
|
|
28
|
+
// silence the decorative rail without each writer knowing about it.
|
|
29
|
+
let _silent = false;
|
|
30
|
+
export function setSilent(value) {
|
|
31
|
+
_silent = Boolean(value);
|
|
32
|
+
}
|
|
33
|
+
export function out(text) {
|
|
34
|
+
if (!_silent) process.stdout.write(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
// Linear interpolate between #9999FF (techBlue40) and #6C7D8C (slate).
|
|
27
38
|
// Top of the rail is bright tech-blue, fading to brand slate as the
|
|
28
39
|
// install progresses — feels like the deck's tech-blue gradation card.
|
|
@@ -52,7 +63,7 @@ export function bumpRow(n = 1) {
|
|
|
52
63
|
|
|
53
64
|
export function intro(text) {
|
|
54
65
|
_row = 0;
|
|
55
|
-
|
|
66
|
+
out("\n" + paint(_row, "┌") + " " + text + "\n");
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
// Each message starts with its own gradient-coloured rail spacer, then
|
|
@@ -60,28 +71,28 @@ export function intro(text) {
|
|
|
60
71
|
// (◆ ● ▲ ■ etc.); when omitted we fall back to a continuation rail.
|
|
61
72
|
export function message(text, { symbol } = {}) {
|
|
62
73
|
_row += 1;
|
|
63
|
-
|
|
74
|
+
out(paint(_row, "│") + "\n");
|
|
64
75
|
_row += 1;
|
|
65
76
|
const sym = symbol ?? paint(_row, "│");
|
|
66
|
-
|
|
77
|
+
out(sym + " " + text + "\n");
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
export function outro(text) {
|
|
70
81
|
_row += 1;
|
|
71
|
-
|
|
82
|
+
out(paint(_row, "│") + "\n");
|
|
72
83
|
_row += 1;
|
|
73
|
-
|
|
84
|
+
out(paint(_row, "└") + " " + text + "\n\n");
|
|
74
85
|
_row = 0;
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
// Reserve the next two rows for a spin step: emits the rail spacer,
|
|
78
89
|
// returns the row index the spinner is written on. Callers should
|
|
79
|
-
// `
|
|
90
|
+
// `out(...)` the spinner content themselves (without a
|
|
80
91
|
// trailing `\n`) so they can overwrite it via `\r`. When the operation
|
|
81
92
|
// completes they call `commitSpinRow()` after writing the final line.
|
|
82
93
|
export function reserveSpinRows() {
|
|
83
94
|
_row += 1;
|
|
84
|
-
|
|
95
|
+
out(paint(_row, "│") + "\n");
|
|
85
96
|
_row += 1;
|
|
86
97
|
return _row;
|
|
87
98
|
}
|
|
@@ -96,7 +107,7 @@ const ANIMATE = !NO_COLOR && process.stdout.isTTY;
|
|
|
96
107
|
export async function shimmer(text, { startCol = 1, restColor }) {
|
|
97
108
|
const final = brand.bold(restColor(text));
|
|
98
109
|
if (!ANIMATE) {
|
|
99
|
-
|
|
110
|
+
out(final);
|
|
100
111
|
return;
|
|
101
112
|
}
|
|
102
113
|
const len = text.length;
|
|
@@ -117,9 +128,9 @@ export async function shimmer(text, { startCol = 1, restColor }) {
|
|
|
117
128
|
else painter = (s) => brand.dim(brand.slate(s));
|
|
118
129
|
line += brand.bold(painter(text[i]));
|
|
119
130
|
}
|
|
120
|
-
|
|
131
|
+
out(`\x1b[${startCol}G\x1b[K` + line);
|
|
121
132
|
if (f < frames) await new Promise((r) => setTimeout(r, frameMs));
|
|
122
133
|
}
|
|
123
134
|
// Final settle.
|
|
124
|
-
|
|
135
|
+
out(`\x1b[${startCol}G\x1b[K` + final);
|
|
125
136
|
}
|
package/src/pptx/fill/engine.mjs
CHANGED
|
@@ -35,69 +35,11 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
|
|
|
35
35
|
const locale = skill.locale || "en";
|
|
36
36
|
const fieldsByNo = pack.fields[locale] ?? {};
|
|
37
37
|
const repeatsByNo = pack.repeats[locale] ?? {};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---- deck reading ---------------------------------------------------------
|
|
46
|
-
const slidesDir = path.join(deckDir, "slides");
|
|
47
|
-
const entries = fs.readdirSync(slidesDir)
|
|
48
|
-
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
49
|
-
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
50
|
-
if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
|
|
51
|
-
|
|
52
|
-
const problems = [];
|
|
53
|
-
const warnings = [];
|
|
54
|
-
const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
|
|
55
|
-
|
|
56
|
-
// In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
|
|
57
|
-
// sibling NN-name.pptx artifact — when both exist, the .pptx carries the
|
|
58
|
-
// slide and the .jsx is source only.
|
|
59
|
-
const renderedStems = new Set(
|
|
60
|
-
entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
for (const file of entries) {
|
|
64
|
-
if (file.endsWith(".pptx")) {
|
|
65
|
-
sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
|
|
69
|
-
const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
|
|
70
|
-
let parsed;
|
|
71
|
-
try {
|
|
72
|
-
parsed = parseSlideFile(src, file);
|
|
73
|
-
} catch (err) {
|
|
74
|
-
problems.push(err.message);
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
const layout = layoutNumber(parsed.component, locale, pack.components);
|
|
78
|
-
if (!layout) {
|
|
79
|
-
problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
|
|
80
|
-
`rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
|
|
81
|
-
`automatically when a paired add-in backend is reachable`);
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
|
|
85
|
-
const no = String(layout.no);
|
|
86
|
-
|
|
87
|
-
if ((pack.policy.freeform ?? []).includes(no)) {
|
|
88
|
-
if (Object.keys(parsed.props).length > 0) {
|
|
89
|
-
problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
|
|
90
|
-
`Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
|
|
97
|
-
sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
checkIdentities(sequence, warnings, problems);
|
|
38
|
+
const { sequence, warnings, problems } = inspectFillTemplateDeck({
|
|
39
|
+
deckDir,
|
|
40
|
+
skill,
|
|
41
|
+
includeBuffers: true,
|
|
42
|
+
});
|
|
101
43
|
if (problems.length) {
|
|
102
44
|
const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
|
|
103
45
|
err.problems = problems;
|
|
@@ -174,6 +116,113 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
|
|
|
174
116
|
return { outPath: finalOut, slideCount: output.length, warnings };
|
|
175
117
|
}
|
|
176
118
|
|
|
119
|
+
// Parse and structurally validate a fill-template deck without mutating it.
|
|
120
|
+
// External packs use this generic inspection surface to run their own domain
|
|
121
|
+
// checks before invoking build-pptx. Buffers are opt-in because JSON callers
|
|
122
|
+
// must receive a compact, serializable sequence.
|
|
123
|
+
export function inspectFillTemplateDeck({
|
|
124
|
+
deckDir,
|
|
125
|
+
skill,
|
|
126
|
+
allowUnrenderedFreeform = false,
|
|
127
|
+
includeBuffers = false,
|
|
128
|
+
}) {
|
|
129
|
+
const pack = loadPack(skill);
|
|
130
|
+
const locale = skill.locale || "en";
|
|
131
|
+
const fieldsByNo = pack.fields[locale] ?? {};
|
|
132
|
+
const repeatsByNo = pack.repeats[locale] ?? {};
|
|
133
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
134
|
+
const problems = [];
|
|
135
|
+
const warnings = [];
|
|
136
|
+
const sequence = [];
|
|
137
|
+
|
|
138
|
+
if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
|
|
139
|
+
problems.push(
|
|
140
|
+
`template "${skill.templateKey}" not found in pack ${skill.skillDir} — check the pack manifest templates`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
|
|
144
|
+
problems.push(`expected 'slides/' folder in ${deckDir}`);
|
|
145
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const entries = fs.readdirSync(slidesDir)
|
|
149
|
+
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
150
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
problems.push(`no .jsx or .pptx slides in ${slidesDir}`);
|
|
153
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const renderedStems = new Set(
|
|
157
|
+
entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
for (const file of entries) {
|
|
161
|
+
const full = path.join(slidesDir, file);
|
|
162
|
+
if (file.endsWith(".pptx")) {
|
|
163
|
+
sequence.push({
|
|
164
|
+
kind: "insert",
|
|
165
|
+
file,
|
|
166
|
+
...(includeBuffers ? { buf: fs.readFileSync(full) } : {}),
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
|
|
171
|
+
|
|
172
|
+
const src = fs.readFileSync(full, "utf8");
|
|
173
|
+
const component = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1] ?? null;
|
|
174
|
+
let layout = null;
|
|
175
|
+
if (component) {
|
|
176
|
+
try {
|
|
177
|
+
layout = layoutNumber(component, locale, pack.components);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
problems.push(err.message);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A pack validator needs to see freeform source positions even before the
|
|
185
|
+
// paired add-in artifact exists. The build path still rejects such a
|
|
186
|
+
// source until ensureFreeformRenders has produced its .pptx sibling.
|
|
187
|
+
if (!layout && allowUnrenderedFreeform) {
|
|
188
|
+
sequence.push({ kind: "freeform", file, component });
|
|
189
|
+
warnings.push(`${file}: freeform source has no rendered .pptx artifact yet`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = parseSlideFile(src, file);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
problems.push(err.message);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
layout = layoutNumber(parsed.component, locale, pack.components);
|
|
201
|
+
if (!layout) {
|
|
202
|
+
problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
|
|
203
|
+
`rendered artifact next to it — render slides/${file.replace(/\.jsx$/, "")}.pptx ` +
|
|
204
|
+
`through the paired add-in before building`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
|
|
208
|
+
const no = String(layout.no);
|
|
209
|
+
|
|
210
|
+
if ((pack.policy.freeform ?? []).includes(no)) {
|
|
211
|
+
if (Object.keys(parsed.props).length > 0) {
|
|
212
|
+
problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
|
|
213
|
+
`Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
|
|
220
|
+
sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
// ---------------------------------------------------------------------------
|
|
178
227
|
// Pack
|
|
179
228
|
// ---------------------------------------------------------------------------
|
|
@@ -266,77 +315,6 @@ function editDistance(a, b) {
|
|
|
266
315
|
return dp[a.length][b.length];
|
|
267
316
|
}
|
|
268
317
|
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// Cross-slide identities (the catalog's consistency table, as code)
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
|
|
273
|
-
const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
274
|
-
|
|
275
|
-
function checkIdentities(sequence, warnings, problems) {
|
|
276
|
-
const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
|
|
277
|
-
const approach = slide(10)[0];
|
|
278
|
-
const details = slide(11);
|
|
279
|
-
const gantt = slide(13)[0];
|
|
280
|
-
const calc = slide(16)[0];
|
|
281
|
-
|
|
282
|
-
const wpItems = approach?.props?.repeat?.workPackages ?? null;
|
|
283
|
-
const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
|
|
284
|
-
|
|
285
|
-
if (wpItems && details.length && wpItems.length !== details.length) {
|
|
286
|
-
problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
|
|
287
|
-
}
|
|
288
|
-
// Every WP detail must carry FULL bullet lists — a section left empty (or a
|
|
289
|
-
// one-liner) is a defect in a client deliverable, not a style choice.
|
|
290
|
-
details.forEach((d, i) => {
|
|
291
|
-
for (const section of ["services", "assumptions", "deliverables"]) {
|
|
292
|
-
const lines = asLines(d.props?.[section]);
|
|
293
|
-
if (lines.length === 0) {
|
|
294
|
-
problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
|
|
295
|
-
} else if (lines.length < 3) {
|
|
296
|
-
warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
|
|
301
|
-
problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
|
|
302
|
-
}
|
|
303
|
-
if (ganttItems && details.length === ganttItems.length) {
|
|
304
|
-
ganttItems.forEach((item, i) => {
|
|
305
|
-
const canonical = asLines(details[i]?.props?.deliverables);
|
|
306
|
-
const row = asLines(item.deliverable);
|
|
307
|
-
if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
|
|
308
|
-
problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
if (wpItems && ganttItems) {
|
|
313
|
-
ganttItems.forEach((item, i) => {
|
|
314
|
-
if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
|
|
315
|
-
problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
|
|
320
|
-
if (!Array.isArray(rows) || !wpItems) continue;
|
|
321
|
-
const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
|
|
322
|
-
labels.forEach((label, i) => {
|
|
323
|
-
if (wpItems[i]?.title && label && label !== wpItems[i].title) {
|
|
324
|
-
problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
// Price echo: the contracted total should appear in the management summary.
|
|
329
|
-
const costRows = calc?.props?.tables?.["15"];
|
|
330
|
-
if (Array.isArray(costRows)) {
|
|
331
|
-
const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
|
|
332
|
-
const digits = grand.replace(/[^\d.,]/g, "");
|
|
333
|
-
const summary = slide(7)[0];
|
|
334
|
-
if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
|
|
335
|
-
warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
318
|
// ---------------------------------------------------------------------------
|
|
341
319
|
// Per-slide ops
|
|
342
320
|
// ---------------------------------------------------------------------------
|