@liustack/pptwise 0.22.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/LICENSE +21 -0
- package/README.md +127 -0
- package/README.zh-CN.md +136 -0
- package/cordis.patch.yml +5 -0
- package/dist/chunk-3ZUKISTY.js +114 -0
- package/dist/chunk-3ZUKISTY.js.map +1 -0
- package/dist/chunk-M35M4QUC.js +1167 -0
- package/dist/chunk-M35M4QUC.js.map +1 -0
- package/dist/chunk-VUOLBHD7.js +19 -0
- package/dist/chunk-VUOLBHD7.js.map +1 -0
- package/dist/chunk-WL5KWYKS.js +49762 -0
- package/dist/chunk-WL5KWYKS.js.map +1 -0
- package/dist/cli.js +4753 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +4224 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +7 -0
- package/dist/node.js +11 -0
- package/dist/node.js.map +1 -0
- package/dist/pixel-audit-H5K6JK3X.js +218 -0
- package/dist/pixel-audit-H5K6JK3X.js.map +1 -0
- package/dist/registry-C0GJH7ZT.d.ts +46 -0
- package/dsh/client.js +1398 -0
- package/dsh/index.js +141 -0
- package/dsh/preview-tool.js +1931 -0
- package/dsh/spawnHidden.js +109 -0
- package/package.json +113 -0
- package/skills/pptwise/SKILL.md +100 -0
- package/skills/pptwise/SKILL.zh-CN.md +102 -0
- package/skills/pptwise/references/branding.md +18 -0
- package/skills/pptwise/references/branding.zh-CN.md +21 -0
- package/skills/pptwise/references/components.md +35 -0
- package/skills/pptwise/references/components.zh-CN.md +40 -0
- package/skills/pptwise/references/density.md +17 -0
- package/skills/pptwise/references/density.zh-CN.md +22 -0
- package/skills/pptwise/references/images.md +42 -0
- package/skills/pptwise/references/images.zh-CN.md +47 -0
- package/skills/pptwise/references/layouts.md +37 -0
- package/skills/pptwise/references/layouts.zh-CN.md +42 -0
- package/skills/pptwise/references/spec.md +107 -0
- package/skills/pptwise/references/spec.zh-CN.md +112 -0
- package/skills/pptwise/references/validate.md +82 -0
- package/skills/pptwise/references/validate.zh-CN.md +87 -0
- package/skills/pptwise/scripts/run.ps1 +192 -0
- package/skills/pptwise/scripts/run.sh +229 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4753 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
PptxIRV3Schema,
|
|
4
|
+
VERSION,
|
|
5
|
+
assembleDeck,
|
|
6
|
+
buildAssetBrief,
|
|
7
|
+
disassembleDeck,
|
|
8
|
+
extractBrandTheme,
|
|
9
|
+
formatInvalidSpecError,
|
|
10
|
+
migrateBannerHeadingToTwoColumn,
|
|
11
|
+
migrateBloomToClassroom,
|
|
12
|
+
migrateChromeToBranding,
|
|
13
|
+
migrateDeckPlanToSpec,
|
|
14
|
+
migrateIrV3ToV4,
|
|
15
|
+
migrateLogoWallToImageGrid,
|
|
16
|
+
parseBrandThemeFile,
|
|
17
|
+
registerBrandThemeFile,
|
|
18
|
+
resolveSpecThemeId,
|
|
19
|
+
slugify,
|
|
20
|
+
specJsonSchema,
|
|
21
|
+
validateSpec
|
|
22
|
+
} from "./chunk-M35M4QUC.js";
|
|
23
|
+
import {
|
|
24
|
+
installNodePlatform,
|
|
25
|
+
isMissingModuleError,
|
|
26
|
+
proxyFetch
|
|
27
|
+
} from "./chunk-3ZUKISTY.js";
|
|
28
|
+
import {
|
|
29
|
+
AUDIENCE_VALUES,
|
|
30
|
+
CANONICAL_THEME_IDS,
|
|
31
|
+
CANVAS_H_PX,
|
|
32
|
+
CANVAS_W_PX,
|
|
33
|
+
FORMAT_BY_MIME,
|
|
34
|
+
MIME_BY_SNIFFED_FORMAT,
|
|
35
|
+
NARRATIVE_PRESETS,
|
|
36
|
+
PACING_BUDGETS,
|
|
37
|
+
PptwiseError,
|
|
38
|
+
STRATEGY_DEFINITIONS,
|
|
39
|
+
StyleOverrideSchema,
|
|
40
|
+
assertContrastFloor,
|
|
41
|
+
auditDeck,
|
|
42
|
+
formatIssues,
|
|
43
|
+
formatWarnings,
|
|
44
|
+
generatePptx,
|
|
45
|
+
getInstalledThemeIds,
|
|
46
|
+
irJsonSchema,
|
|
47
|
+
listThemes,
|
|
48
|
+
renderSlideSvg,
|
|
49
|
+
resolveNarrative,
|
|
50
|
+
sniffImageFormat,
|
|
51
|
+
styleJsonSchema,
|
|
52
|
+
validateIr
|
|
53
|
+
} from "./chunk-WL5KWYKS.js";
|
|
54
|
+
import {
|
|
55
|
+
getPlatform
|
|
56
|
+
} from "./chunk-VUOLBHD7.js";
|
|
57
|
+
|
|
58
|
+
// src/cli.ts
|
|
59
|
+
import { Command } from "commander";
|
|
60
|
+
|
|
61
|
+
// src/cli/commands.ts
|
|
62
|
+
import { mkdir as mkdir4, readdir as readdir3, readFile as readFile6, rm, writeFile as writeFile3 } from "fs/promises";
|
|
63
|
+
import { basename as basename4, dirname as dirname3, join as join5, relative as relative2, resolve as resolve6 } from "path";
|
|
64
|
+
|
|
65
|
+
// src/cli/config.ts
|
|
66
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
67
|
+
import { dirname, join as join2, resolve as resolve2 } from "path";
|
|
68
|
+
import { z as z2 } from "zod";
|
|
69
|
+
|
|
70
|
+
// src/cli/home.ts
|
|
71
|
+
import { cpSync, existsSync, realpathSync, renameSync, rmSync } from "fs";
|
|
72
|
+
import { homedir as osHomedir } from "os";
|
|
73
|
+
import { join, resolve } from "path";
|
|
74
|
+
|
|
75
|
+
// src/cli/product-env.ts
|
|
76
|
+
var PRODUCT_ENV_PREFIX = "PPTWISE_";
|
|
77
|
+
var LEGACY_ENV_PREFIXES = ["PPTPRESS_", "PPTFAST_"];
|
|
78
|
+
var warnedLegacyKeys = /* @__PURE__ */ new Set();
|
|
79
|
+
function productEnvName(suffix) {
|
|
80
|
+
return `${PRODUCT_ENV_PREFIX}${suffix}`;
|
|
81
|
+
}
|
|
82
|
+
function legacyEnvNames(suffix) {
|
|
83
|
+
return LEGACY_ENV_PREFIXES.map((prefix) => `${prefix}${suffix}`);
|
|
84
|
+
}
|
|
85
|
+
function nonempty(value) {
|
|
86
|
+
return value === void 0 || value === "" ? void 0 : value;
|
|
87
|
+
}
|
|
88
|
+
function warnLegacy(legacyKey, currentKey) {
|
|
89
|
+
if (warnedLegacyKeys.has(legacyKey)) return;
|
|
90
|
+
warnedLegacyKeys.add(legacyKey);
|
|
91
|
+
process.stderr.write(`${legacyKey} is deprecated. Use ${currentKey} instead.
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
function resolveProductEnv(suffix, env = process.env) {
|
|
95
|
+
const currentKey = productEnvName(suffix);
|
|
96
|
+
const current = nonempty(env[currentKey]);
|
|
97
|
+
if (current !== void 0) return current;
|
|
98
|
+
for (const legacyKey of legacyEnvNames(suffix)) {
|
|
99
|
+
const legacy = nonempty(env[legacyKey]);
|
|
100
|
+
if (legacy !== void 0) {
|
|
101
|
+
warnLegacy(legacyKey, currentKey);
|
|
102
|
+
return legacy;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return void 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/cli/home.ts
|
|
109
|
+
var HOME_DIRNAME = ".pptwise";
|
|
110
|
+
var LEGACY_HOME_DIRNAMES = [".pptpress", ".pptfast"];
|
|
111
|
+
function pptwiseHome(opts = {}) {
|
|
112
|
+
const env = opts.env ?? process.env;
|
|
113
|
+
const fromEnv = resolveProductEnv("HOME", env);
|
|
114
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
115
|
+
const home = (opts.homedir ?? osHomedir)();
|
|
116
|
+
const next = join(home, HOME_DIRNAME);
|
|
117
|
+
migrateLegacyHomes(home, next);
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
function migrateLegacyHomes(home, nextDir) {
|
|
121
|
+
if (existsSync(nextDir)) return;
|
|
122
|
+
for (const dirname5 of LEGACY_HOME_DIRNAMES) {
|
|
123
|
+
const legacy = join(home, dirname5);
|
|
124
|
+
if (existsSync(legacy)) {
|
|
125
|
+
copyLegacyHome(legacy, nextDir);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function copyLegacyHome(legacyDir, nextDir) {
|
|
131
|
+
const source = realpathSync(legacyDir);
|
|
132
|
+
const tmpDir = `${nextDir}.migrating`;
|
|
133
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
134
|
+
try {
|
|
135
|
+
cpSync(source, tmpDir, { recursive: true });
|
|
136
|
+
renameSync(tmpDir, nextDir);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function decksRoot(config2, opts) {
|
|
146
|
+
return resolve(pptwiseHome(opts), config2?.decksDir ?? "decks");
|
|
147
|
+
}
|
|
148
|
+
function userConfigPath(opts) {
|
|
149
|
+
return join(pptwiseHome(opts), "config.json");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/cli/image-config.ts
|
|
153
|
+
import { chmodSync, lstatSync } from "fs";
|
|
154
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
155
|
+
import { z } from "zod";
|
|
156
|
+
var ImageProviderConfigSchema = z.object({
|
|
157
|
+
apiKey: z.string().optional()
|
|
158
|
+
}).strict();
|
|
159
|
+
var OpenverseConfigSchema = z.object({
|
|
160
|
+
clientId: z.string().optional(),
|
|
161
|
+
clientSecret: z.string().optional()
|
|
162
|
+
}).strict();
|
|
163
|
+
var GENERATOR_IDS = ["grok", "codex", "antigravity"];
|
|
164
|
+
var DEFAULT_GENERATOR_ORDER = ["grok", "codex", "antigravity"];
|
|
165
|
+
var DEFAULT_GENERATOR_TIMEOUT_MS = 18e4;
|
|
166
|
+
var GeneratorFlagsSchema = z.object({ enabled: z.boolean().optional() }).strict();
|
|
167
|
+
var GeneratorsConfigSchema = z.object({
|
|
168
|
+
grok: GeneratorFlagsSchema.optional(),
|
|
169
|
+
codex: GeneratorFlagsSchema.optional(),
|
|
170
|
+
antigravity: GeneratorFlagsSchema.optional(),
|
|
171
|
+
order: z.array(z.enum(GENERATOR_IDS)).optional(),
|
|
172
|
+
timeoutMs: z.number().int().positive().optional()
|
|
173
|
+
}).strict();
|
|
174
|
+
var ImagesConfigSchema = z.object({
|
|
175
|
+
pexels: ImageProviderConfigSchema.optional(),
|
|
176
|
+
pixabay: ImageProviderConfigSchema.optional(),
|
|
177
|
+
openverse: OpenverseConfigSchema.optional(),
|
|
178
|
+
generators: GeneratorsConfigSchema.optional()
|
|
179
|
+
}).strict();
|
|
180
|
+
var API_KEY_PROVIDERS = ["pexels", "pixabay"];
|
|
181
|
+
var ENV_SUFFIX_BY_PROVIDER = {
|
|
182
|
+
pexels: "PEXELS_API_KEY",
|
|
183
|
+
pixabay: "PIXABAY_API_KEY"
|
|
184
|
+
};
|
|
185
|
+
var FORBIDDEN_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
186
|
+
var CLI_KEYS = {
|
|
187
|
+
"pexels.apiKey": {
|
|
188
|
+
cliKey: "pexels.apiKey",
|
|
189
|
+
path: ["images", "pexels", "apiKey"],
|
|
190
|
+
omitValue: true,
|
|
191
|
+
secret: true,
|
|
192
|
+
kind: "string"
|
|
193
|
+
},
|
|
194
|
+
"pixabay.apiKey": {
|
|
195
|
+
cliKey: "pixabay.apiKey",
|
|
196
|
+
path: ["images", "pixabay", "apiKey"],
|
|
197
|
+
omitValue: true,
|
|
198
|
+
secret: true,
|
|
199
|
+
kind: "string"
|
|
200
|
+
},
|
|
201
|
+
"openverse.clientId": {
|
|
202
|
+
cliKey: "openverse.clientId",
|
|
203
|
+
path: ["images", "openverse", "clientId"],
|
|
204
|
+
omitValue: false,
|
|
205
|
+
secret: true,
|
|
206
|
+
kind: "string"
|
|
207
|
+
},
|
|
208
|
+
"openverse.clientSecret": {
|
|
209
|
+
cliKey: "openverse.clientSecret",
|
|
210
|
+
path: ["images", "openverse", "clientSecret"],
|
|
211
|
+
omitValue: true,
|
|
212
|
+
secret: true,
|
|
213
|
+
kind: "string"
|
|
214
|
+
},
|
|
215
|
+
"images.generators.grok.enabled": {
|
|
216
|
+
cliKey: "images.generators.grok.enabled",
|
|
217
|
+
path: ["images", "generators", "grok", "enabled"],
|
|
218
|
+
omitValue: false,
|
|
219
|
+
secret: false,
|
|
220
|
+
kind: "boolean"
|
|
221
|
+
},
|
|
222
|
+
"images.generators.codex.enabled": {
|
|
223
|
+
cliKey: "images.generators.codex.enabled",
|
|
224
|
+
path: ["images", "generators", "codex", "enabled"],
|
|
225
|
+
omitValue: false,
|
|
226
|
+
secret: false,
|
|
227
|
+
kind: "boolean"
|
|
228
|
+
},
|
|
229
|
+
"images.generators.antigravity.enabled": {
|
|
230
|
+
cliKey: "images.generators.antigravity.enabled",
|
|
231
|
+
path: ["images", "generators", "antigravity", "enabled"],
|
|
232
|
+
omitValue: false,
|
|
233
|
+
secret: false,
|
|
234
|
+
kind: "boolean"
|
|
235
|
+
},
|
|
236
|
+
"images.generators.order": {
|
|
237
|
+
cliKey: "images.generators.order",
|
|
238
|
+
path: ["images", "generators", "order"],
|
|
239
|
+
omitValue: false,
|
|
240
|
+
secret: false,
|
|
241
|
+
kind: "order"
|
|
242
|
+
},
|
|
243
|
+
"images.generators.timeoutMs": {
|
|
244
|
+
cliKey: "images.generators.timeoutMs",
|
|
245
|
+
path: ["images", "generators", "timeoutMs"],
|
|
246
|
+
omitValue: false,
|
|
247
|
+
secret: false,
|
|
248
|
+
kind: "timeoutMs"
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
function maskKey(value) {
|
|
252
|
+
if (value.length <= 8) return "****";
|
|
253
|
+
return `${value.slice(0, 6)}...${value.slice(-2)}`;
|
|
254
|
+
}
|
|
255
|
+
function assertSafeConfigKeyPath(key) {
|
|
256
|
+
for (const segment of key.split(".")) {
|
|
257
|
+
if (FORBIDDEN_SEGMENTS.has(segment)) {
|
|
258
|
+
throw new PptwiseError(`refusing to set "${key}": "${segment}" is not a valid config key`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function parseCliConfigKey(key) {
|
|
263
|
+
assertSafeConfigKeyPath(key);
|
|
264
|
+
const hit = CLI_KEYS[key];
|
|
265
|
+
if (!hit) {
|
|
266
|
+
throw new PptwiseError(
|
|
267
|
+
`unknown config key "${key}" \u2014 expected pexels.apiKey, pixabay.apiKey, openverse.clientId, openverse.clientSecret, or images.generators.*`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return hit;
|
|
271
|
+
}
|
|
272
|
+
function providerNamedInFile(file, provider) {
|
|
273
|
+
return file?.images?.[provider] !== void 0;
|
|
274
|
+
}
|
|
275
|
+
function nonempty2(value) {
|
|
276
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
277
|
+
}
|
|
278
|
+
function resolveOne(file, env, provider) {
|
|
279
|
+
const namedInFile = providerNamedInFile(file, provider);
|
|
280
|
+
if (namedInFile) {
|
|
281
|
+
const apiKey2 = nonempty2(file?.images?.[provider]?.apiKey);
|
|
282
|
+
return { apiKey: apiKey2, source: apiKey2 ? "file" : null, namedInFile: true };
|
|
283
|
+
}
|
|
284
|
+
const apiKey = nonempty2(resolveProductEnv(ENV_SUFFIX_BY_PROVIDER[provider], env));
|
|
285
|
+
return { apiKey, source: apiKey ? "env" : null, namedInFile: false };
|
|
286
|
+
}
|
|
287
|
+
function resolveOpenverse(file, env) {
|
|
288
|
+
const namedInFile = providerNamedInFile(file, "openverse");
|
|
289
|
+
if (namedInFile) {
|
|
290
|
+
const clientId2 = nonempty2(file?.images?.openverse?.clientId);
|
|
291
|
+
const clientSecret2 = nonempty2(file?.images?.openverse?.clientSecret);
|
|
292
|
+
const ready2 = Boolean(clientId2 && clientSecret2);
|
|
293
|
+
return { clientId: clientId2, clientSecret: clientSecret2, source: ready2 ? "file" : null, namedInFile: true, ready: ready2 };
|
|
294
|
+
}
|
|
295
|
+
const clientId = nonempty2(resolveProductEnv("OPENVERSE_CLIENT_ID", env));
|
|
296
|
+
const clientSecret = nonempty2(resolveProductEnv("OPENVERSE_CLIENT_SECRET", env));
|
|
297
|
+
const ready = Boolean(clientId && clientSecret);
|
|
298
|
+
return { clientId, clientSecret, source: ready ? "env" : null, namedInFile: false, ready };
|
|
299
|
+
}
|
|
300
|
+
function resolveImageKeys(opts = {}) {
|
|
301
|
+
const file = opts.file ?? null;
|
|
302
|
+
const env = opts.env ?? process.env;
|
|
303
|
+
return {
|
|
304
|
+
pexels: resolveOne(file, env, "pexels"),
|
|
305
|
+
pixabay: resolveOne(file, env, "pixabay"),
|
|
306
|
+
openverse: resolveOpenverse(file, env)
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function resolveGenerators(opts = {}) {
|
|
310
|
+
const g = opts.file?.images?.generators;
|
|
311
|
+
const order = g?.order && g.order.length > 0 ? g.order : DEFAULT_GENERATOR_ORDER;
|
|
312
|
+
return {
|
|
313
|
+
enabled: {
|
|
314
|
+
grok: g?.grok?.enabled === true,
|
|
315
|
+
codex: g?.codex?.enabled === true,
|
|
316
|
+
antigravity: g?.antigravity?.enabled === true
|
|
317
|
+
},
|
|
318
|
+
order,
|
|
319
|
+
timeoutMs: typeof g?.timeoutMs === "number" && g.timeoutMs > 0 ? g.timeoutMs : DEFAULT_GENERATOR_TIMEOUT_MS
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function parseCliConfigValue(parsed, raw) {
|
|
323
|
+
if (parsed.kind === "boolean") {
|
|
324
|
+
const v = raw.trim().toLowerCase();
|
|
325
|
+
if (v !== "true" && v !== "false") {
|
|
326
|
+
throw new PptwiseError(`${parsed.cliKey} must be true or false`);
|
|
327
|
+
}
|
|
328
|
+
return v === "true";
|
|
329
|
+
}
|
|
330
|
+
if (parsed.kind === "order") {
|
|
331
|
+
const names = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
332
|
+
const unknown = names.find((n) => !GENERATOR_IDS.includes(n));
|
|
333
|
+
if (unknown) {
|
|
334
|
+
throw new PptwiseError(`unknown generator "${unknown}" \u2014 expected grok, codex, or antigravity`);
|
|
335
|
+
}
|
|
336
|
+
if (names.length === 0) {
|
|
337
|
+
throw new PptwiseError("images.generators.order must not be empty");
|
|
338
|
+
}
|
|
339
|
+
return names;
|
|
340
|
+
}
|
|
341
|
+
if (parsed.kind === "timeoutMs") {
|
|
342
|
+
if (!/^[0-9]+$/.test(raw.trim()) || Number(raw) <= 0) {
|
|
343
|
+
throw new PptwiseError(`${parsed.cliKey} must be a positive integer`);
|
|
344
|
+
}
|
|
345
|
+
return Number(raw);
|
|
346
|
+
}
|
|
347
|
+
return raw;
|
|
348
|
+
}
|
|
349
|
+
function knownSecretsFrom(keys) {
|
|
350
|
+
const secrets = [];
|
|
351
|
+
for (const provider of API_KEY_PROVIDERS) {
|
|
352
|
+
const apiKey = keys[provider].apiKey;
|
|
353
|
+
if (apiKey && apiKey.length >= 6) secrets.push(apiKey);
|
|
354
|
+
}
|
|
355
|
+
const { clientId, clientSecret } = keys.openverse;
|
|
356
|
+
if (clientSecret && clientSecret.length >= 6) secrets.push(clientSecret);
|
|
357
|
+
if (clientId && clientId.length >= 6) secrets.push(clientId);
|
|
358
|
+
return secrets;
|
|
359
|
+
}
|
|
360
|
+
function assertNotSymlink(path2) {
|
|
361
|
+
let st;
|
|
362
|
+
try {
|
|
363
|
+
st = lstatSync(path2);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
if (e.code === "ENOENT") return;
|
|
366
|
+
throw e;
|
|
367
|
+
}
|
|
368
|
+
if (st.isSymbolicLink()) {
|
|
369
|
+
throw new PptwiseError(`refusing to write ${path2}: it is a symlink`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function asPlainObject(value) {
|
|
373
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
374
|
+
return { ...value };
|
|
375
|
+
}
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
async function readRawUserConfig() {
|
|
379
|
+
const path2 = userConfigPath();
|
|
380
|
+
let text;
|
|
381
|
+
try {
|
|
382
|
+
text = await readFile(path2, "utf8");
|
|
383
|
+
} catch (e) {
|
|
384
|
+
if (e.code === "ENOENT") return {};
|
|
385
|
+
throw e;
|
|
386
|
+
}
|
|
387
|
+
let raw;
|
|
388
|
+
try {
|
|
389
|
+
raw = JSON.parse(text);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
throw new PptwiseError(`${path2} is not valid JSON: ${e.message}`);
|
|
392
|
+
}
|
|
393
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
394
|
+
throw new PptwiseError(`${path2} must be a JSON object`);
|
|
395
|
+
}
|
|
396
|
+
return raw;
|
|
397
|
+
}
|
|
398
|
+
async function persistUserConfigValue(path2, value) {
|
|
399
|
+
for (const segment of path2) {
|
|
400
|
+
if (FORBIDDEN_SEGMENTS.has(segment) || segment === "") {
|
|
401
|
+
throw new PptwiseError(`refusing to set "${path2.join(".")}": "${segment}" is not a valid config key`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (path2.length === 0) {
|
|
405
|
+
throw new PptwiseError("refusing to set an empty config path");
|
|
406
|
+
}
|
|
407
|
+
const filePath = userConfigPath();
|
|
408
|
+
assertNotSymlink(filePath);
|
|
409
|
+
const raw = await readRawUserConfig();
|
|
410
|
+
let cursor = raw;
|
|
411
|
+
for (let i = 0; i < path2.length - 1; i++) {
|
|
412
|
+
const segment = path2[i];
|
|
413
|
+
const next = asPlainObject(cursor[segment]);
|
|
414
|
+
cursor[segment] = next;
|
|
415
|
+
cursor = next;
|
|
416
|
+
}
|
|
417
|
+
const leaf = path2[path2.length - 1];
|
|
418
|
+
if (value === "") {
|
|
419
|
+
delete cursor[leaf];
|
|
420
|
+
} else {
|
|
421
|
+
cursor[leaf] = value;
|
|
422
|
+
}
|
|
423
|
+
await mkdir(pptwiseHome(), { recursive: true });
|
|
424
|
+
const text = JSON.stringify(raw, null, 2) + "\n";
|
|
425
|
+
await writeFile(filePath, text, { encoding: "utf8", mode: 384 });
|
|
426
|
+
try {
|
|
427
|
+
chmodSync(filePath, 384);
|
|
428
|
+
} catch {
|
|
429
|
+
}
|
|
430
|
+
return filePath;
|
|
431
|
+
}
|
|
432
|
+
function pexelsApplyUrl() {
|
|
433
|
+
return "https://www.pexels.com/api/";
|
|
434
|
+
}
|
|
435
|
+
function pixabayApplyUrl() {
|
|
436
|
+
return "https://pixabay.com/api/docs/";
|
|
437
|
+
}
|
|
438
|
+
function missingKeysError(kind) {
|
|
439
|
+
if (kind === "pixabay") {
|
|
440
|
+
return new PptwiseError(
|
|
441
|
+
`Pixabay is not configured. Apply at ${pixabayApplyUrl()}, then run \`pptwise config set pixabay.apiKey\`.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return new PptwiseError(
|
|
445
|
+
`Pexels is not configured. Apply at ${pexelsApplyUrl()}, then run \`pptwise config set pexels.apiKey\`.`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/cli/config.ts
|
|
450
|
+
var ConfigSchema = z2.object({
|
|
451
|
+
theme: z2.string().optional(),
|
|
452
|
+
style: StyleOverrideSchema.optional(),
|
|
453
|
+
decksDir: z2.string().optional(),
|
|
454
|
+
outDir: z2.string().optional()
|
|
455
|
+
}).strict();
|
|
456
|
+
var UserConfigSchema = z2.object({
|
|
457
|
+
theme: z2.string().optional(),
|
|
458
|
+
style: StyleOverrideSchema.optional(),
|
|
459
|
+
decksDir: z2.string().optional(),
|
|
460
|
+
images: ImagesConfigSchema.optional()
|
|
461
|
+
}).strict();
|
|
462
|
+
var CONFIG_FILENAME = "pptwise.config.json";
|
|
463
|
+
var LEGACY_CONFIG_FILENAMES = ["pptpress.config.json", "pptfast.config.json"];
|
|
464
|
+
async function readConfigFile(path2, schema) {
|
|
465
|
+
let text;
|
|
466
|
+
try {
|
|
467
|
+
text = await readFile2(path2, "utf8");
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
let raw;
|
|
472
|
+
try {
|
|
473
|
+
raw = JSON.parse(text);
|
|
474
|
+
} catch (e) {
|
|
475
|
+
throw new PptwiseError(`${path2} is not valid JSON: ${e.message}`);
|
|
476
|
+
}
|
|
477
|
+
const r = schema.safeParse(raw);
|
|
478
|
+
if (!r.success) {
|
|
479
|
+
const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
480
|
+
throw new PptwiseError(`invalid ${path2}:
|
|
481
|
+
${detail}`);
|
|
482
|
+
}
|
|
483
|
+
return { path: path2, config: r.data };
|
|
484
|
+
}
|
|
485
|
+
async function findConfig(startDir) {
|
|
486
|
+
let dir = resolve2(startDir);
|
|
487
|
+
for (; ; ) {
|
|
488
|
+
const hit = await readConfigFile(join2(dir, CONFIG_FILENAME), ConfigSchema);
|
|
489
|
+
if (hit) return hit;
|
|
490
|
+
for (const name of LEGACY_CONFIG_FILENAMES) {
|
|
491
|
+
const legacy = await readConfigFile(join2(dir, name), ConfigSchema);
|
|
492
|
+
if (legacy) return legacy;
|
|
493
|
+
}
|
|
494
|
+
const parent = dirname(dir);
|
|
495
|
+
if (parent === dir) return null;
|
|
496
|
+
dir = parent;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function findUserConfig() {
|
|
500
|
+
return readConfigFile(userConfigPath(), UserConfigSchema);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/cli/deck-dir.ts
|
|
504
|
+
import { copyFile, mkdir as mkdir2, readFile as readFile4, readdir, stat, writeFile as writeFile2 } from "fs/promises";
|
|
505
|
+
import { basename as basename2, extname as extname2, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve4 } from "path";
|
|
506
|
+
|
|
507
|
+
// src/cli/load-ir.ts
|
|
508
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
509
|
+
import { basename, extname, isAbsolute, resolve as resolve3 } from "path";
|
|
510
|
+
import { fileURLToPath } from "url";
|
|
511
|
+
var MIME_BY_EXT = {
|
|
512
|
+
".png": "image/png",
|
|
513
|
+
".jpg": "image/jpeg",
|
|
514
|
+
".jpeg": "image/jpeg",
|
|
515
|
+
".gif": "image/gif"
|
|
516
|
+
};
|
|
517
|
+
var EXT_BY_MIME = {
|
|
518
|
+
"image/png": ".png",
|
|
519
|
+
"image/jpeg": ".jpg",
|
|
520
|
+
"image/gif": ".gif",
|
|
521
|
+
"image/webp": ".webp"
|
|
522
|
+
};
|
|
523
|
+
function unwrapFileSrc(src, platform = process.platform) {
|
|
524
|
+
if (platform === "win32") {
|
|
525
|
+
const url = new URL(src);
|
|
526
|
+
let pathname = decodeURIComponent(url.pathname.replace(/\//g, "\\"));
|
|
527
|
+
if (url.hostname) return `\\\\${url.hostname}${pathname}`;
|
|
528
|
+
if (pathname.startsWith("\\") && pathname.length >= 3 && pathname[2] === ":") {
|
|
529
|
+
return pathname.slice(1);
|
|
530
|
+
}
|
|
531
|
+
return pathname;
|
|
532
|
+
}
|
|
533
|
+
return fileURLToPath(src);
|
|
534
|
+
}
|
|
535
|
+
async function loadIrFile(irPath, kind = "IR") {
|
|
536
|
+
let text;
|
|
537
|
+
try {
|
|
538
|
+
text = await readFile3(irPath, "utf8");
|
|
539
|
+
} catch {
|
|
540
|
+
throw new PptwiseError(`cannot read ${kind} file: ${irPath}`);
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
return JSON.parse(text);
|
|
544
|
+
} catch (e) {
|
|
545
|
+
throw new PptwiseError(`${kind} file ${irPath} is not valid JSON: ${e.message}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function readFromWorkspace(src, workspaceAssetsDir) {
|
|
549
|
+
const candidates = [resolve3(workspaceAssetsDir, src), resolve3(workspaceAssetsDir, basename(src))];
|
|
550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
551
|
+
for (const candidate of candidates) {
|
|
552
|
+
if (seen.has(candidate)) continue;
|
|
553
|
+
seen.add(candidate);
|
|
554
|
+
try {
|
|
555
|
+
return await readFile3(candidate);
|
|
556
|
+
} catch {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
async function resolveLocalAssets(ir, baseDir, workspaceAssetsDir) {
|
|
563
|
+
for (const [name, asset] of Object.entries(ir.assets.images)) {
|
|
564
|
+
const src = asset.src;
|
|
565
|
+
if (src.startsWith("data:") || /^https?:\/\//.test(src)) continue;
|
|
566
|
+
const abs = src.startsWith("file:") ? unwrapFileSrc(src) : isAbsolute(src) ? src : resolve3(baseDir, src);
|
|
567
|
+
let bytes;
|
|
568
|
+
try {
|
|
569
|
+
bytes = await readFile3(abs);
|
|
570
|
+
} catch {
|
|
571
|
+
const fallback = workspaceAssetsDir && !isAbsolute(src) ? await readFromWorkspace(src, workspaceAssetsDir) : null;
|
|
572
|
+
if (!fallback) {
|
|
573
|
+
throw new PptwiseError(`asset "${name}": cannot read image file ${abs} (from src "${src}")`);
|
|
574
|
+
}
|
|
575
|
+
bytes = fallback;
|
|
576
|
+
}
|
|
577
|
+
if (bytes.length === 0) {
|
|
578
|
+
throw new PptwiseError(`asset "${name}": image file ${abs} is zero bytes \u2014 re-export or re-select the file`);
|
|
579
|
+
}
|
|
580
|
+
const ext = extname(abs).toLowerCase();
|
|
581
|
+
const mime = MIME_BY_EXT[ext];
|
|
582
|
+
if (mime) {
|
|
583
|
+
const sniffed = sniffImageFormat(bytes);
|
|
584
|
+
if (sniffed === null) {
|
|
585
|
+
throw new PptwiseError(
|
|
586
|
+
`asset "${name}": image file ${abs} has a corrupt or unrecognized header (extension claims ${mime}) \u2014 re-export or re-select the file`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const expected = FORMAT_BY_MIME[mime];
|
|
590
|
+
if (expected && sniffed !== expected) {
|
|
591
|
+
throw new PptwiseError(
|
|
592
|
+
`asset "${name}": image file ${abs} is named "${ext}" but its bytes are actually ${MIME_BY_SNIFFED_FORMAT[sniffed]} \u2014 rename the file to match its real format, or re-export/re-save it as a genuine ${mime}`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
asset.src = `data:${mime};base64,${bytes.toString("base64")}`;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const recode = getPlatform().recodeImageToPng;
|
|
599
|
+
if (!recode) {
|
|
600
|
+
throw new PptwiseError(
|
|
601
|
+
`asset "${name}": unsupported image format "${extname(abs)}" \u2014 install sharp or convert to png/jpeg/gif`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
asset.src = await recode(`data:application/octet-stream;base64,${bytes.toString("base64")}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/cli/deck-dir.ts
|
|
609
|
+
var PLAN_FILENAME = "deck.plan.json";
|
|
610
|
+
var SPEC_FILENAME = "deck.spec.json";
|
|
611
|
+
var PAGES_DIRNAME = "pages";
|
|
612
|
+
var ASSETS_DIRNAME = "assets";
|
|
613
|
+
var THEME_FILENAME = "theme.json";
|
|
614
|
+
function assertSafeFileSegment(id, context) {
|
|
615
|
+
const safeBase = resolve4("/pptwise-safe-base");
|
|
616
|
+
const rel = relative(safeBase, resolve4(safeBase, id));
|
|
617
|
+
const safe = !isAbsolute2(id) && !id.includes("/") && !id.includes("\\") && id !== ".." && !rel.startsWith("..") && !isAbsolute2(rel);
|
|
618
|
+
if (!safe) {
|
|
619
|
+
throw new PptwiseError(
|
|
620
|
+
`${context} "${id}" is not a safe file name \u2014 ids used as page/asset file names must not contain path separators or ".."`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function isDeckDirectory(path2) {
|
|
625
|
+
try {
|
|
626
|
+
return (await stat(path2)).isDirectory();
|
|
627
|
+
} catch (e) {
|
|
628
|
+
if (e.code === "ENOENT") return false;
|
|
629
|
+
throw new PptwiseError(`cannot check ${path2}: ${e.message}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function pathExists(path2) {
|
|
633
|
+
try {
|
|
634
|
+
await stat(path2);
|
|
635
|
+
return true;
|
|
636
|
+
} catch (e) {
|
|
637
|
+
if (e.code === "ENOENT") return false;
|
|
638
|
+
throw new PptwiseError(`cannot check path ${path2}: ${e.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function resolveDeckTarget(arg, config2, cwd = process.cwd()) {
|
|
642
|
+
if (arg.trim() === "") throw new PptwiseError("deck target must not be empty");
|
|
643
|
+
if (arg.includes("/") || arg.includes("\\")) return resolve4(cwd, arg);
|
|
644
|
+
const local = resolve4(cwd, arg);
|
|
645
|
+
if (await pathExists(local)) return local;
|
|
646
|
+
const fallback = join3(decksRoot(config2), arg);
|
|
647
|
+
return await pathExists(fallback) ? fallback : local;
|
|
648
|
+
}
|
|
649
|
+
function expectedLayoutHint() {
|
|
650
|
+
const rows = [
|
|
651
|
+
[SPEC_FILENAME, "the locked spec (see `pptwise spec validate`)"],
|
|
652
|
+
[`${PAGES_DIRNAME}/<page-id>.json`, "one file per filled page (missing pages become placeholders)"],
|
|
653
|
+
[`${ASSETS_DIRNAME}/`, "optional local images"]
|
|
654
|
+
];
|
|
655
|
+
const width = Math.max(...rows.map(([name]) => name.length)) + 2;
|
|
656
|
+
return rows.map(([name, desc]) => ` ${name.padEnd(width)}${desc}`).join("\n");
|
|
657
|
+
}
|
|
658
|
+
async function readSpecFile(dir) {
|
|
659
|
+
const specPath = join3(dir, SPEC_FILENAME);
|
|
660
|
+
const planPath = join3(dir, PLAN_FILENAME);
|
|
661
|
+
const [specExists, planExists] = await Promise.all([pathExists(specPath), pathExists(planPath)]);
|
|
662
|
+
if (specExists && planExists) {
|
|
663
|
+
throw new PptwiseError(
|
|
664
|
+
`both ${SPEC_FILENAME} and ${PLAN_FILENAME} exist in ${dir} \u2014 ambiguous, refusing to guess which one wins. Delete ${PLAN_FILENAME} once you have confirmed ${SPEC_FILENAME} is correct (\`pptwise migrate\` never deletes the source file it read)`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (!specExists && planExists) {
|
|
668
|
+
throw new PptwiseError(
|
|
669
|
+
`${dir} has ${PLAN_FILENAME} but no ${SPEC_FILENAME} \u2014 deck project directories now use ${SPEC_FILENAME}. Run \`pptwise migrate ${dir} -o ${dir}\` to convert it`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
let text;
|
|
673
|
+
try {
|
|
674
|
+
text = await readFile4(specPath, "utf8");
|
|
675
|
+
} catch (e) {
|
|
676
|
+
if (e.code === "ENOENT") {
|
|
677
|
+
throw new PptwiseError(
|
|
678
|
+
`no ${SPEC_FILENAME} in ${dir} \u2014 expected a deck project directory:
|
|
679
|
+
${expectedLayoutHint()}`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
throw new PptwiseError(`cannot read spec file: ${specPath}`);
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
return JSON.parse(text);
|
|
686
|
+
} catch (e) {
|
|
687
|
+
throw new PptwiseError(`spec file ${specPath} is not valid JSON: ${e.message}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function readPages(dir) {
|
|
691
|
+
const pagesDir = join3(dir, PAGES_DIRNAME);
|
|
692
|
+
let entries;
|
|
693
|
+
try {
|
|
694
|
+
entries = (await readdir(pagesDir, { withFileTypes: true })).filter((entry) => entry.isFile() && extname2(entry.name) === ".json").map((entry) => entry.name);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
if (e.code === "ENOENT") return {};
|
|
697
|
+
throw new PptwiseError(`cannot read ${PAGES_DIRNAME}/ directory ${pagesDir}: ${e.message}`);
|
|
698
|
+
}
|
|
699
|
+
const pages = {};
|
|
700
|
+
await Promise.all(
|
|
701
|
+
entries.map(async (entry) => {
|
|
702
|
+
const id = basename2(entry, ".json");
|
|
703
|
+
pages[id] = await loadIrFile(join3(pagesDir, entry), `page "${id}"`);
|
|
704
|
+
})
|
|
705
|
+
);
|
|
706
|
+
return pages;
|
|
707
|
+
}
|
|
708
|
+
async function scanAssets(dir) {
|
|
709
|
+
const assetsDir = join3(dir, ASSETS_DIRNAME);
|
|
710
|
+
let entries;
|
|
711
|
+
try {
|
|
712
|
+
entries = (await readdir(assetsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
if (e.code === "ENOENT") return {};
|
|
715
|
+
throw new PptwiseError(`cannot read ${ASSETS_DIRNAME}/ directory ${assetsDir}: ${e.message}`);
|
|
716
|
+
}
|
|
717
|
+
const images2 = {};
|
|
718
|
+
const sourceFile = /* @__PURE__ */ new Map();
|
|
719
|
+
for (const entry of entries) {
|
|
720
|
+
const id = basename2(entry, extname2(entry));
|
|
721
|
+
const previous = sourceFile.get(id);
|
|
722
|
+
if (previous !== void 0) {
|
|
723
|
+
throw new PptwiseError(
|
|
724
|
+
`${ASSETS_DIRNAME}/${previous} and ${ASSETS_DIRNAME}/${entry} both register image id "${id}" \u2014 rename one of the files`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
sourceFile.set(id, entry);
|
|
728
|
+
images2[id] = { src: `${ASSETS_DIRNAME}/${entry}` };
|
|
729
|
+
}
|
|
730
|
+
return images2;
|
|
731
|
+
}
|
|
732
|
+
async function readDeckDir(dir) {
|
|
733
|
+
const deckDir = resolve4(dir);
|
|
734
|
+
const spec2 = await readSpecFile(deckDir);
|
|
735
|
+
const pages = await readPages(deckDir);
|
|
736
|
+
const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(spec2, pages);
|
|
737
|
+
const images2 = await scanAssets(deckDir);
|
|
738
|
+
const merged = { ...ir, assets: { images: { ...ir.assets.images, ...images2 } } };
|
|
739
|
+
return { ir: merged, generatedSeed, materializedLayoutCount, deckDir };
|
|
740
|
+
}
|
|
741
|
+
async function writeDeckAssets(images2, outDir, sourceBaseDir) {
|
|
742
|
+
const entries = Object.entries(images2);
|
|
743
|
+
const assetsDir = join3(outDir, ASSETS_DIRNAME);
|
|
744
|
+
if (entries.length === 0) return { count: 0, assetsDir };
|
|
745
|
+
await mkdir2(assetsDir, { recursive: true });
|
|
746
|
+
await Promise.all(entries.map(([id, asset]) => writeOneAsset(id, asset.src, assetsDir, sourceBaseDir)));
|
|
747
|
+
return { count: entries.length, assetsDir };
|
|
748
|
+
}
|
|
749
|
+
var DATA_URI_RE = /^data:([^;,]+);base64,(.*)$/s;
|
|
750
|
+
async function writeOneAsset(id, src, assetsDir, sourceBaseDir) {
|
|
751
|
+
assertSafeFileSegment(id, "asset id");
|
|
752
|
+
if (src.startsWith("data:")) {
|
|
753
|
+
const match = DATA_URI_RE.exec(src);
|
|
754
|
+
if (!match) {
|
|
755
|
+
throw new PptwiseError(`asset "${id}": only base64-encoded data URIs can be disassembled (malformed data URI)`);
|
|
756
|
+
}
|
|
757
|
+
const mime = match[1];
|
|
758
|
+
const payload = match[2];
|
|
759
|
+
const ext = EXT_BY_MIME[mime];
|
|
760
|
+
if (!ext) {
|
|
761
|
+
throw new PptwiseError(
|
|
762
|
+
`asset "${id}": cannot disassemble a data URI with mime "${mime}" \u2014 expected one of ${Object.keys(EXT_BY_MIME).join(", ")}`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
await writeFile2(join3(assetsDir, `${id}${ext}`), Buffer.from(payload, "base64"));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (/^https?:\/\//.test(src)) {
|
|
769
|
+
throw new PptwiseError(
|
|
770
|
+
`asset "${id}": URL assets cannot be disassembled into a deck directory \u2014 inline it as a data URI or download it first`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const abs = isAbsolute2(src) ? src : resolve4(sourceBaseDir, src);
|
|
774
|
+
try {
|
|
775
|
+
await copyFile(abs, join3(assetsDir, `${id}${extname2(abs)}`));
|
|
776
|
+
} catch {
|
|
777
|
+
throw new PptwiseError(`asset "${id}": cannot read source image ${abs} (from src "${src}") \u2014 cannot disassemble`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/lib/slide-edge.ts
|
|
782
|
+
var TAG = /<(\/?)(g|rect)\b([^>]*?)(\/?)>/g;
|
|
783
|
+
var ATTR_CACHE = /* @__PURE__ */ new Map();
|
|
784
|
+
function attr(attrs, name) {
|
|
785
|
+
let re = ATTR_CACHE.get(name);
|
|
786
|
+
if (!re) {
|
|
787
|
+
re = new RegExp(`\\b${name}="([^"]*)"`);
|
|
788
|
+
ATTR_CACHE.set(name, re);
|
|
789
|
+
}
|
|
790
|
+
const m = re.exec(attrs);
|
|
791
|
+
return m ? m[1] : void 0;
|
|
792
|
+
}
|
|
793
|
+
function num(attrs, name, fallback) {
|
|
794
|
+
const raw = attr(attrs, name);
|
|
795
|
+
if (raw === void 0) return fallback;
|
|
796
|
+
const n = Number(raw);
|
|
797
|
+
return Number.isFinite(n) ? n : fallback;
|
|
798
|
+
}
|
|
799
|
+
function isOpaque(attrs) {
|
|
800
|
+
for (const name of ["opacity", "fill-opacity"]) {
|
|
801
|
+
const raw = attr(attrs, name);
|
|
802
|
+
if (raw !== void 0 && Number(raw) < 1) return false;
|
|
803
|
+
}
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
var HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
807
|
+
function slideEdgeFill(svg) {
|
|
808
|
+
const rows = new Array(CANVAS_H_PX);
|
|
809
|
+
let moved = 0;
|
|
810
|
+
const open = [];
|
|
811
|
+
let painted = false;
|
|
812
|
+
TAG.lastIndex = 0;
|
|
813
|
+
for (let m = TAG.exec(svg); m; m = TAG.exec(svg)) {
|
|
814
|
+
const [, closing, tag, attrs, selfClosing] = m;
|
|
815
|
+
if (tag === "g") {
|
|
816
|
+
if (closing) {
|
|
817
|
+
if (open.pop()) moved--;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const movesChildren = /\btransform="/.test(attrs);
|
|
821
|
+
if (selfClosing) continue;
|
|
822
|
+
open.push(movesChildren);
|
|
823
|
+
if (movesChildren) moved++;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (closing || moved > 0) continue;
|
|
827
|
+
const fill = attr(attrs, "fill");
|
|
828
|
+
if (!fill || !HEX.test(fill) || !isOpaque(attrs)) continue;
|
|
829
|
+
const x = num(attrs, "x", 0);
|
|
830
|
+
const width = num(attrs, "width", 0);
|
|
831
|
+
if (x > 0 || x + width < CANVAS_W_PX) continue;
|
|
832
|
+
const y = num(attrs, "y", 0);
|
|
833
|
+
const height = num(attrs, "height", 0);
|
|
834
|
+
const top = Math.max(0, Math.round(y));
|
|
835
|
+
const bottom = Math.min(CANVAS_H_PX, Math.round(y + height));
|
|
836
|
+
for (let r = top; r < bottom; r++) rows[r] = fill;
|
|
837
|
+
if (bottom > top) painted = true;
|
|
838
|
+
}
|
|
839
|
+
if (!painted) return null;
|
|
840
|
+
let last;
|
|
841
|
+
for (let r = 0; r < CANVAS_H_PX; r++) {
|
|
842
|
+
if (rows[r]) last = rows[r];
|
|
843
|
+
else rows[r] = last;
|
|
844
|
+
}
|
|
845
|
+
for (let r = CANVAS_H_PX - 1; r >= 0; r--) {
|
|
846
|
+
if (rows[r]) last = rows[r];
|
|
847
|
+
else rows[r] = last;
|
|
848
|
+
}
|
|
849
|
+
const stops = [];
|
|
850
|
+
for (let r = 0; r < CANVAS_H_PX; r++) {
|
|
851
|
+
const fill = rows[r];
|
|
852
|
+
const run = stops[stops.length - 1];
|
|
853
|
+
if (run && run.fill === fill) run.bottom = r + 1;
|
|
854
|
+
else stops.push({ fill, top: r, bottom: r + 1 });
|
|
855
|
+
}
|
|
856
|
+
if (stops.length === 1) return stops[0].fill;
|
|
857
|
+
const pct = (row) => `${(row / CANVAS_H_PX * 100).toFixed(3).replace(/\.?0+$/, "")}%`;
|
|
858
|
+
const body = stops.map((s) => `${s.fill} ${pct(s.top)} ${pct(s.bottom)}`).join(",");
|
|
859
|
+
return `linear-gradient(180deg,${body})`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/lib/svg-ids.ts
|
|
863
|
+
function namespaceSvgIds(svg, prefix) {
|
|
864
|
+
if (!prefix) return svg;
|
|
865
|
+
return svg.replace(/\bid="([^"]*)"/g, (_m, id) => `id="${prefix}${id}"`).replace(/url\(#([^)"']*)\)/g, (_m, id) => `url(#${prefix}${id})`).replace(/\b(xlink:href|href)="#([^"]*)"/g, (_m, attr2, id) => `${attr2}="#${prefix}${id}"`);
|
|
866
|
+
}
|
|
867
|
+
function svgIdPrefix(index) {
|
|
868
|
+
return `s${index}-`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/cli/preview-html.ts
|
|
872
|
+
function escapeHtml(s) {
|
|
873
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
874
|
+
}
|
|
875
|
+
function embedJson(value) {
|
|
876
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
877
|
+
}
|
|
878
|
+
function findingBadge(count, className) {
|
|
879
|
+
if (count === 0) return "";
|
|
880
|
+
return `<div class="${className}" aria-hidden="true">${count}</div>`;
|
|
881
|
+
}
|
|
882
|
+
function slideNode(slide, findingCount) {
|
|
883
|
+
const idAttr = slide.id !== void 0 ? ` data-id="${escapeHtml(slide.id)}"` : "";
|
|
884
|
+
const badge = slide.placeholder ? `<div class="pf-badge" aria-hidden="true">unfilled</div>` : "";
|
|
885
|
+
const fBadge = findingBadge(findingCount, "pf-finding-badge");
|
|
886
|
+
const svg = namespaceSvgIds(slide.svg, svgIdPrefix(slide.index));
|
|
887
|
+
const edgeAttr = edgeAttribute(slide, "data-edge");
|
|
888
|
+
return `<div class="pf-slide" id="pf-slide-${slide.index}" data-index="${slide.index}"${idAttr}${edgeAttr}>${badge}${fBadge}${svg}</div>`;
|
|
889
|
+
}
|
|
890
|
+
function edgeAttribute(slide, name) {
|
|
891
|
+
const edge = slideEdgeFill(slide.svg);
|
|
892
|
+
if (!edge) return "";
|
|
893
|
+
return ` ${name}="${escapeHtml(name === "style" ? `background:${edge}` : edge)}"`;
|
|
894
|
+
}
|
|
895
|
+
function thumbDescription(slide) {
|
|
896
|
+
const parts = [`slide ${slide.index + 1}`, slide.type];
|
|
897
|
+
if (slide.id !== void 0) parts.push(slide.id);
|
|
898
|
+
if (slide.placeholder) parts.push("unfilled");
|
|
899
|
+
return escapeHtml(parts.join(" \xB7 "));
|
|
900
|
+
}
|
|
901
|
+
function positionLabel(slide) {
|
|
902
|
+
const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
|
|
903
|
+
return escapeHtml(`${slide.index + 1}${idPart}`);
|
|
904
|
+
}
|
|
905
|
+
function counterText(slide, total) {
|
|
906
|
+
const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
|
|
907
|
+
return escapeHtml(`${slide.index + 1} / ${total}${idPart}`);
|
|
908
|
+
}
|
|
909
|
+
function thumbButton(slide, isActive, slotContent, findingCount) {
|
|
910
|
+
const description = thumbDescription(slide);
|
|
911
|
+
const badge = slide.placeholder ? `<span class="pf-thumb-badge" aria-hidden="true">unfilled</span>` : "";
|
|
912
|
+
const fBadge = findingBadge(findingCount, "pf-thumb-finding-badge");
|
|
913
|
+
return `<button type="button" class="pf-thumb${isActive ? " pf-thumb-active" : ""}" id="pf-thumb-${slide.index}" data-index="${slide.index}" title="${description}" aria-label="${description}"><span class="pf-thumb-slot" id="pf-slot-${slide.index}"${edgeAttribute(slide, "style")}>${slotContent}</span><span class="pf-thumb-label">${positionLabel(slide)}</span>${badge}${fBadge}</button>`;
|
|
914
|
+
}
|
|
915
|
+
function findingPanelEntry(f) {
|
|
916
|
+
const idPart = f.slideId !== void 0 ? ` \xB7 ${escapeHtml(f.slideId)}` : "";
|
|
917
|
+
return `<button type="button" class="pf-finding" data-page-index="${f.page - 1}"><span class="pf-finding-loc">page ${f.page}${idPart}</span><span class="pf-finding-code">[${escapeHtml(f.code)}]</span> <span class="pf-finding-msg">${escapeHtml(f.message)}</span></button>`;
|
|
918
|
+
}
|
|
919
|
+
var CSS = `
|
|
920
|
+
:root{
|
|
921
|
+
--bg:#f4f4f2;--panel:#fff;--line:#d9d9d4;--ink:#1b1b19;--ink-dim:#6d6d66;
|
|
922
|
+
--stage:#e8e8e4;--warn:#9a6b16;--bad:#a8342d;--radius:10px;
|
|
923
|
+
color-scheme:light
|
|
924
|
+
}
|
|
925
|
+
body[data-surround="dark"]{
|
|
926
|
+
--bg:#17181a;--panel:#1f2124;--line:#34373c;--ink:#e9eaec;--ink-dim:#9195a0;--stage:#101113;
|
|
927
|
+
color-scheme:dark
|
|
928
|
+
}
|
|
929
|
+
*{box-sizing:border-box}
|
|
930
|
+
html,body{height:100%;margin:0}
|
|
931
|
+
body{display:flex;flex-direction:column;background:var(--bg);color:var(--ink);
|
|
932
|
+
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;
|
|
933
|
+
-webkit-font-smoothing:antialiased}
|
|
934
|
+
|
|
935
|
+
header{display:flex;align-items:center;gap:14px;padding:11px 18px;background:var(--panel);
|
|
936
|
+
border-bottom:1px solid var(--line);flex:0 0 auto}
|
|
937
|
+
#pf-title{font-weight:650;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
938
|
+
#pf-counter{font-variant-numeric:tabular-nums;color:var(--ink-dim);white-space:nowrap;font-size:13px}
|
|
939
|
+
.pf-spacer{flex:1 1 auto}
|
|
940
|
+
#pf-audit-note{color:var(--warn);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:46ch}
|
|
941
|
+
#pf-audit-checks{color:var(--ink-dim);font-size:12px;white-space:nowrap}
|
|
942
|
+
|
|
943
|
+
.pf-seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;flex:0 0 auto}
|
|
944
|
+
.pf-seg button{appearance:none;border:0;border-right:1px solid var(--line);background:transparent;color:var(--ink);
|
|
945
|
+
font:inherit;font-size:13px;padding:4px 10px;cursor:pointer}
|
|
946
|
+
.pf-seg button:last-child{border-right:0}
|
|
947
|
+
.pf-seg button[aria-pressed="true"]{background:var(--ink);color:var(--panel)}
|
|
948
|
+
.pf-seg button:hover:not([aria-pressed="true"]){background:var(--stage)}
|
|
949
|
+
|
|
950
|
+
/* container-type:size so the stage below can ask this box how tall it is
|
|
951
|
+
instead of guessing. Legal here because the box's own size never depends on
|
|
952
|
+
its contents: it is a flex item stretched to the body's width and given the
|
|
953
|
+
leftover height by flex:1 1 auto. */
|
|
954
|
+
#pf-stage-wrap{container-type:size;flex:1 1 auto;min-height:0;display:flex;align-items:center;
|
|
955
|
+
justify-content:center;gap:16px;padding:18px}
|
|
956
|
+
/* The stage has to fit both ways: as wide as its height allows, never wider
|
|
957
|
+
than the room left beside the findings panel. Sizing it off its height and
|
|
958
|
+
capping the width is the only arrangement that holds in both directions.
|
|
959
|
+
Measured, not reasoned about: height-driven rules (height:100%) overflow
|
|
960
|
+
when width is the tighter axis, and width-driven ones
|
|
961
|
+
(width:100%;max-height:100%) stop being 16:9 when height is.
|
|
962
|
+
100cqh is the wrap's real content height. It used to be 100vh - 210px, a
|
|
963
|
+
guess at what the header and filmstrip take. Guess high and the slide comes
|
|
964
|
+
out smaller than the window allows; guess low and the box comes out wider
|
|
965
|
+
than 16:9, which aspect-ratio cannot correct once both width and max-height
|
|
966
|
+
are set -- the slide then letterboxes inside its own stage and paints a grey
|
|
967
|
+
bar down each side. The first declaration is that old guess, left in as the
|
|
968
|
+
fallback a browser without container query units will land on. */
|
|
969
|
+
#pf-stage{position:relative;background:var(--stage);box-shadow:0 10px 40px rgba(0,0,0,.18);
|
|
970
|
+
aspect-ratio:16/9;max-height:100%;
|
|
971
|
+
width:min(100%,calc((100vh - 210px) * 16 / 9));
|
|
972
|
+
width:min(100%,calc(100cqh * 16 / 9))}
|
|
973
|
+
#pf-stage,.pf-thumb-slot{position:relative}
|
|
974
|
+
.pf-slide{position:absolute;inset:0}
|
|
975
|
+
.pf-slide svg{display:block;width:100%;height:100%}
|
|
976
|
+
|
|
977
|
+
#pf-side{flex:0 0 250px;align-self:stretch;overflow-y:auto;background:var(--panel);
|
|
978
|
+
border:1px solid var(--line);border-radius:var(--radius);padding:12px 13px;font-size:13px}
|
|
979
|
+
#pf-side h2{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-dim)}
|
|
980
|
+
.pf-finding{display:block;width:100%;text-align:left;background:transparent;border:1px solid var(--line);
|
|
981
|
+
border-radius:8px;padding:7px 9px;margin-bottom:7px;cursor:pointer;font:inherit;color:var(--ink)}
|
|
982
|
+
.pf-finding:hover{background:var(--stage)}
|
|
983
|
+
.pf-finding-loc{display:block;font-size:11px;color:var(--ink-dim)}
|
|
984
|
+
.pf-finding-code{display:inline-block;font-size:11px;font-weight:700;color:var(--bad)}
|
|
985
|
+
.pf-finding-msg{display:block;font-size:12px;color:var(--ink-dim);margin-top:2px}
|
|
986
|
+
|
|
987
|
+
/* The strip is cut off by its own right edge when it scrolls, which reads as
|
|
988
|
+
a clipping bug rather than as "there is more this way" -- the last thumbnail
|
|
989
|
+
is simply sliced, and inside an embedder's rounded frame it is sliced on a
|
|
990
|
+
curve. The two widths are driven from script (see fadeStrip) and are 0 until
|
|
991
|
+
there is really something hidden on that side, so a strip that fits is
|
|
992
|
+
untouched: at 0 the gradient is opaque from edge to edge. */
|
|
993
|
+
#pf-filmstrip{display:flex;gap:10px;padding:11px 18px;overflow-x:auto;background:var(--panel);
|
|
994
|
+
border-top:1px solid var(--line);flex:0 0 auto;--pf-fade-l:0px;--pf-fade-r:0px;
|
|
995
|
+
-webkit-mask-image:linear-gradient(to right,transparent 0,#000 var(--pf-fade-l),
|
|
996
|
+
#000 calc(100% - var(--pf-fade-r)),transparent 100%);
|
|
997
|
+
mask-image:linear-gradient(to right,transparent 0,#000 var(--pf-fade-l),
|
|
998
|
+
#000 calc(100% - var(--pf-fade-r)),transparent 100%)}
|
|
999
|
+
.pf-thumb{flex:0 0 auto;width:154px;padding:0;margin:0;border:1px solid var(--line);background:var(--panel);
|
|
1000
|
+
cursor:pointer;border-radius:8px;overflow:hidden;position:relative;font:inherit;text-align:left}
|
|
1001
|
+
.pf-thumb:hover{border-color:var(--ink-dim)}
|
|
1002
|
+
.pf-thumb-active,.pf-thumb-active:hover{border-color:var(--ink);box-shadow:0 0 0 1px var(--ink)}
|
|
1003
|
+
.pf-thumb-slot{display:block;width:100%;aspect-ratio:16/9;background:var(--stage)}
|
|
1004
|
+
/* The active slide's SVG lives in the stage, not here \u2014 one node per slide,
|
|
1005
|
+
moved between the two homes. Without this the vacated slot reads as a
|
|
1006
|
+
broken empty box, so it states what it is instead. */
|
|
1007
|
+
.pf-thumb-active .pf-thumb-slot::after{content:"on stage";position:absolute;inset:0;display:flex;
|
|
1008
|
+
align-items:center;justify-content:center;font-size:10px;letter-spacing:.08em;text-transform:uppercase;
|
|
1009
|
+
color:var(--ink-dim)}
|
|
1010
|
+
.pf-thumb-label{display:block;font-size:11px;line-height:1.5;padding:4px 7px;color:var(--ink-dim);
|
|
1011
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1012
|
+
.pf-badge,.pf-thumb-badge{position:absolute;top:5px;right:5px;background:var(--warn);color:#fff;font-size:10px;
|
|
1013
|
+
font-weight:700;letter-spacing:.03em;padding:2px 6px;border-radius:999px;text-transform:uppercase;z-index:2;pointer-events:none}
|
|
1014
|
+
.pf-finding-badge,.pf-thumb-finding-badge{position:absolute;top:5px;left:5px;background:var(--bad);color:#fff;
|
|
1015
|
+
font-size:10px;font-weight:700;padding:2px 6px;border-radius:999px;z-index:2;pointer-events:none}
|
|
1016
|
+
`.trim();
|
|
1017
|
+
var JS = `
|
|
1018
|
+
(function () {
|
|
1019
|
+
var stage = document.getElementById('pf-stage')
|
|
1020
|
+
var counter = document.getElementById('pf-counter')
|
|
1021
|
+
var thumbs = Array.prototype.slice.call(document.querySelectorAll('.pf-thumb'))
|
|
1022
|
+
var total = thumbs.length
|
|
1023
|
+
if (total === 0) return
|
|
1024
|
+
var current = parseInt(thumbs[0].getAttribute('data-index'), 10)
|
|
1025
|
+
|
|
1026
|
+
function slideEl(i) { return document.getElementById('pf-slide-' + i) }
|
|
1027
|
+
function slotEl(i) { return document.getElementById('pf-slot-' + i) }
|
|
1028
|
+
function thumbEl(i) { return document.getElementById('pf-thumb-' + i) }
|
|
1029
|
+
function thumbPos(i) {
|
|
1030
|
+
for (var t = 0; t < thumbs.length; t++) {
|
|
1031
|
+
if (parseInt(thumbs[t].getAttribute('data-index'), 10) === i) return t
|
|
1032
|
+
}
|
|
1033
|
+
return -1
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function updateCounter(i) {
|
|
1037
|
+
var el = slideEl(i)
|
|
1038
|
+
if (!el) return
|
|
1039
|
+
var text = (thumbPos(i) + 1) + ' / ' + total
|
|
1040
|
+
var id = el.getAttribute('data-id')
|
|
1041
|
+
if (id) text += ' \xB7 ' + id
|
|
1042
|
+
counter.textContent = text
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function activate(i) {
|
|
1046
|
+
if (i === current) return
|
|
1047
|
+
var nextSlide = slideEl(i)
|
|
1048
|
+
var nextThumb = thumbEl(i)
|
|
1049
|
+
if (!nextSlide || !nextThumb) return
|
|
1050
|
+
var prevSlide = slideEl(current)
|
|
1051
|
+
var prevSlot = slotEl(current)
|
|
1052
|
+
if (prevSlide && prevSlot) prevSlot.appendChild(prevSlide)
|
|
1053
|
+
var prevThumb = thumbEl(current)
|
|
1054
|
+
if (prevThumb) prevThumb.classList.remove('pf-thumb-active')
|
|
1055
|
+
stage.appendChild(nextSlide)
|
|
1056
|
+
stage.style.background = nextSlide.getAttribute('data-edge') || ''
|
|
1057
|
+
nextThumb.classList.add('pf-thumb-active')
|
|
1058
|
+
nextThumb.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
1059
|
+
current = i
|
|
1060
|
+
updateCounter(i)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
thumbs.forEach(function (t) {
|
|
1064
|
+
t.addEventListener('click', function () {
|
|
1065
|
+
activate(parseInt(t.getAttribute('data-index'), 10))
|
|
1066
|
+
})
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
document.addEventListener('keydown', function (e) {
|
|
1070
|
+
var pos = thumbPos(current)
|
|
1071
|
+
if (e.key === 'ArrowRight' && pos < total - 1) {
|
|
1072
|
+
activate(parseInt(thumbs[pos + 1].getAttribute('data-index'), 10))
|
|
1073
|
+
} else if (e.key === 'ArrowLeft' && pos > 0) {
|
|
1074
|
+
activate(parseInt(thumbs[pos - 1].getAttribute('data-index'), 10))
|
|
1075
|
+
}
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
// Click a finding, jump to its page \u2014 the same activate() a thumbnail uses.
|
|
1079
|
+
Array.prototype.slice.call(document.querySelectorAll('.pf-finding')).forEach(function (b) {
|
|
1080
|
+
b.addEventListener('click', function () {
|
|
1081
|
+
activate(parseInt(b.getAttribute('data-page-index'), 10))
|
|
1082
|
+
})
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
// Fade whichever end of the strip has more thumbnails behind it. Driven
|
|
1086
|
+
// from here rather than left to CSS because no CSS rule can ask whether a
|
|
1087
|
+
// box currently overflows. See the #pf-filmstrip rule for why it matters.
|
|
1088
|
+
var strip = document.getElementById('pf-filmstrip')
|
|
1089
|
+
function fadeStrip() {
|
|
1090
|
+
if (!strip || thumbs.length === 0) return
|
|
1091
|
+
// Measured off the thumbnails, not off scrollWidth. The strip's own
|
|
1092
|
+
// 18px of padding counts as scrollable width, so scrollWidth says there
|
|
1093
|
+
// is more to the right while a reader is already looking at the last
|
|
1094
|
+
// thumbnail \u2014 and activate()'s scrollIntoView stops exactly there,
|
|
1095
|
+
// leaving the padding unscrolled. The fade would then claim there is
|
|
1096
|
+
// more to see and dim the selected thumbnail's own ring to do it. Asking
|
|
1097
|
+
// whether a thumbnail is actually cut off is what the fade means anyway.
|
|
1098
|
+
var box = strip.getBoundingClientRect()
|
|
1099
|
+
var hiddenLeft = box.left - thumbs[0].getBoundingClientRect().left
|
|
1100
|
+
var hiddenRight = thumbs[thumbs.length - 1].getBoundingClientRect().right - box.right
|
|
1101
|
+
// A pixel of slack: sub-pixel layout leaves a fraction of overflow on
|
|
1102
|
+
// strips that visibly fit, and fading those is the bug in reverse.
|
|
1103
|
+
strip.style.setProperty('--pf-fade-l', (hiddenLeft > 1 ? 28 : 0) + 'px')
|
|
1104
|
+
strip.style.setProperty('--pf-fade-r', (hiddenRight > 1 ? 28 : 0) + 'px')
|
|
1105
|
+
}
|
|
1106
|
+
if (strip) {
|
|
1107
|
+
fadeStrip()
|
|
1108
|
+
strip.addEventListener('scroll', fadeStrip, { passive: true })
|
|
1109
|
+
// The strip also stops and starts overflowing as the window changes width,
|
|
1110
|
+
// and scrollIntoView moves it without a resize.
|
|
1111
|
+
window.addEventListener('resize', fadeStrip)
|
|
1112
|
+
if (window.ResizeObserver) new ResizeObserver(fadeStrip).observe(strip)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Open on a given page: #page=3 is the third thumbnail, not slide index 3.
|
|
1116
|
+
// A reader who clicks page 3 in a harness that embeds this file expects to
|
|
1117
|
+
// land on page 3, and the embedder has no other way to say so \u2014 it holds a
|
|
1118
|
+
// URL, not a handle on this script. Reading the position rather than the
|
|
1119
|
+
// index keeps that promise honest for a deck whose slides are not numbered
|
|
1120
|
+
// 0..n-1. Silently ignored when it names a page this deck does not have.
|
|
1121
|
+
function fromHash() {
|
|
1122
|
+
var m = /(?:^|[#&])page=(\\d+)/.exec(location.hash || '')
|
|
1123
|
+
if (!m) return
|
|
1124
|
+
var pos = parseInt(m[1], 10) - 1
|
|
1125
|
+
// >= 0, not > 0. Page 1 is a no-op on first load, but not afterwards: a
|
|
1126
|
+
// reader who pages forward and then hits Back gets #page=1 in the URL,
|
|
1127
|
+
// and skipping it leaves the address bar and the deck disagreeing.
|
|
1128
|
+
// activate() already returns early when it is handed the current page.
|
|
1129
|
+
if (pos >= 0 && pos < total) activate(parseInt(thumbs[pos].getAttribute('data-index'), 10))
|
|
1130
|
+
}
|
|
1131
|
+
fromHash()
|
|
1132
|
+
window.addEventListener('hashchange', fromHash)
|
|
1133
|
+
|
|
1134
|
+
// Light/dark surround. A deck is judged on color and weight, and the
|
|
1135
|
+
// surround it sits on changes both \u2014 a dark theme reads muddy on a light
|
|
1136
|
+
// page and vice versa, so the reviewer picks rather than the page deciding.
|
|
1137
|
+
var seg = document.getElementById('pf-surround')
|
|
1138
|
+
if (seg) {
|
|
1139
|
+
seg.addEventListener('click', function (e) {
|
|
1140
|
+
var btn = e.target.closest('button')
|
|
1141
|
+
if (!btn) return
|
|
1142
|
+
document.body.setAttribute('data-surround', btn.getAttribute('data-surround'))
|
|
1143
|
+
Array.prototype.slice.call(seg.children).forEach(function (b) {
|
|
1144
|
+
b.setAttribute('aria-pressed', String(b === btn))
|
|
1145
|
+
})
|
|
1146
|
+
})
|
|
1147
|
+
}
|
|
1148
|
+
})()
|
|
1149
|
+
`.trim();
|
|
1150
|
+
function buildPreviewHtml(input) {
|
|
1151
|
+
const { title, slides, findings = [], auditNote, checks } = input;
|
|
1152
|
+
const total = slides.length;
|
|
1153
|
+
const escapedTitle = escapeHtml(title);
|
|
1154
|
+
const findingsByPage = /* @__PURE__ */ new Map();
|
|
1155
|
+
for (const f of findings) {
|
|
1156
|
+
const list = findingsByPage.get(f.page);
|
|
1157
|
+
if (list) list.push(f);
|
|
1158
|
+
else findingsByPage.set(f.page, [f]);
|
|
1159
|
+
}
|
|
1160
|
+
const countFor = (slide) => findingsByPage.get(slide.index + 1)?.length ?? 0;
|
|
1161
|
+
const stageSlide = total > 0 ? slideNode(slides[0], countFor(slides[0])) : "";
|
|
1162
|
+
const stageEdge = total > 0 ? edgeAttribute(slides[0], "style") : "";
|
|
1163
|
+
const thumbs = slides.map((s, i) => thumbButton(s, i === 0, i === 0 ? "" : slideNode(s, countFor(s)), countFor(s))).join("");
|
|
1164
|
+
const initialCounter = total > 0 ? counterText(slides[0], total) : "0 / 0";
|
|
1165
|
+
const auditPanel = findings.length > 0 ? `<section id="pf-audit-panel"><h2>Audit findings (${findings.length})</h2><div id="pf-audit-list">${findings.map(findingPanelEntry).join("")}</div></section>` : "";
|
|
1166
|
+
const findingsDataScript = findings.length > 0 ? `<script type="application/json" id="pf-audit-findings">${embedJson(findings)}</script>` : "";
|
|
1167
|
+
const auditNoteHtml = auditNote !== void 0 ? `<span id="pf-audit-note">${escapeHtml(auditNote)}</span>` : "";
|
|
1168
|
+
const checksLine = checks !== void 0 ? `<span id="pf-audit-checks">audit: svg ${checks.svg} \xB7 pixels ${checks.pixels}</span>` : "";
|
|
1169
|
+
const sideHtml = auditPanel ? `<aside id="pf-side">${auditPanel}</aside>` : "";
|
|
1170
|
+
return `<!doctype html>
|
|
1171
|
+
<html lang="en">
|
|
1172
|
+
<head>
|
|
1173
|
+
<meta charset="utf-8">
|
|
1174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1175
|
+
<title>${escapedTitle} \u2014 pptwise preview</title>
|
|
1176
|
+
<style>${CSS}</style>
|
|
1177
|
+
</head>
|
|
1178
|
+
<body data-surround="light">
|
|
1179
|
+
<header>
|
|
1180
|
+
<span id="pf-title">${escapedTitle}</span>
|
|
1181
|
+
<span id="pf-counter">${initialCounter}</span>
|
|
1182
|
+
<span class="pf-spacer"></span>
|
|
1183
|
+
${auditNoteHtml}
|
|
1184
|
+
${checksLine}
|
|
1185
|
+
<div class="pf-seg" id="pf-surround" role="group" aria-label="surround">
|
|
1186
|
+
<button type="button" data-surround="light" aria-pressed="true">Light</button>
|
|
1187
|
+
<button type="button" data-surround="dark" aria-pressed="false">Dark</button>
|
|
1188
|
+
</div>
|
|
1189
|
+
</header>
|
|
1190
|
+
<div id="pf-stage-wrap"><div id="pf-stage"${stageEdge}>${stageSlide}</div>${sideHtml}</div>
|
|
1191
|
+
<nav id="pf-filmstrip" aria-label="slides">${thumbs}</nav>
|
|
1192
|
+
${findingsDataScript}
|
|
1193
|
+
<script>${JS}</script>
|
|
1194
|
+
</body>
|
|
1195
|
+
</html>
|
|
1196
|
+
`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/cli/preview-manifest.ts
|
|
1200
|
+
var PREVIEW_MANIFEST_VERSION = 1;
|
|
1201
|
+
function pageSlug(slide) {
|
|
1202
|
+
const raw = slide.id?.trim();
|
|
1203
|
+
if (raw) {
|
|
1204
|
+
const safe = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1205
|
+
if (safe) return safe;
|
|
1206
|
+
}
|
|
1207
|
+
return `page-${String(slide.index + 1).padStart(3, "0")}`;
|
|
1208
|
+
}
|
|
1209
|
+
function pageIds(slides) {
|
|
1210
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1211
|
+
const claim = (candidate) => {
|
|
1212
|
+
if (!taken.has(candidate)) {
|
|
1213
|
+
taken.add(candidate);
|
|
1214
|
+
return candidate;
|
|
1215
|
+
}
|
|
1216
|
+
for (let n = 2; ; n++) {
|
|
1217
|
+
const suffixed = `${candidate}-${n}`;
|
|
1218
|
+
if (!taken.has(suffixed)) {
|
|
1219
|
+
taken.add(suffixed);
|
|
1220
|
+
return suffixed;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
return slides.map((slide) => {
|
|
1225
|
+
const slug = pageSlug(slide);
|
|
1226
|
+
if (!taken.has(slug)) return claim(slug);
|
|
1227
|
+
return claim(`page-${String(slide.index + 1).padStart(3, "0")}`);
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
function buildPreviewManifest(input) {
|
|
1231
|
+
const byPage = /* @__PURE__ */ new Map();
|
|
1232
|
+
for (const f of input.findings ?? []) {
|
|
1233
|
+
const list = byPage.get(f.page) ?? [];
|
|
1234
|
+
list.push({ code: f.code, message: f.message });
|
|
1235
|
+
byPage.set(f.page, list);
|
|
1236
|
+
}
|
|
1237
|
+
const ids = pageIds(input.slides);
|
|
1238
|
+
const pages = input.slides.map((slide, i) => {
|
|
1239
|
+
const findings = byPage.get(slide.index + 1) ?? [];
|
|
1240
|
+
return {
|
|
1241
|
+
id: ids[i],
|
|
1242
|
+
page: slide.index + 1,
|
|
1243
|
+
type: slide.type,
|
|
1244
|
+
file: slide.file,
|
|
1245
|
+
...slide.id !== void 0 ? { slideId: slide.id } : {},
|
|
1246
|
+
...slide.placeholder ? { placeholder: true } : {},
|
|
1247
|
+
...findings.length > 0 ? { findings } : {}
|
|
1248
|
+
};
|
|
1249
|
+
});
|
|
1250
|
+
return {
|
|
1251
|
+
manifestVersion: PREVIEW_MANIFEST_VERSION,
|
|
1252
|
+
generator: "pptwise preview",
|
|
1253
|
+
pptwiseVersion: input.pptwiseVersion,
|
|
1254
|
+
title: input.title,
|
|
1255
|
+
slide: { width: input.width, height: input.height },
|
|
1256
|
+
...input.checks !== void 0 ? { checks: input.checks } : {},
|
|
1257
|
+
...input.auditNote !== void 0 ? { auditNote: input.auditNote } : {},
|
|
1258
|
+
pages
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// src/cli/workspace.ts
|
|
1263
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1264
|
+
import { appendFile, mkdir as mkdir3, readFile as readFile5, readdir as readdir2, stat as stat2, unlink } from "fs/promises";
|
|
1265
|
+
import { basename as basename3, dirname as dirname2, extname as extname3, join as join4, resolve as resolve5 } from "path";
|
|
1266
|
+
|
|
1267
|
+
// src/cli/child.ts
|
|
1268
|
+
import {
|
|
1269
|
+
execFile,
|
|
1270
|
+
spawn
|
|
1271
|
+
} from "child_process";
|
|
1272
|
+
|
|
1273
|
+
// src/cli/win-exec.ts
|
|
1274
|
+
import * as fs from "fs";
|
|
1275
|
+
import * as path from "path";
|
|
1276
|
+
|
|
1277
|
+
// src/cli/path-lookup.ts
|
|
1278
|
+
import { constants } from "fs";
|
|
1279
|
+
import { access as fsAccess } from "fs/promises";
|
|
1280
|
+
import { delimiter as defaultDelimiter, join as defaultJoin } from "path";
|
|
1281
|
+
var DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
1282
|
+
function envValue(env, name) {
|
|
1283
|
+
const upper = name.toUpperCase();
|
|
1284
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1285
|
+
if (key.toUpperCase() === upper) return value;
|
|
1286
|
+
}
|
|
1287
|
+
return void 0;
|
|
1288
|
+
}
|
|
1289
|
+
async function findOnPath(bin, env, deps = {}) {
|
|
1290
|
+
const platform = deps.platform ?? process.platform;
|
|
1291
|
+
const join10 = deps.join ?? defaultJoin;
|
|
1292
|
+
const delimiter = deps.delimiter ?? defaultDelimiter;
|
|
1293
|
+
const tryAccess = deps.access ?? ((target, mode) => fsAccess(target, mode));
|
|
1294
|
+
const dirs = (envValue(env, "PATH") ?? "").split(delimiter).filter(Boolean);
|
|
1295
|
+
const suffixes = platform === "win32" ? [...(envValue(env, "PATHEXT") ?? DEFAULT_PATHEXT).split(";").map((ext) => ext.trim()).filter(Boolean), ""] : [""];
|
|
1296
|
+
for (const dir of dirs) {
|
|
1297
|
+
for (const suffix of suffixes) {
|
|
1298
|
+
const full = join10(dir, `${bin}${suffix}`);
|
|
1299
|
+
try {
|
|
1300
|
+
await tryAccess(full, constants.X_OK);
|
|
1301
|
+
return full;
|
|
1302
|
+
} catch {
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/cli/win-exec.ts
|
|
1310
|
+
function existenceOf(target) {
|
|
1311
|
+
try {
|
|
1312
|
+
return fs.statSync(target).isDirectory() ? "directory" : "present";
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
return error.code === "ENOENT" ? "absent" : "unknown";
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
var REAL_DEPS = {
|
|
1318
|
+
platform: process.platform,
|
|
1319
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
1320
|
+
resolveOnPath: (bin, env) => findOnPath(bin, env),
|
|
1321
|
+
existence: existenceOf
|
|
1322
|
+
};
|
|
1323
|
+
var UnrecognizedBatchShimError = class extends Error {
|
|
1324
|
+
constructor(command) {
|
|
1325
|
+
super(
|
|
1326
|
+
`Cannot spawn "${command}" as a Windows batch file without a shell. Node refuses to run .cmd/.bat files without a shell (EINVAL after CVE-2024-27980), and wrapping the call in cmd.exe would truncate multi-line arguments at the first newline. Use a recognised npm/pnpm shim, or spawn the real executable.`
|
|
1327
|
+
);
|
|
1328
|
+
this.name = "UnrecognizedBatchShimError";
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
var PATHEXT_EDIT = /^@?SET PATHEXT=%PATHEXT:;\.([A-Z]+);=;%$/;
|
|
1332
|
+
var FLAG = /^--?[A-Za-z0-9][-A-Za-z0-9._]*(?:=[-A-Za-z0-9._/\\:]+)?$/;
|
|
1333
|
+
function withWindowsEnvAssignment(env, name, value) {
|
|
1334
|
+
const upper = name.toUpperCase();
|
|
1335
|
+
const next = {};
|
|
1336
|
+
let replaced = false;
|
|
1337
|
+
for (const [key, val] of Object.entries(env)) {
|
|
1338
|
+
if (key.toUpperCase() === upper) {
|
|
1339
|
+
next[key] = value;
|
|
1340
|
+
replaced = true;
|
|
1341
|
+
} else {
|
|
1342
|
+
next[key] = val;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (!replaced) next[name] = value;
|
|
1346
|
+
return next;
|
|
1347
|
+
}
|
|
1348
|
+
function withoutWindowsEnvVariable(env, name) {
|
|
1349
|
+
const upper = name.toUpperCase();
|
|
1350
|
+
const next = {};
|
|
1351
|
+
for (const [key, val] of Object.entries(env)) {
|
|
1352
|
+
if (key.toUpperCase() !== upper) next[key] = val;
|
|
1353
|
+
}
|
|
1354
|
+
return next;
|
|
1355
|
+
}
|
|
1356
|
+
function withPathextEdit(env, removed) {
|
|
1357
|
+
const current = envValue(env, "PATHEXT") ?? "";
|
|
1358
|
+
const edited = current.replace(new RegExp(`;\\.${removed};`, "gi"), ";");
|
|
1359
|
+
if (edited === "") return withoutWindowsEnvVariable(env, "PATHEXT");
|
|
1360
|
+
return withWindowsEnvAssignment(env, "PATHEXT", edited);
|
|
1361
|
+
}
|
|
1362
|
+
function isFullyQualifiedLocalPath(target) {
|
|
1363
|
+
return /^[A-Za-z]:[\\/]/.test(target);
|
|
1364
|
+
}
|
|
1365
|
+
function templatePath(text, shimDir) {
|
|
1366
|
+
const rooted = /^%(?:dp0%|~dp0)\\?(.*)$/i.exec(text);
|
|
1367
|
+
if (!rooted) {
|
|
1368
|
+
return isFullyQualifiedLocalPath(text) && !text.includes("%") ? text : null;
|
|
1369
|
+
}
|
|
1370
|
+
const rest = rooted[1] ?? "";
|
|
1371
|
+
if (rest.includes("%") || rest === "") return null;
|
|
1372
|
+
return path.win32.normalize(path.win32.join(shimDir, rest));
|
|
1373
|
+
}
|
|
1374
|
+
function interpreterTail(middle, shimDir) {
|
|
1375
|
+
const tokens = middle.trim().split(/\s+/).filter((token) => token !== "");
|
|
1376
|
+
if (tokens.length === 0) return null;
|
|
1377
|
+
const quoted = /^"([^"]*)"$/.exec(tokens[tokens.length - 1] ?? "");
|
|
1378
|
+
if (!quoted) return null;
|
|
1379
|
+
const entry = templatePath(quoted[1], shimDir);
|
|
1380
|
+
if (entry === null) return null;
|
|
1381
|
+
const flags = tokens.slice(0, -1);
|
|
1382
|
+
return flags.every((flag) => FLAG.test(flag)) ? [...flags, entry] : null;
|
|
1383
|
+
}
|
|
1384
|
+
async function nodeRecipe(shimDir, tail, effective, env, deps) {
|
|
1385
|
+
const local = path.win32.join(shimDir, "node.exe");
|
|
1386
|
+
const found = deps.existence(local);
|
|
1387
|
+
if (found === "unknown") return null;
|
|
1388
|
+
if (found === "present" || found === "directory") {
|
|
1389
|
+
return {
|
|
1390
|
+
command: local,
|
|
1391
|
+
args: tail,
|
|
1392
|
+
...effective.present === env ? {} : { env: effective.present }
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
const resolved = await deps.resolveOnPath("node", effective.absent);
|
|
1396
|
+
return resolved === null ? null : {
|
|
1397
|
+
command: resolved,
|
|
1398
|
+
args: tail,
|
|
1399
|
+
...effective.absent === env ? {} : { env: effective.absent }
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
var NPM_PROLOGUE = [
|
|
1403
|
+
"@ECHO off",
|
|
1404
|
+
"GOTO start",
|
|
1405
|
+
":find_dp0",
|
|
1406
|
+
"SET dp0=%~dp0",
|
|
1407
|
+
"EXIT /b",
|
|
1408
|
+
":start",
|
|
1409
|
+
"SETLOCAL",
|
|
1410
|
+
"CALL :find_dp0"
|
|
1411
|
+
];
|
|
1412
|
+
var NPM_PREFIX = "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ";
|
|
1413
|
+
var NPM_EXEC_LEGACY = /^"%_prog%"(.*)\s%\*$/;
|
|
1414
|
+
var NPM_EXEC_CURRENT = /^set PATHEXT=%PATHEXT:;\.([A-Z]+);=;% & "%_prog%"(.*)\s%\*$/;
|
|
1415
|
+
var NPM_NATIVE_EXEC = /^"([^"]*)"\s+%\*$/;
|
|
1416
|
+
var PNPM_NODE_PATH_IF = /^@IF NOT DEFINED NODE_PATH \($/;
|
|
1417
|
+
var PNPM_NODE_PATH_SET = /^@SET "NODE_PATH=([^"%]+)"$/;
|
|
1418
|
+
var PNPM_NODE_PATH_PREPEND = /^@SET "NODE_PATH=([^"%]+);%NODE_PATH%"$/;
|
|
1419
|
+
var PNPM_IF = /^@IF EXIST "([^"]*)" \($/;
|
|
1420
|
+
var PNPM_ARM = /^"([^"]*)"(.*)\s%\*$/;
|
|
1421
|
+
var PNPM_BARE_ARM = /^node(.*)\s%\*$/;
|
|
1422
|
+
var PNPM_ONELINE = /^@"([^"]*)"(.*)\s%\*$/;
|
|
1423
|
+
function normalizeLines(content) {
|
|
1424
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim());
|
|
1425
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
1426
|
+
return lines;
|
|
1427
|
+
}
|
|
1428
|
+
async function matchNpm(lines, shimDir, env, deps) {
|
|
1429
|
+
if (lines.length < NPM_PROLOGUE.length) return null;
|
|
1430
|
+
if (!NPM_PROLOGUE.every((expected, index) => lines[index] === expected)) return null;
|
|
1431
|
+
const body = lines.slice(NPM_PROLOGUE.length).filter((line) => line !== "");
|
|
1432
|
+
if (body.length === 1) {
|
|
1433
|
+
const native = NPM_NATIVE_EXEC.exec(body[0] ?? "");
|
|
1434
|
+
if (!native) return null;
|
|
1435
|
+
const target = templatePath(native[1], shimDir);
|
|
1436
|
+
if (target === null || !/\.exe$/i.test(target)) return null;
|
|
1437
|
+
const normalizedDir = path.win32.normalize(shimDir);
|
|
1438
|
+
const dp0 = normalizedDir.endsWith("\\") ? normalizedDir : `${normalizedDir}\\`;
|
|
1439
|
+
return { command: target, args: [], env: withWindowsEnvAssignment(env, "dp0", dp0) };
|
|
1440
|
+
}
|
|
1441
|
+
if (body[0] !== 'IF EXIST "%dp0%\\node.exe" (') return null;
|
|
1442
|
+
if (body[1] !== 'SET "_prog=%dp0%\\node.exe"') return null;
|
|
1443
|
+
if (body[2] !== ") ELSE (") return null;
|
|
1444
|
+
if (body[3] !== 'SET "_prog=node"') return null;
|
|
1445
|
+
if (body.length === 7) {
|
|
1446
|
+
if (!PATHEXT_EDIT.test(body[4] ?? "")) return null;
|
|
1447
|
+
if (body[5] !== ")") return null;
|
|
1448
|
+
if (!(body[6] ?? "").startsWith(NPM_PREFIX)) return null;
|
|
1449
|
+
const exec = NPM_EXEC_LEGACY.exec((body[6] ?? "").slice(NPM_PREFIX.length));
|
|
1450
|
+
if (!exec) return null;
|
|
1451
|
+
const tail = interpreterTail(exec[1] ?? "", shimDir);
|
|
1452
|
+
return tail === null ? null : nodeRecipe(shimDir, tail, { present: env, absent: env }, env, deps);
|
|
1453
|
+
}
|
|
1454
|
+
if (body.length === 6) {
|
|
1455
|
+
if (body[4] !== ")") return null;
|
|
1456
|
+
if (!(body[5] ?? "").startsWith(NPM_PREFIX)) return null;
|
|
1457
|
+
const exec = NPM_EXEC_CURRENT.exec((body[5] ?? "").slice(NPM_PREFIX.length));
|
|
1458
|
+
if (!exec) return null;
|
|
1459
|
+
const tail = interpreterTail(exec[2] ?? "", shimDir);
|
|
1460
|
+
if (tail === null) return null;
|
|
1461
|
+
const edited = withPathextEdit(env, exec[1] ?? "JS");
|
|
1462
|
+
return nodeRecipe(shimDir, tail, { present: edited, absent: edited }, env, deps);
|
|
1463
|
+
}
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
async function matchPnpm(lines, shimDir, env, deps) {
|
|
1467
|
+
if (lines[0] !== "@SETLOCAL") return null;
|
|
1468
|
+
let body = lines.slice(1).filter((line) => line !== "");
|
|
1469
|
+
let baseEnv = env;
|
|
1470
|
+
if (body.length > 0 && PNPM_NODE_PATH_IF.test(body[0] ?? "")) {
|
|
1471
|
+
if (body.length < 5) return null;
|
|
1472
|
+
const set = PNPM_NODE_PATH_SET.exec(body[1] ?? "");
|
|
1473
|
+
const prepend = PNPM_NODE_PATH_PREPEND.exec(body[3] ?? "");
|
|
1474
|
+
if (!set || body[2] !== ") ELSE (" || !prepend || body[4] !== ")") return null;
|
|
1475
|
+
if (set[1] !== prepend[1]) return null;
|
|
1476
|
+
const currentValue = envValue(env, "NODE_PATH");
|
|
1477
|
+
const defined = currentValue !== void 0 && currentValue !== "";
|
|
1478
|
+
baseEnv = withWindowsEnvAssignment(env, "NODE_PATH", defined ? `${set[1]};${currentValue}` : set[1]);
|
|
1479
|
+
body = body.slice(5);
|
|
1480
|
+
}
|
|
1481
|
+
if (body.length === 1) {
|
|
1482
|
+
const one = PNPM_ONELINE.exec(body[0] ?? "");
|
|
1483
|
+
if (!one) return null;
|
|
1484
|
+
const program2 = templatePath(one[1], shimDir);
|
|
1485
|
+
if (program2 === null) return null;
|
|
1486
|
+
const onelineEnv = baseEnv === env ? {} : { env: baseEnv };
|
|
1487
|
+
if ((one[2] ?? "").trim() === "") {
|
|
1488
|
+
return /\.exe$/i.test(program2) ? { command: program2, args: [], ...onelineEnv } : null;
|
|
1489
|
+
}
|
|
1490
|
+
const tail2 = interpreterTail(one[2] ?? "", shimDir);
|
|
1491
|
+
return tail2 === null || /\.(cmd|bat)$/i.test(program2) ? null : { command: program2, args: tail2, ...onelineEnv };
|
|
1492
|
+
}
|
|
1493
|
+
if (body.length !== 6) return null;
|
|
1494
|
+
const opened = PNPM_IF.exec(body[0] ?? "");
|
|
1495
|
+
if (!opened) return null;
|
|
1496
|
+
const local = path.win32.join(shimDir, "node.exe");
|
|
1497
|
+
const candidate = templatePath(opened[1], shimDir);
|
|
1498
|
+
if (candidate === null || candidate.toLowerCase() !== local.toLowerCase()) return null;
|
|
1499
|
+
const present = PNPM_ARM.exec(body[1] ?? "");
|
|
1500
|
+
if (!present) return null;
|
|
1501
|
+
const presentProgram = templatePath(present[1], shimDir);
|
|
1502
|
+
if (presentProgram === null || presentProgram.toLowerCase() !== local.toLowerCase()) return null;
|
|
1503
|
+
if (body[2] !== ") ELSE (") return null;
|
|
1504
|
+
const edit = PATHEXT_EDIT.exec(body[3] ?? "");
|
|
1505
|
+
if (!edit) return null;
|
|
1506
|
+
const absent = PNPM_BARE_ARM.exec(body[4] ?? "");
|
|
1507
|
+
if (!absent) return null;
|
|
1508
|
+
if (body[5] !== ")") return null;
|
|
1509
|
+
const tail = interpreterTail(present[2] ?? "", shimDir);
|
|
1510
|
+
const otherTail = interpreterTail(absent[1] ?? "", shimDir);
|
|
1511
|
+
if (tail === null || otherTail === null || tail.join(" ") !== otherTail.join(" ")) return null;
|
|
1512
|
+
const removed = edit[1] ?? "JS";
|
|
1513
|
+
return nodeRecipe(
|
|
1514
|
+
shimDir,
|
|
1515
|
+
tail,
|
|
1516
|
+
{ present: baseEnv, absent: withPathextEdit(baseEnv, removed) },
|
|
1517
|
+
env,
|
|
1518
|
+
deps
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
async function recognizeShim(cmdPath, content, env, deps = REAL_DEPS) {
|
|
1522
|
+
if (!isFullyQualifiedLocalPath(cmdPath)) return null;
|
|
1523
|
+
const shimDir = path.win32.dirname(cmdPath);
|
|
1524
|
+
const lines = normalizeLines(content);
|
|
1525
|
+
if (lines.length === 0) return null;
|
|
1526
|
+
return await matchNpm(lines, shimDir, env, deps) ?? await matchPnpm(lines, shimDir, env, deps);
|
|
1527
|
+
}
|
|
1528
|
+
async function resolveSpawnPlan(command, args, env = process.env, _cwd, deps = REAL_DEPS) {
|
|
1529
|
+
if (deps.platform !== "win32") return { command, args: [...args] };
|
|
1530
|
+
let resolved = command;
|
|
1531
|
+
if (!command.includes("/") && !command.includes("\\")) {
|
|
1532
|
+
resolved = await deps.resolveOnPath(command, env) ?? command;
|
|
1533
|
+
}
|
|
1534
|
+
if (!/\.(cmd|bat)$/i.test(path.win32.basename(resolved))) {
|
|
1535
|
+
return { command: resolved, args: [...args] };
|
|
1536
|
+
}
|
|
1537
|
+
if (!isFullyQualifiedLocalPath(resolved)) {
|
|
1538
|
+
throw new UnrecognizedBatchShimError(command);
|
|
1539
|
+
}
|
|
1540
|
+
let content;
|
|
1541
|
+
try {
|
|
1542
|
+
content = deps.readFileSync(resolved);
|
|
1543
|
+
} catch {
|
|
1544
|
+
throw new UnrecognizedBatchShimError(command);
|
|
1545
|
+
}
|
|
1546
|
+
const recipe = await recognizeShim(resolved, content, env, deps);
|
|
1547
|
+
if (recipe === null) throw new UnrecognizedBatchShimError(command);
|
|
1548
|
+
return {
|
|
1549
|
+
command: recipe.command,
|
|
1550
|
+
args: [...recipe.args, ...args],
|
|
1551
|
+
...recipe.env ? { env: recipe.env } : {}
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/cli/child.ts
|
|
1556
|
+
var DRAIN_GRACE_MS = 500;
|
|
1557
|
+
function spawnHidden(command, args = [], options = {}) {
|
|
1558
|
+
return spawn(command, args, { ...options, windowsHide: true });
|
|
1559
|
+
}
|
|
1560
|
+
var ChildTimeoutError = class extends Error {
|
|
1561
|
+
timedOut = true;
|
|
1562
|
+
constructor(timeoutMs) {
|
|
1563
|
+
super(`child process timed out after ${timeoutMs}ms`);
|
|
1564
|
+
this.name = "ChildTimeoutError";
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
async function runChild(command, args = [], options = {}) {
|
|
1568
|
+
const { timeoutMs, signal, ...spawnOptions } = options;
|
|
1569
|
+
const cwd = typeof spawnOptions.cwd === "string" ? spawnOptions.cwd : void 0;
|
|
1570
|
+
const plan2 = await resolveSpawnPlan(command, args, spawnOptions.env ?? process.env, cwd);
|
|
1571
|
+
return new Promise((resolve8, reject) => {
|
|
1572
|
+
const child = spawnHidden(plan2.command, plan2.args, {
|
|
1573
|
+
...spawnOptions,
|
|
1574
|
+
env: plan2.env ?? spawnOptions.env,
|
|
1575
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1576
|
+
});
|
|
1577
|
+
let stdout = "";
|
|
1578
|
+
let stderr = "";
|
|
1579
|
+
let settled = false;
|
|
1580
|
+
let drainTimer;
|
|
1581
|
+
let timeoutTimer;
|
|
1582
|
+
let exitCode = null;
|
|
1583
|
+
let exited = false;
|
|
1584
|
+
let timedOut = false;
|
|
1585
|
+
const settle = (code) => {
|
|
1586
|
+
if (settled) return;
|
|
1587
|
+
settled = true;
|
|
1588
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
1589
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
1590
|
+
child.stdout?.destroy();
|
|
1591
|
+
child.stderr?.destroy();
|
|
1592
|
+
child.unref();
|
|
1593
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1594
|
+
if (timedOut) {
|
|
1595
|
+
reject(new ChildTimeoutError(timeoutMs ?? 0));
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
resolve8({ code: code ?? 0, stdout, stderr });
|
|
1599
|
+
};
|
|
1600
|
+
const restartDrain = () => {
|
|
1601
|
+
if (!exited || settled) return;
|
|
1602
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
1603
|
+
drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
|
|
1604
|
+
};
|
|
1605
|
+
child.stdout?.on("data", (chunk) => {
|
|
1606
|
+
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1607
|
+
restartDrain();
|
|
1608
|
+
});
|
|
1609
|
+
child.stderr?.on("data", (chunk) => {
|
|
1610
|
+
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1611
|
+
restartDrain();
|
|
1612
|
+
});
|
|
1613
|
+
child.on("error", (error) => {
|
|
1614
|
+
if (settled) return;
|
|
1615
|
+
settled = true;
|
|
1616
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
1617
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
1618
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1619
|
+
reject(error);
|
|
1620
|
+
});
|
|
1621
|
+
child.on("exit", (code) => {
|
|
1622
|
+
exitCode = code;
|
|
1623
|
+
exited = true;
|
|
1624
|
+
restartDrain();
|
|
1625
|
+
});
|
|
1626
|
+
child.on("close", (code) => settle(code));
|
|
1627
|
+
if (timeoutMs !== void 0) {
|
|
1628
|
+
timeoutTimer = setTimeout(() => {
|
|
1629
|
+
timedOut = true;
|
|
1630
|
+
child.kill("SIGTERM");
|
|
1631
|
+
settle(null);
|
|
1632
|
+
}, timeoutMs);
|
|
1633
|
+
}
|
|
1634
|
+
const onAbort = () => child.kill();
|
|
1635
|
+
if (signal) {
|
|
1636
|
+
if (signal.aborted) onAbort();
|
|
1637
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// src/cli/workspace.ts
|
|
1643
|
+
var WORKSPACE_DIRNAME = ".pptwise";
|
|
1644
|
+
var LEGACY_WORKSPACE_DIRNAMES = [".pptpress", ".pptfast"];
|
|
1645
|
+
var WORKSPACE_IGNORE_ENTRY = `${WORKSPACE_DIRNAME}/`;
|
|
1646
|
+
function defaultWorkspaceDirname(anchor) {
|
|
1647
|
+
if (existsSync2(join4(anchor, WORKSPACE_DIRNAME))) return WORKSPACE_DIRNAME;
|
|
1648
|
+
for (const name of LEGACY_WORKSPACE_DIRNAMES) {
|
|
1649
|
+
if (existsSync2(join4(anchor, name))) return name;
|
|
1650
|
+
}
|
|
1651
|
+
return WORKSPACE_DIRNAME;
|
|
1652
|
+
}
|
|
1653
|
+
function ignoreEntryFor(dirname5) {
|
|
1654
|
+
return `${dirname5}/`;
|
|
1655
|
+
}
|
|
1656
|
+
var RENDERED_SVG_PATTERN = /^\d{3}-[a-z-]+\.svg$/;
|
|
1657
|
+
function deckSlug(target, isDir) {
|
|
1658
|
+
const base = basename3(target);
|
|
1659
|
+
const name = isDir ? base : base.slice(0, base.length - extname3(base).length);
|
|
1660
|
+
const slug = slugify(name, "deck");
|
|
1661
|
+
assertSafeFileSegment(slug, "deck slug");
|
|
1662
|
+
return slug;
|
|
1663
|
+
}
|
|
1664
|
+
function resolveWorkspaceRoot(opts) {
|
|
1665
|
+
const anchor = opts.projectConfigPath ? dirname2(resolve5(opts.projectConfigPath)) : resolve5(opts.cwd);
|
|
1666
|
+
const configured = opts.outDir !== void 0;
|
|
1667
|
+
const root = opts.outDir !== void 0 ? resolve5(anchor, opts.outDir) : join4(anchor, defaultWorkspaceDirname(anchor));
|
|
1668
|
+
return { anchor, root, configured };
|
|
1669
|
+
}
|
|
1670
|
+
function resolveWorkspaceLocation(opts) {
|
|
1671
|
+
const { anchor, root, configured } = resolveWorkspaceRoot(opts);
|
|
1672
|
+
const slug = deckSlug(opts.target, opts.isDir);
|
|
1673
|
+
return { anchor, root, dir: join4(root, slug), slug, configured };
|
|
1674
|
+
}
|
|
1675
|
+
var runGitDefault = async (args, cwd) => {
|
|
1676
|
+
try {
|
|
1677
|
+
const { code, stdout } = await runChild("git", args, { cwd });
|
|
1678
|
+
return { code, stdout };
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
const code = error.code;
|
|
1681
|
+
if (typeof code !== "number") return null;
|
|
1682
|
+
throw error;
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
async function gitIgnoreStatus(dir, entry, runGit = runGitDefault) {
|
|
1686
|
+
const check = await runGit(["check-ignore", "-q", "--", entry], dir);
|
|
1687
|
+
if (check === null) return "not-a-repo";
|
|
1688
|
+
if (check.code === 0) return "ignored";
|
|
1689
|
+
if (check.code === 1) return "not-ignored";
|
|
1690
|
+
return "not-a-repo";
|
|
1691
|
+
}
|
|
1692
|
+
async function inspectWorkspace(opts) {
|
|
1693
|
+
const { anchor, root, configured } = resolveWorkspaceRoot(opts);
|
|
1694
|
+
if (configured) return { anchor, root, configured, ignore: "skipped" };
|
|
1695
|
+
const ignore = await gitIgnoreStatus(anchor, ignoreEntryFor(basename3(root)), opts.runGit);
|
|
1696
|
+
return { anchor, root, configured, ignore };
|
|
1697
|
+
}
|
|
1698
|
+
async function ensureGitIgnored(dir, entry, runGit = runGitDefault) {
|
|
1699
|
+
const status = await gitIgnoreStatus(dir, entry, runGit);
|
|
1700
|
+
if (status === "ignored") return { kind: "already-ignored" };
|
|
1701
|
+
if (status === "not-a-repo") return { kind: "no-repo" };
|
|
1702
|
+
const common = await runGit(["rev-parse", "--git-common-dir"], dir);
|
|
1703
|
+
if (common === null || common.code !== 0 || common.stdout.trim() === "") return { kind: "no-repo" };
|
|
1704
|
+
const excludePath = join4(resolve5(dir, common.stdout.trim()), "info", "exclude");
|
|
1705
|
+
try {
|
|
1706
|
+
await mkdir3(dirname2(excludePath), { recursive: true });
|
|
1707
|
+
let existing = "";
|
|
1708
|
+
try {
|
|
1709
|
+
existing = await readFile5(excludePath, "utf8");
|
|
1710
|
+
} catch {
|
|
1711
|
+
existing = "";
|
|
1712
|
+
}
|
|
1713
|
+
const lead = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
1714
|
+
await appendFile(excludePath, `${lead}${entry}
|
|
1715
|
+
`);
|
|
1716
|
+
return { kind: "appended", path: excludePath };
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
return { kind: "failed", path: excludePath, reason: e.message };
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async function exists(path2) {
|
|
1722
|
+
try {
|
|
1723
|
+
await stat2(path2);
|
|
1724
|
+
return true;
|
|
1725
|
+
} catch {
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
async function prepareWorkspaceDir(location, opts = {}) {
|
|
1730
|
+
const rootExisted = await exists(location.root);
|
|
1731
|
+
try {
|
|
1732
|
+
await mkdir3(location.dir, { recursive: true });
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
throw new PptwiseError(
|
|
1735
|
+
`cannot create the output directory ${location.dir}: ${e.message}
|
|
1736
|
+
pass -o <path> to write somewhere writable, set "outDir" in pptwise.config.json, or run from a writable workspace`
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
if (rootExisted || location.configured || opts.gitIgnore === false) return [];
|
|
1740
|
+
const ignoreEntry = ignoreEntryFor(basename3(location.root));
|
|
1741
|
+
const outcome = await ensureGitIgnored(location.anchor, ignoreEntry, opts.runGit);
|
|
1742
|
+
if (outcome.kind === "appended") {
|
|
1743
|
+
return [
|
|
1744
|
+
`note: added ${ignoreEntry} to ${outcome.path} \u2014 a local ignore, your shared .gitignore is untouched`
|
|
1745
|
+
];
|
|
1746
|
+
}
|
|
1747
|
+
if (outcome.kind === "failed") {
|
|
1748
|
+
return [
|
|
1749
|
+
`note: could not write ${outcome.path} (${outcome.reason}) \u2014 add ${ignoreEntry} to your ignore rules yourself`
|
|
1750
|
+
];
|
|
1751
|
+
}
|
|
1752
|
+
return [];
|
|
1753
|
+
}
|
|
1754
|
+
async function pruneRenderedSvgs(dir) {
|
|
1755
|
+
let entries;
|
|
1756
|
+
try {
|
|
1757
|
+
entries = await readdir2(dir);
|
|
1758
|
+
} catch {
|
|
1759
|
+
return 0;
|
|
1760
|
+
}
|
|
1761
|
+
const stale = entries.filter((name) => RENDERED_SVG_PATTERN.test(name));
|
|
1762
|
+
await Promise.all(stale.map((name) => unlink(join4(dir, name))));
|
|
1763
|
+
return stale.length;
|
|
1764
|
+
}
|
|
1765
|
+
var WORKSPACE_IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
1766
|
+
async function scanWorkspaceAssets(assetsDir) {
|
|
1767
|
+
let names;
|
|
1768
|
+
try {
|
|
1769
|
+
names = (await readdir2(assetsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
1770
|
+
} catch (e) {
|
|
1771
|
+
if (e.code === "ENOENT") return {};
|
|
1772
|
+
throw new PptwiseError(`cannot read workspace assets directory ${assetsDir}: ${e.message}`);
|
|
1773
|
+
}
|
|
1774
|
+
const images2 = {};
|
|
1775
|
+
const sourceFile = /* @__PURE__ */ new Map();
|
|
1776
|
+
for (const name of names) {
|
|
1777
|
+
const ext = extname3(name).toLowerCase();
|
|
1778
|
+
if (ext === ".json" || !WORKSPACE_IMAGE_EXTS.has(ext)) continue;
|
|
1779
|
+
const id = basename3(name, extname3(name));
|
|
1780
|
+
const previous = sourceFile.get(id);
|
|
1781
|
+
if (previous !== void 0) {
|
|
1782
|
+
throw new PptwiseError(
|
|
1783
|
+
`workspace ${ASSETS_DIRNAME}/${previous} and ${ASSETS_DIRNAME}/${name} both register image id "${id}" \u2014 rename one of the files`
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
sourceFile.set(id, name);
|
|
1787
|
+
images2[id] = { src: join4(assetsDir, name) };
|
|
1788
|
+
}
|
|
1789
|
+
return images2;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/cli/commands.ts
|
|
1793
|
+
async function loadStyleFile(path2) {
|
|
1794
|
+
const raw = await loadIrFile(path2);
|
|
1795
|
+
const r = StyleOverrideSchema.safeParse(raw);
|
|
1796
|
+
if (!r.success) {
|
|
1797
|
+
const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
1798
|
+
throw new PptwiseError(`invalid style file ${path2}:
|
|
1799
|
+
${detail}`);
|
|
1800
|
+
}
|
|
1801
|
+
return r.data;
|
|
1802
|
+
}
|
|
1803
|
+
async function loadThemeFile(path2) {
|
|
1804
|
+
const raw = await loadIrFile(path2, "theme");
|
|
1805
|
+
return registerBrandThemeFile(parseBrandThemeFile(raw, path2));
|
|
1806
|
+
}
|
|
1807
|
+
async function registerDeckThemeFile(deckDir) {
|
|
1808
|
+
const themePath = join5(deckDir, THEME_FILENAME);
|
|
1809
|
+
if (await pathExists(themePath)) await loadThemeFile(themePath);
|
|
1810
|
+
}
|
|
1811
|
+
function describeThemeSource(opts, projectHit, userHit) {
|
|
1812
|
+
if (opts.theme !== void 0) return "--theme";
|
|
1813
|
+
if (projectHit?.config.theme !== void 0) return projectHit.path;
|
|
1814
|
+
if (userHit?.config.theme !== void 0) return userHit.path;
|
|
1815
|
+
return "the deck's own theme";
|
|
1816
|
+
}
|
|
1817
|
+
function resolveDecksDirSource(projectHit, userHit) {
|
|
1818
|
+
if (projectHit?.config.decksDir !== void 0) {
|
|
1819
|
+
return { decksDir: resolve6(dirname3(projectHit.path), projectHit.config.decksDir) };
|
|
1820
|
+
}
|
|
1821
|
+
return userHit?.config;
|
|
1822
|
+
}
|
|
1823
|
+
async function applyDeckConfig(raw, opts) {
|
|
1824
|
+
if (typeof raw !== "object" || raw === null) return;
|
|
1825
|
+
const deck = raw;
|
|
1826
|
+
const irTheme = typeof deck.theme === "object" && deck.theme !== null ? deck.theme : {};
|
|
1827
|
+
const themeFileId = opts.themeFilePath !== void 0 ? await loadThemeFile(opts.themeFilePath) : void 0;
|
|
1828
|
+
const [projectHit, userHit] = await Promise.all([
|
|
1829
|
+
opts.projectHit !== void 0 ? Promise.resolve(opts.projectHit) : findConfig(opts.cwd),
|
|
1830
|
+
opts.userHit !== void 0 ? Promise.resolve(opts.userHit) : findUserConfig()
|
|
1831
|
+
]);
|
|
1832
|
+
const theme = opts.theme ?? themeFileId ?? projectHit?.config.theme ?? userHit?.config.theme ?? irTheme.id;
|
|
1833
|
+
const style = opts.stylePath ? await loadStyleFile(opts.stylePath) : projectHit?.config.style ?? userHit?.config.style ?? irTheme.style;
|
|
1834
|
+
if (theme !== void 0) {
|
|
1835
|
+
const installedThemeIds = getInstalledThemeIds();
|
|
1836
|
+
if (!installedThemeIds.includes(theme)) {
|
|
1837
|
+
throw new PptwiseError(
|
|
1838
|
+
`unknown theme "${theme}" (from ${describeThemeSource(opts, projectHit, userHit)}) \u2014 available: ${installedThemeIds.join(", ")} (see \`pptwise themes\`)`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (theme === void 0 && style === void 0) return;
|
|
1843
|
+
deck.theme = { ...irTheme, id: theme, ...style !== void 0 ? { style } : {} };
|
|
1844
|
+
}
|
|
1845
|
+
function mergeWorkspaceImages(raw, extras) {
|
|
1846
|
+
if (typeof raw !== "object" || raw === null) return raw;
|
|
1847
|
+
const deck = raw;
|
|
1848
|
+
const existing = deck.assets?.images ?? {};
|
|
1849
|
+
return { ...deck, assets: { images: { ...extras, ...existing } } };
|
|
1850
|
+
}
|
|
1851
|
+
async function loadWorkspaceStock(cwd, projectHit, resolvedTarget, isDir) {
|
|
1852
|
+
const location = resolveWorkspaceLocation({
|
|
1853
|
+
cwd,
|
|
1854
|
+
projectConfigPath: projectHit?.path,
|
|
1855
|
+
outDir: projectHit?.config.outDir,
|
|
1856
|
+
target: resolvedTarget,
|
|
1857
|
+
isDir
|
|
1858
|
+
});
|
|
1859
|
+
const workspaceAssetsDir = join5(location.dir, ASSETS_DIRNAME);
|
|
1860
|
+
const images2 = await scanWorkspaceAssets(workspaceAssetsDir);
|
|
1861
|
+
return { workspaceAssetsDir, images: images2 };
|
|
1862
|
+
}
|
|
1863
|
+
async function loadDeckTarget(arg, cwd, projectHit, userHit) {
|
|
1864
|
+
const target = await resolveDeckTarget(arg, resolveDecksDirSource(projectHit, userHit), cwd);
|
|
1865
|
+
if (await isDeckDirectory(target)) {
|
|
1866
|
+
await registerDeckThemeFile(target);
|
|
1867
|
+
const { ir, deckDir } = await readDeckDir(target);
|
|
1868
|
+
const stock2 = await loadWorkspaceStock(cwd, projectHit, deckDir, true);
|
|
1869
|
+
return {
|
|
1870
|
+
raw: mergeWorkspaceImages(ir, stock2.images),
|
|
1871
|
+
baseDir: deckDir,
|
|
1872
|
+
isDir: true,
|
|
1873
|
+
resolvedTarget: deckDir,
|
|
1874
|
+
workspaceAssetsDir: stock2.workspaceAssetsDir
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
const raw = await loadIrFile(target);
|
|
1878
|
+
const resolvedFile = resolve6(target);
|
|
1879
|
+
const stock = await loadWorkspaceStock(cwd, projectHit, resolvedFile, false);
|
|
1880
|
+
return {
|
|
1881
|
+
raw: mergeWorkspaceImages(raw, stock.images),
|
|
1882
|
+
baseDir: dirname3(resolvedFile),
|
|
1883
|
+
isDir: false,
|
|
1884
|
+
resolvedTarget: resolvedFile,
|
|
1885
|
+
workspaceAssetsDir: stock.workspaceAssetsDir
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
async function loadValidatedDeckIr(target, cwd) {
|
|
1889
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
1890
|
+
const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
|
|
1891
|
+
await applyDeckConfig(raw, { cwd, projectHit, userHit });
|
|
1892
|
+
const v = validateIr(raw);
|
|
1893
|
+
if (!v.ok) {
|
|
1894
|
+
throw new PptwiseError(
|
|
1895
|
+
`invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
|
|
1896
|
+
${formatIssues(v.errors)}`
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
1900
|
+
return v.ir;
|
|
1901
|
+
}
|
|
1902
|
+
async function runRender(irPath, opts) {
|
|
1903
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1904
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
1905
|
+
const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
|
|
1906
|
+
await applyDeckConfig(raw, {
|
|
1907
|
+
theme: opts.theme,
|
|
1908
|
+
themeFilePath: opts.themeFilePath,
|
|
1909
|
+
stylePath: opts.stylePath,
|
|
1910
|
+
cwd,
|
|
1911
|
+
projectHit,
|
|
1912
|
+
userHit
|
|
1913
|
+
});
|
|
1914
|
+
const v = validateIr(raw);
|
|
1915
|
+
if (!v.ok) throw new PptwiseError(`invalid IR:
|
|
1916
|
+
${formatIssues(v.errors)}`);
|
|
1917
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
1918
|
+
const bytes = await generatePptx(v.ir, {
|
|
1919
|
+
draft: opts.draft,
|
|
1920
|
+
allowDroppedContent: opts.allowDroppedContent
|
|
1921
|
+
});
|
|
1922
|
+
const extraNotes = [];
|
|
1923
|
+
let output;
|
|
1924
|
+
if (opts.output !== void 0) {
|
|
1925
|
+
output = resolve6(cwd, opts.output);
|
|
1926
|
+
await mkdir4(dirname3(output), { recursive: true });
|
|
1927
|
+
} else {
|
|
1928
|
+
const location = resolveWorkspaceLocation({
|
|
1929
|
+
cwd,
|
|
1930
|
+
projectConfigPath: projectHit?.path,
|
|
1931
|
+
outDir: projectHit?.config.outDir,
|
|
1932
|
+
target: resolvedTarget,
|
|
1933
|
+
isDir
|
|
1934
|
+
});
|
|
1935
|
+
extraNotes.push(...await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit }));
|
|
1936
|
+
output = join5(location.dir, `${location.slug}.pptx`);
|
|
1937
|
+
}
|
|
1938
|
+
await writeFile3(output, bytes);
|
|
1939
|
+
const ok = `wrote ${output} (${v.ir.slides.length} slides, ${bytes.length} bytes)`;
|
|
1940
|
+
const notes = [...extraNotes, warningsNote(v.warnings), normalizedNote(v.normalized)].filter(
|
|
1941
|
+
(n) => n !== void 0
|
|
1942
|
+
);
|
|
1943
|
+
return notes.length > 0 ? `${ok}
|
|
1944
|
+
${notes.join("\n")}` : ok;
|
|
1945
|
+
}
|
|
1946
|
+
function normalizedNote(normalized) {
|
|
1947
|
+
if (!normalized || normalized.length === 0) return void 0;
|
|
1948
|
+
const n = normalized.length;
|
|
1949
|
+
return `note: ${n} field alias${n === 1 ? "" : "es"} normalized
|
|
1950
|
+
${normalized.map((line) => ` ${line}`).join("\n")}`;
|
|
1951
|
+
}
|
|
1952
|
+
function warningsNote(warnings) {
|
|
1953
|
+
if (!warnings || warnings.length === 0) return void 0;
|
|
1954
|
+
return formatWarnings(warnings);
|
|
1955
|
+
}
|
|
1956
|
+
function placeholderNote(ir) {
|
|
1957
|
+
const placeholders = ir.slides.map((slide, i) => ({ slide, page: i + 1 })).filter(({ slide }) => slide.placeholder);
|
|
1958
|
+
if (placeholders.length === 0) return void 0;
|
|
1959
|
+
const refs = placeholders.map(({ slide, page }) => slide.id ? `${slide.id} (page ${page})` : `page ${page}`).join(", ");
|
|
1960
|
+
return `note: ${placeholders.length} unfilled placeholder page${placeholders.length === 1 ? "" : "s"}: ${refs}`;
|
|
1961
|
+
}
|
|
1962
|
+
async function runValidate(irPath, cwd = process.cwd(), opts = {}) {
|
|
1963
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
1964
|
+
const { raw, baseDir, isDir, workspaceAssetsDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
|
|
1965
|
+
await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
|
|
1966
|
+
const v = validateIr(raw);
|
|
1967
|
+
if (!v.ok)
|
|
1968
|
+
throw new PptwiseError(
|
|
1969
|
+
`invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
|
|
1970
|
+
${formatIssues(v.errors)}`
|
|
1971
|
+
);
|
|
1972
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
1973
|
+
const ok = `OK \u2014 ${v.ir.slides.length} slides, theme "${v.ir.theme.id}"`;
|
|
1974
|
+
const notes = [];
|
|
1975
|
+
const warnNote = warningsNote(v.warnings);
|
|
1976
|
+
if (warnNote) notes.push(warnNote);
|
|
1977
|
+
const aliasNote = normalizedNote(v.normalized);
|
|
1978
|
+
if (aliasNote) notes.push(aliasNote);
|
|
1979
|
+
if (isDir) {
|
|
1980
|
+
const note = placeholderNote(v.ir);
|
|
1981
|
+
if (note) notes.push(note);
|
|
1982
|
+
}
|
|
1983
|
+
return notes.length > 0 ? `${ok}
|
|
1984
|
+
${notes.join("\n")}` : ok;
|
|
1985
|
+
}
|
|
1986
|
+
function formatAuditFinding(f) {
|
|
1987
|
+
const idSuffix = f.slideId !== void 0 ? ` (${f.slideId})` : "";
|
|
1988
|
+
return `page ${f.page}${idSuffix}: [${f.code}] ${f.message}`;
|
|
1989
|
+
}
|
|
1990
|
+
function formatAuditReport(report, ir) {
|
|
1991
|
+
const lines = report.findings.map(formatAuditFinding);
|
|
1992
|
+
lines.push(
|
|
1993
|
+
`audited ${report.pagesAudited} page${report.pagesAudited === 1 ? "" : "s"}, ${report.pagesSkipped} skipped, ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}`
|
|
1994
|
+
);
|
|
1995
|
+
if (report.checks.pixels === "completed") {
|
|
1996
|
+
lines.push("pixel-contrast check: completed");
|
|
1997
|
+
}
|
|
1998
|
+
const note = placeholderNote(ir);
|
|
1999
|
+
if (note) lines.push(note);
|
|
2000
|
+
return lines.join("\n");
|
|
2001
|
+
}
|
|
2002
|
+
async function runAudit(target, opts = {}) {
|
|
2003
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2004
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
2005
|
+
const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
|
|
2006
|
+
await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
|
|
2007
|
+
const v = validateIr(raw);
|
|
2008
|
+
if (!v.ok) {
|
|
2009
|
+
throw new PptwiseError(
|
|
2010
|
+
`invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
|
|
2011
|
+
${formatIssues(v.errors)}`
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
2015
|
+
const report = opts.pixels ? await auditDeck(v.ir, { pixels: true }) : auditDeck(v.ir);
|
|
2016
|
+
const hasFindings = report.findings.length > 0;
|
|
2017
|
+
const output = opts.json ? JSON.stringify(report, null, 2) : formatAuditReport(report, v.ir);
|
|
2018
|
+
return { output, hasFindings };
|
|
2019
|
+
}
|
|
2020
|
+
function formatAssetBriefItem(item) {
|
|
2021
|
+
const idSuffix = item.page.id !== void 0 ? `, ${item.page.id}` : "";
|
|
2022
|
+
const sharedSuffix = item.shared ? ` (shared by ${item.occurrenceCount} image slots, frame not attributable to one)` : "";
|
|
2023
|
+
const header = `page ${item.page.index + 1} (${item.page.type}${idSuffix}) \u2014 ${item.asset_id}${item.missing ? " (missing)" : ""}${item.rendered ? "" : " (not rendered under the selected layout)"}${sharedSuffix}`;
|
|
2024
|
+
const lines = [header];
|
|
2025
|
+
if (item.frame && item.suggested_pixels) {
|
|
2026
|
+
lines.push(
|
|
2027
|
+
` frame: ${item.frame.w}x${item.frame.h} @ (${item.frame.x},${item.frame.y}), aspect ${item.frame.aspect}, ${item.fit.mode}`
|
|
2028
|
+
);
|
|
2029
|
+
lines.push(` suggested pixels: ${item.suggested_pixels.w}x${item.suggested_pixels.h}`);
|
|
2030
|
+
}
|
|
2031
|
+
lines.push(` fit: ${item.fit.note}`);
|
|
2032
|
+
lines.push(` palette: primary ${item.palette.primary}, accent ${item.palette.accent} (${item.palette.hexes.join(", ")})`);
|
|
2033
|
+
lines.push(` mood: ${item.mood.description}`);
|
|
2034
|
+
lines.push(` prompt: ${item.suggested_prompt}`);
|
|
2035
|
+
return lines.join("\n");
|
|
2036
|
+
}
|
|
2037
|
+
function formatAssetBriefReport(brief) {
|
|
2038
|
+
if (brief.items.length === 0) return `no image components found for theme "${brief.theme}"`;
|
|
2039
|
+
const missingCount = brief.items.filter((i) => i.missing).length;
|
|
2040
|
+
const notRenderedCount = brief.items.filter((i) => !i.rendered).length;
|
|
2041
|
+
const lines = brief.items.map(formatAssetBriefItem);
|
|
2042
|
+
lines.push(
|
|
2043
|
+
`${brief.items.length} image slot${brief.items.length === 1 ? "" : "s"}, ${missingCount} to generate, ${notRenderedCount} not rendered under their selected layout`
|
|
2044
|
+
);
|
|
2045
|
+
return lines.join("\n\n");
|
|
2046
|
+
}
|
|
2047
|
+
async function runAssetBrief(target, opts = {}) {
|
|
2048
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2049
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
2050
|
+
const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
|
|
2051
|
+
await applyDeckConfig(raw, { cwd, projectHit, userHit });
|
|
2052
|
+
const v = validateIr(raw);
|
|
2053
|
+
if (!v.ok) {
|
|
2054
|
+
throw new PptwiseError(
|
|
2055
|
+
`invalid IR (${v.errors.length} issue${v.errors.length === 1 ? "" : "s"}):
|
|
2056
|
+
${formatIssues(v.errors)}`
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
2060
|
+
const brief = buildAssetBrief(v.ir);
|
|
2061
|
+
return opts.json ? JSON.stringify(brief, null, 2) : formatAssetBriefReport(brief);
|
|
2062
|
+
}
|
|
2063
|
+
async function runSpecValidate(specPath) {
|
|
2064
|
+
const raw = await loadIrFile(specPath, "spec");
|
|
2065
|
+
await registerDeckThemeFile(dirname3(resolve6(specPath)));
|
|
2066
|
+
const v = validateSpec(raw);
|
|
2067
|
+
if (!v.ok) {
|
|
2068
|
+
throw new PptwiseError(formatInvalidSpecError(v.errors));
|
|
2069
|
+
}
|
|
2070
|
+
const spec2 = v.spec;
|
|
2071
|
+
const axes = resolveNarrative(spec2.narrative);
|
|
2072
|
+
const ok = `OK \u2014 ${spec2.pages.length} pages, narrative ${axes.strategy}/${axes.pacing}/${axes.audience}, theme "${resolveSpecThemeId(spec2)}"`;
|
|
2073
|
+
const aliasNote = normalizedNote(v.normalized);
|
|
2074
|
+
return aliasNote ? `${ok}
|
|
2075
|
+
${aliasNote}` : ok;
|
|
2076
|
+
}
|
|
2077
|
+
function runSchema(mode) {
|
|
2078
|
+
const schema = mode === "style" ? styleJsonSchema() : mode === "spec" ? specJsonSchema() : irJsonSchema();
|
|
2079
|
+
return JSON.stringify(schema, null, 2);
|
|
2080
|
+
}
|
|
2081
|
+
function runThemes(asJson) {
|
|
2082
|
+
const themes = listThemes();
|
|
2083
|
+
if (asJson) return JSON.stringify(themes, null, 2);
|
|
2084
|
+
return themes.map((t) => `${t.id.padEnd(12)} ${t.label}`).join("\n");
|
|
2085
|
+
}
|
|
2086
|
+
function defaultThemeIdFor(output) {
|
|
2087
|
+
return slugify(basename4(output).replace(/\.theme\.json$|\.json$/i, ""));
|
|
2088
|
+
}
|
|
2089
|
+
async function runBrandExtract(file, opts) {
|
|
2090
|
+
let bytes;
|
|
2091
|
+
try {
|
|
2092
|
+
bytes = await readFile6(file);
|
|
2093
|
+
} catch {
|
|
2094
|
+
throw new PptwiseError(`cannot read template file: ${file}`);
|
|
2095
|
+
}
|
|
2096
|
+
const id = opts.id ?? defaultThemeIdFor(opts.output);
|
|
2097
|
+
if (CANONICAL_THEME_IDS.includes(id)) {
|
|
2098
|
+
throw new PptwiseError(
|
|
2099
|
+
`theme id "${id}" collides with a built-in pptwise theme \u2014 pick a different id with --id (or a different output filename)`
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
const theme = await extractBrandTheme(bytes, { id, label: opts.label });
|
|
2103
|
+
const outPath = resolve6(opts.output);
|
|
2104
|
+
await mkdir4(dirname3(outPath), { recursive: true });
|
|
2105
|
+
await writeFile3(outPath, JSON.stringify(theme, null, 2) + "\n");
|
|
2106
|
+
const c = theme.style.colors;
|
|
2107
|
+
const lines = [
|
|
2108
|
+
`wrote ${opts.output} (theme "${theme.id}", label "${theme.label}")`,
|
|
2109
|
+
` colors: bg ${c.bg}, text ${c.text}, primary ${c.primary}, accent ${c.accent}, muted ${c.muted} (derived), ${c.chartPalette.length} chart colors`,
|
|
2110
|
+
` fonts: heading "${theme.style.fonts.heading[0]}", body "${theme.style.fonts.body[0]}"`,
|
|
2111
|
+
`use it: pptwise render <deck> --theme-file ${opts.output} \u2014 or drop it into a deck project directory as ${THEME_FILENAME} and reference "${theme.id}" as the deck's theme`
|
|
2112
|
+
];
|
|
2113
|
+
try {
|
|
2114
|
+
assertContrastFloor(theme.id, theme.style);
|
|
2115
|
+
} catch (e) {
|
|
2116
|
+
lines.push(
|
|
2117
|
+
`warning: this theme will be refused at load time \u2014 ${e instanceof Error ? e.message : String(e)}. Edit the written file's colors (darker text, or a lighter bg) before using it`
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
return lines.join("\n");
|
|
2121
|
+
}
|
|
2122
|
+
function runNarratives(asJson) {
|
|
2123
|
+
if (asJson) {
|
|
2124
|
+
return JSON.stringify(
|
|
2125
|
+
{
|
|
2126
|
+
presets: NARRATIVE_PRESETS,
|
|
2127
|
+
strategies: STRATEGY_DEFINITIONS,
|
|
2128
|
+
pacings: PACING_BUDGETS,
|
|
2129
|
+
audiences: AUDIENCE_VALUES
|
|
2130
|
+
},
|
|
2131
|
+
null,
|
|
2132
|
+
2
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
const rows = Object.values(NARRATIVE_PRESETS).map((p) => ({
|
|
2136
|
+
id: p.id,
|
|
2137
|
+
axes: `${p.axes.strategy}/${p.axes.pacing}/${p.axes.audience}`,
|
|
2138
|
+
themes: p.themeRecommendations.join(", ")
|
|
2139
|
+
}));
|
|
2140
|
+
const idWidth = Math.max(...rows.map((r) => r.id.length));
|
|
2141
|
+
const axesWidth = Math.max(...rows.map((r) => r.axes.length));
|
|
2142
|
+
return rows.map((r) => `${r.id.padEnd(idWidth + 2)}${r.axes.padEnd(axesWidth + 2)}${r.themes}`).join("\n");
|
|
2143
|
+
}
|
|
2144
|
+
var CONFIG_TEMPLATE = {
|
|
2145
|
+
theme: "consulting",
|
|
2146
|
+
style: {
|
|
2147
|
+
colors: { primary: "#0B5FFF", accent: "#FF6A00" }
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
async function runInit(cwd = process.cwd()) {
|
|
2151
|
+
const target = join5(cwd, CONFIG_FILENAME);
|
|
2152
|
+
try {
|
|
2153
|
+
await writeFile3(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n", { flag: "wx" });
|
|
2154
|
+
} catch (e) {
|
|
2155
|
+
if (e.code === "EEXIST") {
|
|
2156
|
+
throw new PptwiseError(`${target} already exists \u2014 edit it instead`);
|
|
2157
|
+
}
|
|
2158
|
+
throw e;
|
|
2159
|
+
}
|
|
2160
|
+
return `wrote ${target} \u2014 themes: \`pptwise themes\`, style schema: \`pptwise schema --style\``;
|
|
2161
|
+
}
|
|
2162
|
+
async function renderDeckSlides(target, opts = {}) {
|
|
2163
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2164
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
2165
|
+
const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
|
|
2166
|
+
await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
|
|
2167
|
+
const v = validateIr(raw);
|
|
2168
|
+
if (!v.ok) throw new PptwiseError(`invalid IR:
|
|
2169
|
+
${formatIssues(v.errors)}`);
|
|
2170
|
+
await resolveLocalAssets(v.ir, baseDir, workspaceAssetsDir);
|
|
2171
|
+
const ir = v.ir;
|
|
2172
|
+
const svgs = ir.slides.map((_, i) => renderSlideSvg(ir, i));
|
|
2173
|
+
return { ir, svgs, resolvedTarget, isDir, normalized: v.normalized };
|
|
2174
|
+
}
|
|
2175
|
+
function buildDeckAuditAndHtml(ir, svgs) {
|
|
2176
|
+
const hasPlaceholder = ir.slides.some((slide) => slide.placeholder);
|
|
2177
|
+
const auditReport = hasPlaceholder ? void 0 : auditDeck(ir);
|
|
2178
|
+
const findings = auditReport?.findings ?? [];
|
|
2179
|
+
const html = buildPreviewHtml({
|
|
2180
|
+
title: ir.filename,
|
|
2181
|
+
slides: ir.slides.map((slide, i) => ({
|
|
2182
|
+
index: i,
|
|
2183
|
+
id: slide.id,
|
|
2184
|
+
type: slide.type,
|
|
2185
|
+
svg: svgs[i],
|
|
2186
|
+
placeholder: slide.placeholder
|
|
2187
|
+
})),
|
|
2188
|
+
findings: findings.map((f) => ({ page: f.page, slideId: f.slideId, code: f.code, message: f.message })),
|
|
2189
|
+
auditNote: hasPlaceholder ? "audit overlay skipped \u2014 deck has unfilled placeholder pages; fill every page and re-run `pptwise preview --html` to see audit findings" : void 0,
|
|
2190
|
+
checks: auditReport?.checks
|
|
2191
|
+
});
|
|
2192
|
+
return { html, findings, checks: auditReport?.checks };
|
|
2193
|
+
}
|
|
2194
|
+
async function buildDeckPreview(target, opts = {}) {
|
|
2195
|
+
const rendered = await renderDeckSlides(target, opts);
|
|
2196
|
+
const { html, findings, checks } = buildDeckAuditAndHtml(rendered.ir, rendered.svgs);
|
|
2197
|
+
return { ...rendered, html, findings, checks };
|
|
2198
|
+
}
|
|
2199
|
+
async function runPreview(irPath, outDir, opts = {}) {
|
|
2200
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2201
|
+
const { ir, svgs, normalized, isDir, resolvedTarget } = await renderDeckSlides(irPath, {
|
|
2202
|
+
cwd,
|
|
2203
|
+
themeFilePath: opts.themeFilePath
|
|
2204
|
+
});
|
|
2205
|
+
const extraNotes = [];
|
|
2206
|
+
let resolvedOut;
|
|
2207
|
+
if (outDir !== void 0) {
|
|
2208
|
+
resolvedOut = resolve6(cwd, outDir);
|
|
2209
|
+
await mkdir4(resolvedOut, { recursive: true });
|
|
2210
|
+
} else {
|
|
2211
|
+
const projectHit = await findConfig(cwd);
|
|
2212
|
+
const location = resolveWorkspaceLocation({
|
|
2213
|
+
cwd,
|
|
2214
|
+
projectConfigPath: projectHit?.path,
|
|
2215
|
+
outDir: projectHit?.config.outDir,
|
|
2216
|
+
target: resolvedTarget,
|
|
2217
|
+
isDir
|
|
2218
|
+
});
|
|
2219
|
+
extraNotes.push(...await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit }));
|
|
2220
|
+
await pruneRenderedSvgs(location.dir);
|
|
2221
|
+
resolvedOut = location.dir;
|
|
2222
|
+
}
|
|
2223
|
+
const svgNames = [];
|
|
2224
|
+
for (let i = 0; i < ir.slides.length; i++) {
|
|
2225
|
+
const name = `${String(i + 1).padStart(3, "0")}-${ir.slides[i].type}.svg`;
|
|
2226
|
+
svgNames.push(name);
|
|
2227
|
+
await writeFile3(join5(resolvedOut, name), svgs[i]);
|
|
2228
|
+
}
|
|
2229
|
+
const ok = `wrote ${ir.slides.length} SVG files to ${resolvedOut}`;
|
|
2230
|
+
const notes = [...extraNotes];
|
|
2231
|
+
const aliasNote = normalizedNote(normalized);
|
|
2232
|
+
if (aliasNote) notes.push(aliasNote);
|
|
2233
|
+
if (opts.htmlOut) {
|
|
2234
|
+
const { html, findings, checks } = buildDeckAuditAndHtml(ir, svgs);
|
|
2235
|
+
const htmlPath = join5(resolvedOut, "preview.html");
|
|
2236
|
+
await writeFile3(htmlPath, html);
|
|
2237
|
+
const hasPlaceholder = ir.slides.some((s) => s.placeholder);
|
|
2238
|
+
const manifest = buildPreviewManifest({
|
|
2239
|
+
title: ir.filename,
|
|
2240
|
+
pptwiseVersion: VERSION,
|
|
2241
|
+
width: CANVAS_W_PX,
|
|
2242
|
+
height: CANVAS_H_PX,
|
|
2243
|
+
slides: ir.slides.map((slide, i) => ({
|
|
2244
|
+
index: i,
|
|
2245
|
+
type: slide.type ?? "content",
|
|
2246
|
+
id: slide.id,
|
|
2247
|
+
placeholder: slide.placeholder,
|
|
2248
|
+
file: svgNames[i]
|
|
2249
|
+
})),
|
|
2250
|
+
findings: findings.map((f) => ({ page: f.page, code: f.code, message: f.message })),
|
|
2251
|
+
checks,
|
|
2252
|
+
auditNote: hasPlaceholder ? "audit skipped \u2014 deck has unfilled placeholder pages" : void 0
|
|
2253
|
+
});
|
|
2254
|
+
const manifestPath = join5(resolvedOut, "manifest.json");
|
|
2255
|
+
await writeFile3(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
2256
|
+
`);
|
|
2257
|
+
notes.push(`note: wrote self-contained preview to ${htmlPath}`);
|
|
2258
|
+
notes.push(`note: wrote machine-readable page manifest to ${manifestPath}`);
|
|
2259
|
+
if (findings.length > 0) {
|
|
2260
|
+
notes.push(`note: audit found ${findings.length} finding${findings.length === 1 ? "" : "s"} \u2014 see preview.html`);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return notes.length > 0 ? `${ok}
|
|
2264
|
+
${notes.join("\n")}` : ok;
|
|
2265
|
+
}
|
|
2266
|
+
function withRewrittenAssetPaths(ir, deckDir, outDir) {
|
|
2267
|
+
const images2 = Object.fromEntries(
|
|
2268
|
+
Object.entries(ir.assets.images).map(([id, asset]) => {
|
|
2269
|
+
if (asset.src.startsWith("data:") || /^https?:\/\//.test(asset.src)) return [id, asset];
|
|
2270
|
+
return [id, { ...asset, src: relative2(outDir, join5(deckDir, asset.src)) }];
|
|
2271
|
+
})
|
|
2272
|
+
);
|
|
2273
|
+
return { ...ir, assets: { images: images2 } };
|
|
2274
|
+
}
|
|
2275
|
+
async function runAssemble(target, opts = {}) {
|
|
2276
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2277
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
2278
|
+
const dir = await resolveDeckTarget(target, resolveDecksDirSource(projectHit, userHit), cwd);
|
|
2279
|
+
if (await pathExists(dir) && !await isDeckDirectory(dir)) {
|
|
2280
|
+
throw new PptwiseError(`expected a deck project directory: ${dir}`);
|
|
2281
|
+
}
|
|
2282
|
+
if (await isDeckDirectory(dir)) await registerDeckThemeFile(dir);
|
|
2283
|
+
const { ir, generatedSeed, materializedLayoutCount, deckDir } = await readDeckDir(dir);
|
|
2284
|
+
const outPath = opts.output ? resolve6(cwd, opts.output) : join5(deckDir, "deck.json");
|
|
2285
|
+
const outDir = dirname3(outPath);
|
|
2286
|
+
const outIr = outDir === deckDir ? ir : withRewrittenAssetPaths(ir, deckDir, outDir);
|
|
2287
|
+
await mkdir4(outDir, { recursive: true });
|
|
2288
|
+
await writeFile3(outPath, JSON.stringify(outIr, null, 2) + "\n");
|
|
2289
|
+
const placeholderCount = outIr.slides.filter((s) => s.placeholder).length;
|
|
2290
|
+
const summary = `wrote ${outPath} (${outIr.slides.length} slides, ${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"})`;
|
|
2291
|
+
const notes = [];
|
|
2292
|
+
if (generatedSeed !== void 0) {
|
|
2293
|
+
notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.spec.json for revision stability`);
|
|
2294
|
+
}
|
|
2295
|
+
if (materializedLayoutCount !== void 0) {
|
|
2296
|
+
notes.push(
|
|
2297
|
+
`note: ${materializedLayoutCount} layout${materializedLayoutCount === 1 ? "" : "s"} auto-selected into deck.json \u2014 pin "layout" in a page file to lock one`
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
return [summary, ...notes].join("\n");
|
|
2301
|
+
}
|
|
2302
|
+
async function runDisassemble(irPath, outDir) {
|
|
2303
|
+
const raw = await loadIrFile(irPath);
|
|
2304
|
+
const v = validateIr(raw);
|
|
2305
|
+
if (!v.ok) throw new PptwiseError(`invalid IR:
|
|
2306
|
+
${formatIssues(v.errors)}`);
|
|
2307
|
+
const { spec: spec2, pages } = disassembleDeck(v.ir);
|
|
2308
|
+
const ids = Object.keys(pages);
|
|
2309
|
+
for (const id of ids) assertSafeFileSegment(id, "slide id");
|
|
2310
|
+
const specPath = join5(outDir, "deck.spec.json");
|
|
2311
|
+
await mkdir4(outDir, { recursive: true });
|
|
2312
|
+
try {
|
|
2313
|
+
await writeFile3(specPath, JSON.stringify(spec2, null, 2) + "\n", { flag: "wx" });
|
|
2314
|
+
} catch (e) {
|
|
2315
|
+
if (e.code === "EEXIST") {
|
|
2316
|
+
throw new PptwiseError(`${specPath} already exists \u2014 refusing to overwrite an existing deck project`);
|
|
2317
|
+
}
|
|
2318
|
+
throw e;
|
|
2319
|
+
}
|
|
2320
|
+
const pagesDir = join5(outDir, "pages");
|
|
2321
|
+
try {
|
|
2322
|
+
if (ids.length > 0) {
|
|
2323
|
+
await mkdir4(pagesDir, { recursive: true });
|
|
2324
|
+
await Promise.all(
|
|
2325
|
+
ids.map((id) => {
|
|
2326
|
+
const content = pages[id];
|
|
2327
|
+
return writeFile3(join5(pagesDir, `${id}.json`), JSON.stringify(content, null, 2) + "\n");
|
|
2328
|
+
})
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
const { count: assetCount, assetsDir } = await writeDeckAssets(
|
|
2332
|
+
v.ir.assets.images,
|
|
2333
|
+
outDir,
|
|
2334
|
+
dirname3(resolve6(irPath))
|
|
2335
|
+
);
|
|
2336
|
+
const pagesNote = ids.length > 0 ? `${ids.length} page file${ids.length === 1 ? "" : "s"} to ${pagesDir}` : "no pages (every slide was a placeholder)";
|
|
2337
|
+
const assetsNote = assetCount > 0 ? `, and ${assetCount} asset file${assetCount === 1 ? "" : "s"} to ${assetsDir}` : "";
|
|
2338
|
+
return `wrote ${specPath}, ${pagesNote}${assetsNote}`;
|
|
2339
|
+
} catch (e) {
|
|
2340
|
+
await rm(specPath, { force: true }).catch(() => {
|
|
2341
|
+
});
|
|
2342
|
+
throw e;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
async function runMigrate(input, output, cwd = process.cwd()) {
|
|
2346
|
+
const resolvedInput = resolve6(cwd, input);
|
|
2347
|
+
if (await isDeckDirectory(resolvedInput)) {
|
|
2348
|
+
return runMigrateDeckDir(resolvedInput, output, cwd);
|
|
2349
|
+
}
|
|
2350
|
+
return runMigrateIrFile(resolvedInput, output, cwd);
|
|
2351
|
+
}
|
|
2352
|
+
function isPlainRecord(value) {
|
|
2353
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2354
|
+
}
|
|
2355
|
+
function needsChromeRewrite(raw) {
|
|
2356
|
+
return Object.hasOwn(raw, "chrome");
|
|
2357
|
+
}
|
|
2358
|
+
function needsBloomRewrite(raw) {
|
|
2359
|
+
const theme = raw.theme;
|
|
2360
|
+
if (theme === "bloom") return true;
|
|
2361
|
+
return isPlainRecord(theme) && theme.id === "bloom";
|
|
2362
|
+
}
|
|
2363
|
+
function componentListHasLogoWall(components) {
|
|
2364
|
+
return Array.isArray(components) && components.some((component) => isPlainRecord(component) && component.type === "logo_wall");
|
|
2365
|
+
}
|
|
2366
|
+
function needsLogoWallRewrite(raw) {
|
|
2367
|
+
if (componentListHasLogoWall(raw.components)) return true;
|
|
2368
|
+
if (!Array.isArray(raw.slides)) return false;
|
|
2369
|
+
return raw.slides.some((slide) => isPlainRecord(slide) && componentListHasLogoWall(slide.components));
|
|
2370
|
+
}
|
|
2371
|
+
function recordHasBannerHeadingPin(obj) {
|
|
2372
|
+
return obj.layout === "banner-heading" || obj.focus === "banner-heading";
|
|
2373
|
+
}
|
|
2374
|
+
function needsBannerHeadingRewrite(raw) {
|
|
2375
|
+
if (recordHasBannerHeadingPin(raw)) return true;
|
|
2376
|
+
if (Array.isArray(raw.slides) && raw.slides.some((slide) => isPlainRecord(slide) && recordHasBannerHeadingPin(slide))) {
|
|
2377
|
+
return true;
|
|
2378
|
+
}
|
|
2379
|
+
if (Array.isArray(raw.pages) && raw.pages.some((page) => isPlainRecord(page) && recordHasBannerHeadingPin(page))) {
|
|
2380
|
+
return true;
|
|
2381
|
+
}
|
|
2382
|
+
return false;
|
|
2383
|
+
}
|
|
2384
|
+
function migrateRewriteNote(chrome, bloom, logoWall = false, bannerHeading = false) {
|
|
2385
|
+
const parts = [];
|
|
2386
|
+
if (chrome) parts.push("renamed chrome \u2192 branding");
|
|
2387
|
+
if (bloom) parts.push("relocated bloom \u2192 classroom");
|
|
2388
|
+
if (logoWall) parts.push("rewrote logo_wall \u2192 image_grid");
|
|
2389
|
+
if (bannerHeading) parts.push("rewrote banner-heading \u2192 two-column");
|
|
2390
|
+
return parts.join(", ");
|
|
2391
|
+
}
|
|
2392
|
+
function applyV4LeftoverRewrites(raw) {
|
|
2393
|
+
return migrateBannerHeadingToTwoColumn(
|
|
2394
|
+
migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(raw)))
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
async function listPageJsonNames(dir) {
|
|
2398
|
+
const pagesDir = join5(dir, PAGES_DIRNAME);
|
|
2399
|
+
try {
|
|
2400
|
+
const entries = await readdir3(pagesDir);
|
|
2401
|
+
return entries.filter((name) => name.endsWith(".json")).sort();
|
|
2402
|
+
} catch (e) {
|
|
2403
|
+
if (e.code === "ENOENT") return [];
|
|
2404
|
+
throw e;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
async function rewriteLeftoverPages(dir, outDir) {
|
|
2408
|
+
const names = await listPageJsonNames(dir);
|
|
2409
|
+
const written = [];
|
|
2410
|
+
let logoWall = false;
|
|
2411
|
+
let bannerHeading = false;
|
|
2412
|
+
for (const name of names) {
|
|
2413
|
+
const src = join5(dir, PAGES_DIRNAME, name);
|
|
2414
|
+
const raw = await loadIrFile(src, "page");
|
|
2415
|
+
if (!isPlainRecord(raw)) continue;
|
|
2416
|
+
const hasLogo = needsLogoWallRewrite(raw);
|
|
2417
|
+
const hasBanner = needsBannerHeadingRewrite(raw);
|
|
2418
|
+
if (!hasLogo && !hasBanner) continue;
|
|
2419
|
+
if (hasLogo) logoWall = true;
|
|
2420
|
+
if (hasBanner) bannerHeading = true;
|
|
2421
|
+
const dest = join5(outDir, PAGES_DIRNAME, name);
|
|
2422
|
+
await writeMigratedJson(dest, migrateBannerHeadingToTwoColumn(migrateLogoWallToImageGrid(raw)));
|
|
2423
|
+
written.push(dest);
|
|
2424
|
+
}
|
|
2425
|
+
return { paths: written, logoWall, bannerHeading };
|
|
2426
|
+
}
|
|
2427
|
+
async function writeMigratedJson(outPath, data) {
|
|
2428
|
+
await mkdir4(dirname3(outPath), { recursive: true });
|
|
2429
|
+
try {
|
|
2430
|
+
await writeFile3(outPath, JSON.stringify(data, null, 2) + "\n", { flag: "wx" });
|
|
2431
|
+
} catch (e) {
|
|
2432
|
+
if (e.code === "EEXIST") {
|
|
2433
|
+
throw new PptwiseError(`${outPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
|
|
2434
|
+
}
|
|
2435
|
+
throw e;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
async function runMigrateDeckDir(dir, output, cwd) {
|
|
2439
|
+
const planPath = join5(dir, PLAN_FILENAME);
|
|
2440
|
+
const sourceSpecPath = join5(dir, SPEC_FILENAME);
|
|
2441
|
+
const outDir = resolve6(cwd, output);
|
|
2442
|
+
const specPath = join5(outDir, SPEC_FILENAME);
|
|
2443
|
+
if (!await pathExists(planPath) && await pathExists(sourceSpecPath)) {
|
|
2444
|
+
const raw2 = await loadIrFile(sourceSpecPath, "spec");
|
|
2445
|
+
const specNeeds = isPlainRecord(raw2) && (needsChromeRewrite(raw2) || needsBloomRewrite(raw2) || needsBannerHeadingRewrite(raw2));
|
|
2446
|
+
const pages2 = await rewriteLeftoverPages(dir, outDir);
|
|
2447
|
+
if (specNeeds && isPlainRecord(raw2)) {
|
|
2448
|
+
const chrome = needsChromeRewrite(raw2);
|
|
2449
|
+
const bloom = needsBloomRewrite(raw2);
|
|
2450
|
+
const bannerHeading = needsBannerHeadingRewrite(raw2);
|
|
2451
|
+
const migrated2 = applyV4LeftoverRewrites(raw2);
|
|
2452
|
+
await writeMigratedJson(specPath, migrated2);
|
|
2453
|
+
const specNote2 = `wrote ${specPath} (${migrateRewriteNote(chrome, bloom, false, bannerHeading)})`;
|
|
2454
|
+
if (pages2.paths.length === 0) return specNote2;
|
|
2455
|
+
return `${specNote2}, wrote ${pages2.paths.length === 1 ? pages2.paths[0] : join5(outDir, PAGES_DIRNAME)} (${migrateRewriteNote(false, false, pages2.logoWall, pages2.bannerHeading)})`;
|
|
2456
|
+
}
|
|
2457
|
+
if (pages2.paths.length > 0) {
|
|
2458
|
+
const target = pages2.paths.length === 1 ? pages2.paths[0] : join5(outDir, PAGES_DIRNAME);
|
|
2459
|
+
return `wrote ${target} (${migrateRewriteNote(false, false, pages2.logoWall, pages2.bannerHeading)})`;
|
|
2460
|
+
}
|
|
2461
|
+
throw new PptwiseError(
|
|
2462
|
+
`${dir} has ${SPEC_FILENAME} but no ${PLAN_FILENAME} \u2014 this deck project is already migrated, nothing to do`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
const raw = await loadIrFile(planPath, "plan");
|
|
2466
|
+
const migrated = migrateDeckPlanToSpec(raw);
|
|
2467
|
+
await writeMigratedJson(specPath, migrated);
|
|
2468
|
+
const pages = await rewriteLeftoverPages(dir, outDir);
|
|
2469
|
+
const specNote = `wrote ${specPath} \u2014 run \`pptwise spec validate ${specPath}\` to confirm it, then delete ${planPath} (a directory with both files present is rejected)`;
|
|
2470
|
+
if (pages.paths.length === 0) return specNote;
|
|
2471
|
+
return `${specNote}, wrote ${pages.paths.length === 1 ? pages.paths[0] : join5(outDir, PAGES_DIRNAME)} (${migrateRewriteNote(false, false, pages.logoWall, pages.bannerHeading)})`;
|
|
2472
|
+
}
|
|
2473
|
+
async function runMigrateIrFile(filePath, output, cwd) {
|
|
2474
|
+
const raw = await loadIrFile(filePath);
|
|
2475
|
+
const version = typeof raw === "object" && raw !== null ? raw.version : void 0;
|
|
2476
|
+
if (version === "2") {
|
|
2477
|
+
throw new PptwiseError(
|
|
2478
|
+
"pptwise migrate does not support IR v2 (spec \xA715.3: v2 has no real users) \u2014 run `pptwise validate` on the v2 file to see the full v2\u2192v4 combined field mapping and rewrite it by hand"
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
const outPath = resolve6(cwd, output);
|
|
2482
|
+
if (version === "3") {
|
|
2483
|
+
const pre = isPlainRecord(raw) ? migrateLogoWallToImageGrid(raw) : raw;
|
|
2484
|
+
const parsed = PptxIRV3Schema.safeParse(pre);
|
|
2485
|
+
if (!parsed.success) {
|
|
2486
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
2487
|
+
throw new PptwiseError(`invalid IR v3 file ${filePath}:
|
|
2488
|
+
${detail}`);
|
|
2489
|
+
}
|
|
2490
|
+
const migrated = migrateIrV3ToV4(parsed.data);
|
|
2491
|
+
await writeMigratedJson(outPath, migrated);
|
|
2492
|
+
return `wrote ${outPath} (migrated IR v3 \u2192 v4)`;
|
|
2493
|
+
}
|
|
2494
|
+
if (isPlainRecord(raw) && (needsChromeRewrite(raw) || needsBloomRewrite(raw) || needsLogoWallRewrite(raw) || needsBannerHeadingRewrite(raw))) {
|
|
2495
|
+
const chrome = needsChromeRewrite(raw);
|
|
2496
|
+
const bloom = needsBloomRewrite(raw);
|
|
2497
|
+
const logoWall = needsLogoWallRewrite(raw);
|
|
2498
|
+
const bannerHeading = needsBannerHeadingRewrite(raw);
|
|
2499
|
+
const migrated = applyV4LeftoverRewrites(raw);
|
|
2500
|
+
await writeMigratedJson(outPath, migrated);
|
|
2501
|
+
return `wrote ${outPath} (${migrateRewriteNote(chrome, bloom, logoWall, bannerHeading)})`;
|
|
2502
|
+
}
|
|
2503
|
+
throw new PptwiseError(
|
|
2504
|
+
`pptwise migrate converts an IR v3 file (version: "3"), a v4 IR or deck spec still carrying the old chrome field (renamed to branding), the removed bloom theme id (relocated to classroom), a leftover logo_wall component (rewritten to image_grid), or a leftover banner-heading layout pin (rewritten to two-column), or a deck project directory containing ${PLAN_FILENAME} \u2014 got version ${JSON.stringify(version)} in ${filePath} with nothing to migrate`
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// src/cli/secret-input.ts
|
|
2509
|
+
import readline from "readline";
|
|
2510
|
+
async function readSecret(prompt, io = { stdin: process.stdin, stderr: process.stderr }) {
|
|
2511
|
+
const { stdin, stderr } = io;
|
|
2512
|
+
const isTTY = Boolean(stdin.isTTY);
|
|
2513
|
+
if (!isTTY) {
|
|
2514
|
+
const line = await readFirstLine(stdin);
|
|
2515
|
+
if (line === "") throw new PptwiseError("API key cannot be empty");
|
|
2516
|
+
return line;
|
|
2517
|
+
}
|
|
2518
|
+
return await readHiddenTty(prompt, stdin, stderr);
|
|
2519
|
+
}
|
|
2520
|
+
function readFirstLine(stdin) {
|
|
2521
|
+
return new Promise((resolve8, reject) => {
|
|
2522
|
+
const encoding = stdin.readableEncoding ?? "utf8";
|
|
2523
|
+
let buf = "";
|
|
2524
|
+
const onData = (chunk) => {
|
|
2525
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString(encoding);
|
|
2526
|
+
const nl = buf.indexOf("\n");
|
|
2527
|
+
if (nl === -1) return;
|
|
2528
|
+
cleanup();
|
|
2529
|
+
resolve8(buf.slice(0, nl).replace(/\r$/, "").trim());
|
|
2530
|
+
};
|
|
2531
|
+
const onEnd = () => {
|
|
2532
|
+
cleanup();
|
|
2533
|
+
resolve8(buf.replace(/\r$/, "").trim());
|
|
2534
|
+
};
|
|
2535
|
+
const onError = (e) => {
|
|
2536
|
+
cleanup();
|
|
2537
|
+
reject(e);
|
|
2538
|
+
};
|
|
2539
|
+
const cleanup = () => {
|
|
2540
|
+
stdin.off("data", onData);
|
|
2541
|
+
stdin.off("end", onEnd);
|
|
2542
|
+
stdin.off("error", onError);
|
|
2543
|
+
};
|
|
2544
|
+
stdin.on("data", onData);
|
|
2545
|
+
stdin.on("end", onEnd);
|
|
2546
|
+
stdin.on("error", onError);
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
function readHiddenTty(prompt, stdin, stderr) {
|
|
2550
|
+
return new Promise((resolve8, reject) => {
|
|
2551
|
+
const rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
|
|
2552
|
+
const mutable = rl;
|
|
2553
|
+
mutable._writeToOutput = () => {
|
|
2554
|
+
};
|
|
2555
|
+
stderr.write(prompt);
|
|
2556
|
+
let settled = false;
|
|
2557
|
+
const finish = (fn) => {
|
|
2558
|
+
if (settled) return;
|
|
2559
|
+
settled = true;
|
|
2560
|
+
fn();
|
|
2561
|
+
};
|
|
2562
|
+
rl.question("", (answer) => {
|
|
2563
|
+
finish(() => {
|
|
2564
|
+
rl.close();
|
|
2565
|
+
stderr.write("\n");
|
|
2566
|
+
const value = answer.trim();
|
|
2567
|
+
if (value === "") reject(new PptwiseError("API key cannot be empty"));
|
|
2568
|
+
else resolve8(value);
|
|
2569
|
+
});
|
|
2570
|
+
});
|
|
2571
|
+
rl.on("SIGINT", () => {
|
|
2572
|
+
finish(() => {
|
|
2573
|
+
rl.close();
|
|
2574
|
+
stderr.write("\n");
|
|
2575
|
+
reject(new PptwiseError("cancelled"));
|
|
2576
|
+
});
|
|
2577
|
+
});
|
|
2578
|
+
rl.on("close", () => {
|
|
2579
|
+
finish(() => reject(new PptwiseError("cancelled")));
|
|
2580
|
+
});
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// src/cli/config-cmd.ts
|
|
2585
|
+
function canOmitValue(key) {
|
|
2586
|
+
return key.endsWith(".apiKey") || key.endsWith(".clientSecret");
|
|
2587
|
+
}
|
|
2588
|
+
async function runConfigSet(key, value, opts = {}) {
|
|
2589
|
+
if (value === void 0 && !canOmitValue(key)) {
|
|
2590
|
+
throw new PptwiseError(`${key} needs a value: pptwise config set ${key} <value>`);
|
|
2591
|
+
}
|
|
2592
|
+
const parsed = parseCliConfigKey(key);
|
|
2593
|
+
let resolved = value;
|
|
2594
|
+
if (resolved === void 0) {
|
|
2595
|
+
const read = opts.readSecret ?? readSecret;
|
|
2596
|
+
resolved = await read(`${key} (input hidden): `, opts.io);
|
|
2597
|
+
}
|
|
2598
|
+
const stored = parseCliConfigValue(parsed, resolved);
|
|
2599
|
+
const path2 = await persistUserConfigValue(parsed.path, stored);
|
|
2600
|
+
return `Saved ${key} to ${path2}`;
|
|
2601
|
+
}
|
|
2602
|
+
async function runConfigShow(opts = {}) {
|
|
2603
|
+
const path2 = userConfigPath();
|
|
2604
|
+
const hit = await findUserConfig();
|
|
2605
|
+
const file = hit?.config ?? null;
|
|
2606
|
+
const keys = resolveImageKeys({ file, env: opts.env ?? process.env });
|
|
2607
|
+
const lines = [`User config: ${path2}`, ""];
|
|
2608
|
+
for (const provider of ["pexels", "pixabay"]) {
|
|
2609
|
+
const label = `${provider}.apiKey`;
|
|
2610
|
+
const entry = keys[provider];
|
|
2611
|
+
if (!entry.apiKey) {
|
|
2612
|
+
lines.push(`${label} missing`);
|
|
2613
|
+
continue;
|
|
2614
|
+
}
|
|
2615
|
+
const src = entry.source === null ? "" : ` (${entry.source})`;
|
|
2616
|
+
lines.push(`${label} ${maskKey(entry.apiKey)}${src}`);
|
|
2617
|
+
}
|
|
2618
|
+
const ov = keys.openverse;
|
|
2619
|
+
const ovSrc = ov.source === null ? "" : ` (${ov.source})`;
|
|
2620
|
+
if (ov.clientId) lines.push(`openverse.clientId ${maskKey(ov.clientId)}${ovSrc}`);
|
|
2621
|
+
else lines.push("openverse.clientId missing");
|
|
2622
|
+
if (ov.clientSecret) lines.push(`openverse.clientSecret ${maskKey(ov.clientSecret)}${ovSrc}`);
|
|
2623
|
+
else lines.push("openverse.clientSecret missing");
|
|
2624
|
+
const gens = resolveGenerators({ file });
|
|
2625
|
+
lines.push("");
|
|
2626
|
+
for (const id of GENERATOR_IDS) {
|
|
2627
|
+
lines.push(`images.generators.${id}.enabled ${gens.enabled[id] ? "true" : "false"}`);
|
|
2628
|
+
}
|
|
2629
|
+
const rawGens = file?.images?.generators;
|
|
2630
|
+
if (rawGens?.order) lines.push(`images.generators.order ${rawGens.order.join(",")}`);
|
|
2631
|
+
if (rawGens?.timeoutMs !== void 0) lines.push(`images.generators.timeoutMs ${rawGens.timeoutMs}`);
|
|
2632
|
+
return lines.join("\n");
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/cli/doctor.ts
|
|
2636
|
+
import { lstatSync as lstatSync2 } from "fs";
|
|
2637
|
+
import { access, readFile as readFile7, readdir as readdir5 } from "fs/promises";
|
|
2638
|
+
import { homedir } from "os";
|
|
2639
|
+
import { basename as basename5, join as join7 } from "path";
|
|
2640
|
+
|
|
2641
|
+
// src/cli/image-generators.ts
|
|
2642
|
+
import { copyFile as copyFile2, readdir as readdir4, stat as stat3 } from "fs/promises";
|
|
2643
|
+
import { join as join6 } from "path";
|
|
2644
|
+
var GENERATOR_PROBE_TIMEOUT_MS = 2e3;
|
|
2645
|
+
var BIN_NAMES = {
|
|
2646
|
+
grok: ["grok"],
|
|
2647
|
+
codex: ["codex"],
|
|
2648
|
+
antigravity: ["antigravity", "agy"]
|
|
2649
|
+
};
|
|
2650
|
+
async function locateGeneratorBin(id, env) {
|
|
2651
|
+
for (const name of BIN_NAMES[id]) {
|
|
2652
|
+
const found = await findOnPath(name, env);
|
|
2653
|
+
if (found) return found;
|
|
2654
|
+
}
|
|
2655
|
+
return null;
|
|
2656
|
+
}
|
|
2657
|
+
function parseGeneratorVersion(stdout) {
|
|
2658
|
+
const line = stdout.trim().split("\n")[0] ?? "";
|
|
2659
|
+
const m = /(\d+\.\d+\.\d+\S*)/.exec(line);
|
|
2660
|
+
return m?.[1] ?? line;
|
|
2661
|
+
}
|
|
2662
|
+
async function defaultProcessRunner(req) {
|
|
2663
|
+
try {
|
|
2664
|
+
return await runChild(req.command, req.args, {
|
|
2665
|
+
cwd: req.cwd,
|
|
2666
|
+
env: req.env,
|
|
2667
|
+
timeoutMs: req.timeoutMs
|
|
2668
|
+
});
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
if (error instanceof ChildTimeoutError) {
|
|
2671
|
+
throw new PptwiseError(`image generator timed out after ${req.timeoutMs}ms`);
|
|
2672
|
+
}
|
|
2673
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2674
|
+
return { code: 1, stdout: "", stderr: message };
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
async function probeGenerators(opts) {
|
|
2678
|
+
const env = opts.env ?? process.env;
|
|
2679
|
+
const run = opts.run ?? defaultProcessRunner;
|
|
2680
|
+
const probes = [];
|
|
2681
|
+
for (const id of GENERATOR_IDS) {
|
|
2682
|
+
const bin = await locateGeneratorBin(id, env);
|
|
2683
|
+
let version = null;
|
|
2684
|
+
if (bin) {
|
|
2685
|
+
try {
|
|
2686
|
+
const result = await run({ command: bin, args: ["--version"], cwd: void 0, timeoutMs: GENERATOR_PROBE_TIMEOUT_MS, env });
|
|
2687
|
+
if (result.code === 0) version = parseGeneratorVersion(result.stdout);
|
|
2688
|
+
} catch {
|
|
2689
|
+
version = null;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
probes.push({ id, found: bin !== null, bin, version, enabled: opts.enabled[id] === true });
|
|
2693
|
+
}
|
|
2694
|
+
return probes;
|
|
2695
|
+
}
|
|
2696
|
+
function destContractPrompt(userPrompt, destAbs, toolName) {
|
|
2697
|
+
return [
|
|
2698
|
+
`Generate exactly one image with the ${toolName} tool using this prompt:`,
|
|
2699
|
+
userPrompt,
|
|
2700
|
+
`Then copy or move the saved file to ${destAbs} so that path exists as a real image file.`,
|
|
2701
|
+
"Print DONE when that path exists. Edit no other files."
|
|
2702
|
+
].join("\n");
|
|
2703
|
+
}
|
|
2704
|
+
async function isSniffedImage(path2) {
|
|
2705
|
+
try {
|
|
2706
|
+
const { readFile: readFile9 } = await import("fs/promises");
|
|
2707
|
+
const bytes = await readFile9(path2);
|
|
2708
|
+
return sniffImageFormat(bytes) !== null;
|
|
2709
|
+
} catch {
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
async function collectFiles(dir, acc) {
|
|
2714
|
+
let entries;
|
|
2715
|
+
try {
|
|
2716
|
+
entries = await readdir4(dir, { withFileTypes: true });
|
|
2717
|
+
} catch {
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
for (const entry of entries) {
|
|
2721
|
+
const full = join6(dir, entry.name);
|
|
2722
|
+
if (entry.isDirectory()) {
|
|
2723
|
+
await collectFiles(full, acc);
|
|
2724
|
+
} else if (entry.isFile()) {
|
|
2725
|
+
try {
|
|
2726
|
+
const st = await stat3(full);
|
|
2727
|
+
acc.push({ path: full, mtime: st.mtimeMs });
|
|
2728
|
+
} catch {
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
async function harvestNewestImage(workdir, dest) {
|
|
2734
|
+
const files = [];
|
|
2735
|
+
await collectFiles(workdir, files);
|
|
2736
|
+
const images2 = [];
|
|
2737
|
+
for (const file of files) {
|
|
2738
|
+
if (file.path === dest) continue;
|
|
2739
|
+
if (await isSniffedImage(file.path)) images2.push(file);
|
|
2740
|
+
}
|
|
2741
|
+
images2.sort((a, b) => b.mtime - a.mtime);
|
|
2742
|
+
const newest = images2[0];
|
|
2743
|
+
if (!newest) return false;
|
|
2744
|
+
await copyFile2(newest.path, dest);
|
|
2745
|
+
return isSniffedImage(dest);
|
|
2746
|
+
}
|
|
2747
|
+
async function settleDest(req, result) {
|
|
2748
|
+
if (await isSniffedImage(req.dest)) return;
|
|
2749
|
+
if (await harvestNewestImage(req.workdir, req.dest)) return;
|
|
2750
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 400);
|
|
2751
|
+
throw new PptwiseError(
|
|
2752
|
+
result.code === 0 ? `produced no image file at ${req.dest}${detail ? `: ${detail}` : ""}` : `exited ${result.code}${detail ? `: ${detail}` : ""}`
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
async function runGrokAdapter(req) {
|
|
2756
|
+
const result = await req.run({
|
|
2757
|
+
command: req.bin,
|
|
2758
|
+
args: [
|
|
2759
|
+
"-p",
|
|
2760
|
+
destContractPrompt(req.prompt, req.dest, "image_gen"),
|
|
2761
|
+
"--cwd",
|
|
2762
|
+
req.workdir,
|
|
2763
|
+
"--permission-mode",
|
|
2764
|
+
"bypassPermissions",
|
|
2765
|
+
"--max-turns",
|
|
2766
|
+
"12"
|
|
2767
|
+
],
|
|
2768
|
+
cwd: req.workdir,
|
|
2769
|
+
timeoutMs: req.timeoutMs
|
|
2770
|
+
});
|
|
2771
|
+
await settleDest(req, result);
|
|
2772
|
+
}
|
|
2773
|
+
async function runCodexAdapter(req) {
|
|
2774
|
+
const result = await req.run({
|
|
2775
|
+
command: req.bin,
|
|
2776
|
+
args: [
|
|
2777
|
+
"exec",
|
|
2778
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
2779
|
+
"--skip-git-repo-check",
|
|
2780
|
+
"--ephemeral",
|
|
2781
|
+
"-C",
|
|
2782
|
+
req.workdir,
|
|
2783
|
+
destContractPrompt(req.prompt, req.dest, "image_gen__imagegen")
|
|
2784
|
+
],
|
|
2785
|
+
cwd: req.workdir,
|
|
2786
|
+
timeoutMs: req.timeoutMs
|
|
2787
|
+
});
|
|
2788
|
+
await settleDest(req, result);
|
|
2789
|
+
}
|
|
2790
|
+
async function runAntigravityAdapter(req) {
|
|
2791
|
+
const seconds = Math.max(1, Math.ceil(req.timeoutMs / 1e3));
|
|
2792
|
+
const result = await req.run({
|
|
2793
|
+
command: req.bin,
|
|
2794
|
+
args: [
|
|
2795
|
+
"-p",
|
|
2796
|
+
destContractPrompt(req.prompt, req.dest, "generate_image"),
|
|
2797
|
+
"--dangerously-skip-permissions",
|
|
2798
|
+
"--print-timeout",
|
|
2799
|
+
`${seconds}s`
|
|
2800
|
+
],
|
|
2801
|
+
cwd: req.workdir,
|
|
2802
|
+
timeoutMs: req.timeoutMs
|
|
2803
|
+
});
|
|
2804
|
+
await settleDest(req, result);
|
|
2805
|
+
}
|
|
2806
|
+
var GENERATOR_ADAPTERS = {
|
|
2807
|
+
grok: runGrokAdapter,
|
|
2808
|
+
codex: runCodexAdapter,
|
|
2809
|
+
antigravity: runAntigravityAdapter
|
|
2810
|
+
};
|
|
2811
|
+
|
|
2812
|
+
// src/cli/update.ts
|
|
2813
|
+
var PACKAGE_NAME = "@liustack/pptwise";
|
|
2814
|
+
function normalizeVersion(version) {
|
|
2815
|
+
const normalized = version.trim().replace(/^v/i, "").split("-")[0];
|
|
2816
|
+
if (!normalized) throw new Error(`invalid version: ${version}`);
|
|
2817
|
+
return normalized;
|
|
2818
|
+
}
|
|
2819
|
+
function parseVersion(version) {
|
|
2820
|
+
return normalizeVersion(version).split(".").map((segment) => {
|
|
2821
|
+
const value = Number.parseInt(segment, 10);
|
|
2822
|
+
if (!Number.isFinite(value)) throw new Error(`invalid version segment: ${segment}`);
|
|
2823
|
+
return value;
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
function compareVersions(left, right) {
|
|
2827
|
+
const l = parseVersion(left);
|
|
2828
|
+
const r = parseVersion(right);
|
|
2829
|
+
for (let i = 0; i < Math.max(l.length, r.length); i++) {
|
|
2830
|
+
const a = l[i] ?? 0;
|
|
2831
|
+
const b = r[i] ?? 0;
|
|
2832
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
2833
|
+
}
|
|
2834
|
+
return 0;
|
|
2835
|
+
}
|
|
2836
|
+
var runCommand = async (command, args) => {
|
|
2837
|
+
const { code, stdout, stderr } = await runChild(command, args);
|
|
2838
|
+
if (code !== 0) throw new Error(stderr.trim() || `exited ${code}`);
|
|
2839
|
+
return stdout.trim();
|
|
2840
|
+
};
|
|
2841
|
+
async function checkForUpdate({
|
|
2842
|
+
currentVersion,
|
|
2843
|
+
packageName = PACKAGE_NAME,
|
|
2844
|
+
run = runCommand
|
|
2845
|
+
}) {
|
|
2846
|
+
const current = normalizeVersion(currentVersion);
|
|
2847
|
+
try {
|
|
2848
|
+
const latest = normalizeVersion(await run("npm", ["view", packageName, "version"]));
|
|
2849
|
+
return {
|
|
2850
|
+
packageName,
|
|
2851
|
+
currentVersion: current,
|
|
2852
|
+
latestVersion: latest,
|
|
2853
|
+
updateAvailable: compareVersions(current, latest) < 0,
|
|
2854
|
+
checked: true
|
|
2855
|
+
};
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
return {
|
|
2858
|
+
packageName,
|
|
2859
|
+
currentVersion: current,
|
|
2860
|
+
latestVersion: null,
|
|
2861
|
+
updateAvailable: false,
|
|
2862
|
+
checked: false,
|
|
2863
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
function createSelfUpdater(run = runCommand) {
|
|
2868
|
+
return async ({
|
|
2869
|
+
currentVersion,
|
|
2870
|
+
packageName = PACKAGE_NAME
|
|
2871
|
+
}) => {
|
|
2872
|
+
const info = await checkForUpdate({ currentVersion, packageName, run });
|
|
2873
|
+
if (!info.checked) throw new Error(`unable to check for updates: ${info.error ?? "unknown error"}`);
|
|
2874
|
+
if (!info.updateAvailable) return { ...info, updated: false };
|
|
2875
|
+
await run("npm", ["install", "-g", `${packageName}@latest`]);
|
|
2876
|
+
return { ...info, updated: true };
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
// src/cli/doctor.ts
|
|
2881
|
+
var MIN_NODE = "22.19";
|
|
2882
|
+
var SKILL_DIR_NAME = "pptwise";
|
|
2883
|
+
var LEGACY_SKILL_DIR_NAMES = ["pptpress", "pptfast"];
|
|
2884
|
+
var SKILL_COPY_FILES = [
|
|
2885
|
+
"SKILL.md",
|
|
2886
|
+
"SKILL.zh-CN.md",
|
|
2887
|
+
"scripts/run.sh",
|
|
2888
|
+
"scripts/run.ps1",
|
|
2889
|
+
"references/spec.md",
|
|
2890
|
+
"references/spec.zh-CN.md",
|
|
2891
|
+
"references/layouts.md",
|
|
2892
|
+
"references/layouts.zh-CN.md",
|
|
2893
|
+
"references/components.md",
|
|
2894
|
+
"references/components.zh-CN.md",
|
|
2895
|
+
"references/density.md",
|
|
2896
|
+
"references/density.zh-CN.md",
|
|
2897
|
+
"references/branding.md",
|
|
2898
|
+
"references/branding.zh-CN.md",
|
|
2899
|
+
"references/images.md",
|
|
2900
|
+
"references/images.zh-CN.md",
|
|
2901
|
+
"references/validate.md",
|
|
2902
|
+
"references/validate.zh-CN.md"
|
|
2903
|
+
];
|
|
2904
|
+
var SKILL_ROOTS = [
|
|
2905
|
+
{ harness: "Claude Code", relative: join7(".claude", "skills") },
|
|
2906
|
+
{ harness: "Codex", relative: join7(".codex", "skills") },
|
|
2907
|
+
{ harness: "Pi, OpenCode", relative: join7(".agents", "skills") }
|
|
2908
|
+
];
|
|
2909
|
+
var SELF_TEST_DECK = {
|
|
2910
|
+
version: "4",
|
|
2911
|
+
filename: "pptwise-doctor-self-test.pptx",
|
|
2912
|
+
theme: { id: "consulting" },
|
|
2913
|
+
slides: [
|
|
2914
|
+
{ type: "cover", heading: "pptwise doctor", subheading: "self-test render" },
|
|
2915
|
+
{
|
|
2916
|
+
type: "content",
|
|
2917
|
+
heading: "Core chain",
|
|
2918
|
+
components: [{ type: "bullets", items: ["validate the IR", "render a slide to SVG", "generate the .pptx bytes"] }]
|
|
2919
|
+
}
|
|
2920
|
+
]
|
|
2921
|
+
};
|
|
2922
|
+
function readPinnedVersion(launcher) {
|
|
2923
|
+
return /^PINNED="([^"]+)"/m.exec(launcher)?.[1] ?? null;
|
|
2924
|
+
}
|
|
2925
|
+
function isOlder(version, current) {
|
|
2926
|
+
try {
|
|
2927
|
+
return compareVersions(version, current) < 0;
|
|
2928
|
+
} catch {
|
|
2929
|
+
return false;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
async function pathExists2(target) {
|
|
2933
|
+
try {
|
|
2934
|
+
await access(target);
|
|
2935
|
+
return true;
|
|
2936
|
+
} catch {
|
|
2937
|
+
return false;
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
async function scanSkillCopies(home, currentVersion) {
|
|
2941
|
+
const scanned = [];
|
|
2942
|
+
const copies = [];
|
|
2943
|
+
for (const { harness, relative: relative3 } of SKILL_ROOTS) {
|
|
2944
|
+
const root = join7(home, relative3);
|
|
2945
|
+
scanned.push(root);
|
|
2946
|
+
for (const { dirName, legacy } of [
|
|
2947
|
+
{ dirName: SKILL_DIR_NAME, legacy: false },
|
|
2948
|
+
...LEGACY_SKILL_DIR_NAMES.map((dirName2) => ({ dirName: dirName2, legacy: true }))
|
|
2949
|
+
]) {
|
|
2950
|
+
const copyDir = join7(root, dirName);
|
|
2951
|
+
if (!await pathExists2(copyDir)) continue;
|
|
2952
|
+
const launcherPath = join7(copyDir, "scripts", "run.sh");
|
|
2953
|
+
let pinned = null;
|
|
2954
|
+
let launcher = null;
|
|
2955
|
+
try {
|
|
2956
|
+
pinned = readPinnedVersion(await readFile7(launcherPath, "utf8"));
|
|
2957
|
+
launcher = launcherPath;
|
|
2958
|
+
} catch {
|
|
2959
|
+
}
|
|
2960
|
+
const missing = [];
|
|
2961
|
+
for (const rel of SKILL_COPY_FILES) {
|
|
2962
|
+
if (!await pathExists2(join7(copyDir, rel))) missing.push(rel);
|
|
2963
|
+
}
|
|
2964
|
+
copies.push({
|
|
2965
|
+
harness,
|
|
2966
|
+
root,
|
|
2967
|
+
path: copyDir,
|
|
2968
|
+
launcher,
|
|
2969
|
+
pinned,
|
|
2970
|
+
stale: legacy || pinned !== null && isOlder(pinned, currentVersion),
|
|
2971
|
+
missing,
|
|
2972
|
+
legacy
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
return { scanned, copies };
|
|
2977
|
+
}
|
|
2978
|
+
async function readInstalledPluginVersion(profileDir) {
|
|
2979
|
+
const pkgPath = join7(profileDir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json");
|
|
2980
|
+
try {
|
|
2981
|
+
const pkg = JSON.parse(await readFile7(pkgPath, "utf8"));
|
|
2982
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
2983
|
+
} catch {
|
|
2984
|
+
return null;
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
async function inspectDsh(home, currentVersion) {
|
|
2988
|
+
const dshHome = join7(home, ".dsh");
|
|
2989
|
+
if (!await pathExists2(dshHome)) return { applicable: false, home: dshHome, profiles: [] };
|
|
2990
|
+
const profilesDir = join7(dshHome, "profiles");
|
|
2991
|
+
let entries;
|
|
2992
|
+
try {
|
|
2993
|
+
const dirents = await readdir5(profilesDir, { withFileTypes: true });
|
|
2994
|
+
entries = dirents.filter((d) => d.isDirectory() && d.name !== "node_modules").map((d) => d.name);
|
|
2995
|
+
} catch {
|
|
2996
|
+
return { applicable: true, home: dshHome, profiles: [] };
|
|
2997
|
+
}
|
|
2998
|
+
const profiles = [];
|
|
2999
|
+
for (const name of entries.sort()) {
|
|
3000
|
+
const profileDir = join7(profilesDir, name);
|
|
3001
|
+
let declared;
|
|
3002
|
+
try {
|
|
3003
|
+
const pkg = JSON.parse(await readFile7(join7(profileDir, "package.json"), "utf8"));
|
|
3004
|
+
declared = pkg.dependencies?.[PACKAGE_NAME];
|
|
3005
|
+
} catch {
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
const installedVersion = await readInstalledPluginVersion(profileDir);
|
|
3009
|
+
const declaredVersion = typeof declared === "string" && /^\d+\.\d+\.\d+/.test(declared) ? declared.match(/^\d+\.\d+\.\d+/)[0] : null;
|
|
3010
|
+
const version = installedVersion ?? declaredVersion;
|
|
3011
|
+
const installed = installedVersion !== null || declared !== void 0;
|
|
3012
|
+
profiles.push({
|
|
3013
|
+
name,
|
|
3014
|
+
path: profileDir,
|
|
3015
|
+
installed,
|
|
3016
|
+
version,
|
|
3017
|
+
source: installedVersion !== null ? "node_modules" : declaredVersion !== null ? "package.json" : null,
|
|
3018
|
+
stale: installed && version !== null && isOlder(version, currentVersion)
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
return { applicable: true, home: dshHome, profiles };
|
|
3022
|
+
}
|
|
3023
|
+
function dshInstallCommand(profile, version) {
|
|
3024
|
+
return `npx -y @deepseek-ai/dsh plugin --profile ${profile} add ${PACKAGE_NAME}@${version}`;
|
|
3025
|
+
}
|
|
3026
|
+
async function inspectCapabilities(env) {
|
|
3027
|
+
const capabilities = [];
|
|
3028
|
+
let sharpAvailable = false;
|
|
3029
|
+
let sharpError = null;
|
|
3030
|
+
try {
|
|
3031
|
+
await import("sharp");
|
|
3032
|
+
sharpAvailable = true;
|
|
3033
|
+
} catch (e) {
|
|
3034
|
+
sharpError = isMissingModuleError(e) ? "not installed" : e instanceof Error ? e.message : String(e);
|
|
3035
|
+
}
|
|
3036
|
+
capabilities.push({
|
|
3037
|
+
name: "sharp",
|
|
3038
|
+
available: sharpAvailable,
|
|
3039
|
+
detail: sharpAvailable ? "importable \u2014 preview rasterization and `audit --pixels` are available" : `${sharpError} \u2014 preview rasterization and \`audit --pixels\` are unavailable. Plain SVG preview and .pptx rendering are unaffected`,
|
|
3040
|
+
fix: sharpAvailable ? void 0 : "npm i sharp (optional dependency \u2014 install it only if you want the pixel checks)"
|
|
3041
|
+
});
|
|
3042
|
+
const soffice = await findOnPath("soffice", env);
|
|
3043
|
+
capabilities.push({
|
|
3044
|
+
name: "soffice",
|
|
3045
|
+
available: soffice !== null,
|
|
3046
|
+
detail: soffice !== null ? `found at ${soffice} \u2014 the PDF export path is available` : "not on PATH \u2014 the PDF export path is unavailable. Rendering and validating .pptx files are unaffected",
|
|
3047
|
+
fix: soffice !== null ? void 0 : "install LibreOffice (https://libreoffice.org) and make sure `soffice` is on PATH"
|
|
3048
|
+
});
|
|
3049
|
+
return capabilities;
|
|
3050
|
+
}
|
|
3051
|
+
function posixBitsApply() {
|
|
3052
|
+
return typeof process.getuid === "function";
|
|
3053
|
+
}
|
|
3054
|
+
async function inspectImages(env) {
|
|
3055
|
+
const configPath = userConfigPath();
|
|
3056
|
+
let configExists = false;
|
|
3057
|
+
let groupOrOtherReadable = false;
|
|
3058
|
+
try {
|
|
3059
|
+
const st = lstatSync2(configPath);
|
|
3060
|
+
configExists = true;
|
|
3061
|
+
if (posixBitsApply() && !st.isSymbolicLink()) {
|
|
3062
|
+
groupOrOtherReadable = (st.mode & 63) !== 0;
|
|
3063
|
+
}
|
|
3064
|
+
} catch {
|
|
3065
|
+
configExists = false;
|
|
3066
|
+
}
|
|
3067
|
+
let file = null;
|
|
3068
|
+
try {
|
|
3069
|
+
file = await findUserConfig();
|
|
3070
|
+
} catch {
|
|
3071
|
+
file = null;
|
|
3072
|
+
}
|
|
3073
|
+
const keys = resolveImageKeys({ file: file?.config ?? null, env });
|
|
3074
|
+
const providers = ["pexels", "pixabay"].map((provider) => ({
|
|
3075
|
+
provider,
|
|
3076
|
+
present: Boolean(keys[provider].apiKey),
|
|
3077
|
+
source: keys[provider].source
|
|
3078
|
+
}));
|
|
3079
|
+
providers.push({
|
|
3080
|
+
provider: "openverse",
|
|
3081
|
+
present: keys.openverse.ready,
|
|
3082
|
+
source: keys.openverse.ready ? keys.openverse.source : null
|
|
3083
|
+
});
|
|
3084
|
+
return { configPath, configExists, groupOrOtherReadable, providers };
|
|
3085
|
+
}
|
|
3086
|
+
async function inspectGenerators(env, run) {
|
|
3087
|
+
let file = null;
|
|
3088
|
+
try {
|
|
3089
|
+
file = await findUserConfig();
|
|
3090
|
+
} catch {
|
|
3091
|
+
file = null;
|
|
3092
|
+
}
|
|
3093
|
+
const flags = resolveGenerators({ file: file?.config ?? null });
|
|
3094
|
+
return probeGenerators({ env, run, enabled: flags.enabled });
|
|
3095
|
+
}
|
|
3096
|
+
async function runSelfTest() {
|
|
3097
|
+
const started = performance.now();
|
|
3098
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
3099
|
+
try {
|
|
3100
|
+
const v = validateIr(SELF_TEST_DECK);
|
|
3101
|
+
if (!v.ok) throw new Error(`the built-in self-test deck failed validation:
|
|
3102
|
+
${formatIssues(v.errors)}`);
|
|
3103
|
+
const svg = renderSlideSvg(v.ir, 0);
|
|
3104
|
+
if (!svg.includes("<svg")) throw new Error("the SVG renderer produced no <svg> element");
|
|
3105
|
+
const bytes = await generatePptx(v.ir);
|
|
3106
|
+
if (bytes.length === 0) throw new Error("the PPTX generator produced zero bytes");
|
|
3107
|
+
return { ok: true, elapsedMs: elapsed(), slides: v.ir.slides.length, bytes: bytes.length };
|
|
3108
|
+
} catch (e) {
|
|
3109
|
+
return {
|
|
3110
|
+
ok: false,
|
|
3111
|
+
elapsedMs: elapsed(),
|
|
3112
|
+
slides: SELF_TEST_DECK.slides.length,
|
|
3113
|
+
bytes: null,
|
|
3114
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
function skillRefreshCommand(copyDir) {
|
|
3119
|
+
return `rm -rf /tmp/pptwise-src && git clone --depth 1 https://github.com/liustack/pptwise.git /tmp/pptwise-src && cp -R /tmp/pptwise-src/skills/${SKILL_DIR_NAME}/. ${copyDir}/`;
|
|
3120
|
+
}
|
|
3121
|
+
async function buildDoctorReport(input = {}) {
|
|
3122
|
+
const version = input.version ?? VERSION;
|
|
3123
|
+
const home = input.home ?? homedir();
|
|
3124
|
+
const env = input.env ?? process.env;
|
|
3125
|
+
const nodeVersion = input.nodeVersion ?? process.version;
|
|
3126
|
+
const cwd = input.cwd ?? process.cwd();
|
|
3127
|
+
const [skills, dsh, capabilities, selfTest, projectHit, images2, generators] = await Promise.all([
|
|
3128
|
+
scanSkillCopies(home, version),
|
|
3129
|
+
inspectDsh(home, version),
|
|
3130
|
+
inspectCapabilities(env),
|
|
3131
|
+
runSelfTest(),
|
|
3132
|
+
findConfig(cwd),
|
|
3133
|
+
inspectImages(env),
|
|
3134
|
+
inspectGenerators(env, input.runProcess)
|
|
3135
|
+
]);
|
|
3136
|
+
const workspace = await inspectWorkspace({
|
|
3137
|
+
cwd,
|
|
3138
|
+
projectConfigPath: projectHit?.path,
|
|
3139
|
+
outDir: projectHit?.config.outDir,
|
|
3140
|
+
runGit: input.runGit
|
|
3141
|
+
});
|
|
3142
|
+
let meetsMinimum = true;
|
|
3143
|
+
try {
|
|
3144
|
+
meetsMinimum = compareVersions(nodeVersion, MIN_NODE) >= 0;
|
|
3145
|
+
} catch {
|
|
3146
|
+
meetsMinimum = true;
|
|
3147
|
+
}
|
|
3148
|
+
const runtime = {
|
|
3149
|
+
node: nodeVersion,
|
|
3150
|
+
bun: process.versions.bun ?? null,
|
|
3151
|
+
minimum: MIN_NODE,
|
|
3152
|
+
meetsMinimum
|
|
3153
|
+
};
|
|
3154
|
+
const errors = [];
|
|
3155
|
+
const warnings = [];
|
|
3156
|
+
if (!runtime.meetsMinimum) {
|
|
3157
|
+
errors.push({
|
|
3158
|
+
check: "runtime",
|
|
3159
|
+
message: `Node ${runtime.node} is below the ${MIN_NODE} floor pptwise needs`,
|
|
3160
|
+
fix: `install Node ${MIN_NODE}+ from https://nodejs.org (or \`nvm install 22\`), open a new shell, then re-run \`pptwise doctor\``
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
if (!selfTest.ok) {
|
|
3164
|
+
errors.push({
|
|
3165
|
+
check: "self-test",
|
|
3166
|
+
message: `the self-test render failed: ${selfTest.error}`,
|
|
3167
|
+
fix: `reinstall the CLI: npm install -g ${PACKAGE_NAME}`
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
for (const copy of skills.copies) {
|
|
3171
|
+
if (copy.legacy) {
|
|
3172
|
+
warnings.push({
|
|
3173
|
+
check: "skill copy",
|
|
3174
|
+
message: `${copy.path} is a leftover ${basename5(copy.path)} skill copy`,
|
|
3175
|
+
fix: `remove it and install the pptwise skill in ${join7(copy.root, SKILL_DIR_NAME)}`
|
|
3176
|
+
});
|
|
3177
|
+
} else if (copy.stale) {
|
|
3178
|
+
warnings.push({
|
|
3179
|
+
check: "skill copy",
|
|
3180
|
+
message: `${copy.path} pins ${copy.pinned}, behind this CLI's ${version}`,
|
|
3181
|
+
fix: `re-run the install to overwrite the copy in place: ${skillRefreshCommand(copy.path)}`
|
|
3182
|
+
});
|
|
3183
|
+
} else if (copy.pinned === null) {
|
|
3184
|
+
warnings.push({
|
|
3185
|
+
check: "skill copy",
|
|
3186
|
+
message: `${copy.path} has ${copy.launcher === null ? "no scripts/run.sh" : "a scripts/run.sh with no PINNED line"} \u2014 version unknown, so drift cannot be ruled out`,
|
|
3187
|
+
fix: `re-run the install to restore a complete copy: ${skillRefreshCommand(copy.path)}`
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
if (copy.missing.length > 0) {
|
|
3191
|
+
warnings.push({
|
|
3192
|
+
check: "skill copy",
|
|
3193
|
+
message: `${copy.path} is missing ${copy.missing.join(", ")}`,
|
|
3194
|
+
fix: `re-run the install to restore a complete copy: ${skillRefreshCommand(copy.path)}`
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
for (const profile of dsh.profiles) {
|
|
3199
|
+
if (profile.stale) {
|
|
3200
|
+
warnings.push({
|
|
3201
|
+
check: "dsh plugin",
|
|
3202
|
+
message: `profile "${profile.name}" has ${PACKAGE_NAME} ${profile.version}, behind this CLI's ${version}`,
|
|
3203
|
+
fix: dshInstallCommand(profile.name, version)
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
for (const capability of capabilities) {
|
|
3208
|
+
if (!capability.available) {
|
|
3209
|
+
warnings.push({ check: "capability", message: `${capability.name}: ${capability.detail}`, fix: capability.fix });
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
if (images2.groupOrOtherReadable) {
|
|
3213
|
+
warnings.push({
|
|
3214
|
+
check: "images",
|
|
3215
|
+
message: `${images2.configPath} is group/other-readable \u2014 API keys should be mode 0600`,
|
|
3216
|
+
fix: `chmod 600 ${images2.configPath}`
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
return { version, runtime, skills, dsh, capabilities, selfTest, workspace, images: images2, generators, errors, warnings };
|
|
3220
|
+
}
|
|
3221
|
+
function mark(state) {
|
|
3222
|
+
return state === "ok" ? "[ok]" : state === "warn" ? "[!]" : state === "fail" ? "[!!]" : "[-]";
|
|
3223
|
+
}
|
|
3224
|
+
function renderDoctorReport(report) {
|
|
3225
|
+
const lines = [];
|
|
3226
|
+
lines.push(`pptwise doctor \u2014 CLI ${report.version}`);
|
|
3227
|
+
lines.push("(local diagnostics only: nothing is written, no network call is made)");
|
|
3228
|
+
lines.push("");
|
|
3229
|
+
lines.push("Installed skill copies (a copy keeps its install-time version forever)");
|
|
3230
|
+
if (report.skills.copies.length === 0) {
|
|
3231
|
+
lines.push(" [-] no installed skill copy found \u2014 the CLI runs fine on its own, and dsh ships the");
|
|
3232
|
+
lines.push(" skill inside the plugin instead");
|
|
3233
|
+
lines.push(` looked in: ${report.skills.scanned.join(", ")}`);
|
|
3234
|
+
} else {
|
|
3235
|
+
for (const copy of report.skills.copies) {
|
|
3236
|
+
const state = copy.legacy || copy.stale || copy.pinned === null || copy.missing.length > 0 ? "warn" : "ok";
|
|
3237
|
+
const pin = copy.legacy ? `leftover ${basename5(copy.path)} copy` : copy.pinned === null ? "version unknown" : `pins ${copy.pinned}`;
|
|
3238
|
+
lines.push(` ${mark(state)} ${copy.harness}: ${copy.path} \u2014 ${pin}${copy.stale && !copy.legacy ? " (stale)" : ""}`);
|
|
3239
|
+
if (copy.legacy) {
|
|
3240
|
+
lines.push(` fix: remove it and install the pptwise skill in ${join7(copy.root, SKILL_DIR_NAME)}`);
|
|
3241
|
+
} else if (copy.stale) {
|
|
3242
|
+
lines.push(` fix: ${skillRefreshCommand(copy.path)}`);
|
|
3243
|
+
} else if (copy.pinned === null) {
|
|
3244
|
+
lines.push(` ${copy.launcher === null ? "no scripts/run.sh in this copy" : "scripts/run.sh carries no PINNED line"}`);
|
|
3245
|
+
lines.push(` fix: ${skillRefreshCommand(copy.path)}`);
|
|
3246
|
+
}
|
|
3247
|
+
if (copy.missing.length > 0) {
|
|
3248
|
+
lines.push(` missing: ${copy.missing.join(", ")}`);
|
|
3249
|
+
lines.push(` fix: ${skillRefreshCommand(copy.path)}`);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
lines.push("");
|
|
3254
|
+
lines.push("DSH plugin");
|
|
3255
|
+
if (!report.dsh.applicable) {
|
|
3256
|
+
lines.push(` ${mark("n/a")} no ${report.dsh.home} \u2014 not a dsh machine, this check does not apply`);
|
|
3257
|
+
} else if (report.dsh.profiles.length === 0) {
|
|
3258
|
+
lines.push(` ${mark("n/a")} ${report.dsh.home} exists but has no profiles to inspect`);
|
|
3259
|
+
} else {
|
|
3260
|
+
for (const profile of report.dsh.profiles) {
|
|
3261
|
+
if (!profile.installed) {
|
|
3262
|
+
lines.push(` ${mark("n/a")} ${profile.name}: ${PACKAGE_NAME} not installed in this profile`);
|
|
3263
|
+
continue;
|
|
3264
|
+
}
|
|
3265
|
+
const version = profile.version ?? "version unknown";
|
|
3266
|
+
lines.push(` ${mark(profile.stale ? "warn" : "ok")} ${profile.name}: ${version}${profile.source ? ` (via ${profile.source})` : ""}${profile.stale ? " (behind this CLI)" : ""}`);
|
|
3267
|
+
if (profile.stale) {
|
|
3268
|
+
lines.push(` fix: ${dshInstallCommand(profile.name, report.version)}`);
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
lines.push("");
|
|
3273
|
+
lines.push("Runtime");
|
|
3274
|
+
lines.push(` ${mark(report.runtime.meetsMinimum ? "ok" : "fail")} Node ${report.runtime.node} (minimum ${report.runtime.minimum})`);
|
|
3275
|
+
if (report.runtime.bun !== null) {
|
|
3276
|
+
lines.push(` ${mark("ok")} running under Bun ${report.runtime.bun}`);
|
|
3277
|
+
}
|
|
3278
|
+
lines.push("");
|
|
3279
|
+
lines.push("Optional capabilities (neither is needed by the main flow)");
|
|
3280
|
+
for (const capability of report.capabilities) {
|
|
3281
|
+
lines.push(` ${mark(capability.available ? "ok" : "warn")} ${capability.name}: ${capability.detail}`);
|
|
3282
|
+
if (capability.fix) lines.push(` fix: ${capability.fix}`);
|
|
3283
|
+
}
|
|
3284
|
+
lines.push("");
|
|
3285
|
+
lines.push("Self-test render (a built-in deck through the real pipeline, in memory)");
|
|
3286
|
+
lines.push(
|
|
3287
|
+
report.selfTest.ok ? ` ${mark("ok")} ${report.selfTest.slides} slides validated, rendered, and packed into ${report.selfTest.bytes} bytes in ${report.selfTest.elapsedMs}ms` : ` ${mark("fail")} failed after ${report.selfTest.elapsedMs}ms: ${report.selfTest.error}`
|
|
3288
|
+
);
|
|
3289
|
+
lines.push("");
|
|
3290
|
+
lines.push("Workspace artifacts (where render/preview write when -o is omitted)");
|
|
3291
|
+
lines.push(` ${mark("ok")} anchor ${report.workspace.anchor}`);
|
|
3292
|
+
lines.push(
|
|
3293
|
+
report.workspace.configured ? ` ${mark("ok")} output ${report.workspace.root} (from pptwise.config.json outDir)` : ` ${mark("ok")} output ${report.workspace.root}`
|
|
3294
|
+
);
|
|
3295
|
+
if (report.workspace.ignore === "ignored") {
|
|
3296
|
+
lines.push(` ${mark("ok")} git-ignored`);
|
|
3297
|
+
} else if (report.workspace.ignore === "not-ignored") {
|
|
3298
|
+
lines.push(` ${mark("n/a")} not git-ignored \u2014 the first render will add a local exclude line`);
|
|
3299
|
+
} else if (report.workspace.ignore === "skipped") {
|
|
3300
|
+
lines.push(` ${mark("n/a")} git-ignore skipped (outDir is set in pptwise.config.json)`);
|
|
3301
|
+
} else {
|
|
3302
|
+
lines.push(` ${mark("n/a")} not a git repository`);
|
|
3303
|
+
}
|
|
3304
|
+
lines.push("");
|
|
3305
|
+
lines.push("Images (optional stock-photo search \u2014 rendering PPTX still needs no credentials)");
|
|
3306
|
+
lines.push(` ${mark("ok")} config ${report.images.configPath}${report.images.configExists ? "" : " (missing)"}`);
|
|
3307
|
+
if (report.images.groupOrOtherReadable) {
|
|
3308
|
+
lines.push(` ${mark("warn")} file is group/other-readable \u2014 chmod 600`);
|
|
3309
|
+
}
|
|
3310
|
+
for (const provider of report.images.providers) {
|
|
3311
|
+
if (provider.present) {
|
|
3312
|
+
lines.push(` ${mark("ok")} ${provider.provider}: present (${provider.source})`);
|
|
3313
|
+
} else if (provider.provider === "openverse") {
|
|
3314
|
+
lines.push(` ${mark("n/a")} openverse: missing \u2014 pptwise config set openverse.clientId`);
|
|
3315
|
+
} else {
|
|
3316
|
+
lines.push(` ${mark("n/a")} ${provider.provider}: missing \u2014 pptwise config set ${provider.provider}.apiKey`);
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
lines.push("");
|
|
3320
|
+
lines.push("Image generators (optional, off until enabled)");
|
|
3321
|
+
for (const gen of report.generators) {
|
|
3322
|
+
if (!gen.found) {
|
|
3323
|
+
lines.push(` ${mark("n/a")} ${gen.id}: not found`);
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
const ver = gen.version ? `, ${gen.version}` : "";
|
|
3327
|
+
const state = gen.enabled ? "enabled" : "disabled";
|
|
3328
|
+
const hint = gen.enabled ? "" : ` \u2014 pptwise config set images.generators.${gen.id}.enabled true`;
|
|
3329
|
+
lines.push(` ${mark("ok")} ${gen.id}: found (${gen.bin}${ver}) ${state}${hint}`);
|
|
3330
|
+
}
|
|
3331
|
+
lines.push("");
|
|
3332
|
+
lines.push(
|
|
3333
|
+
`${report.errors.length} error${report.errors.length === 1 ? "" : "s"}, ${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"}`
|
|
3334
|
+
);
|
|
3335
|
+
for (const error of report.errors) {
|
|
3336
|
+
lines.push(` [!!] ${error.check}: ${error.message}`);
|
|
3337
|
+
if (error.fix) lines.push(` fix: ${error.fix}`);
|
|
3338
|
+
}
|
|
3339
|
+
for (const warning of report.warnings) {
|
|
3340
|
+
lines.push(` [!] ${warning.check}: ${warning.message}`);
|
|
3341
|
+
}
|
|
3342
|
+
if (report.errors.length === 0) {
|
|
3343
|
+
lines.push(report.warnings.length === 0 ? "pptwise is healthy on this machine" : "nothing blocking \u2014 the warnings above are worth fixing when convenient");
|
|
3344
|
+
}
|
|
3345
|
+
return lines.join("\n");
|
|
3346
|
+
}
|
|
3347
|
+
async function runDoctor(opts = {}) {
|
|
3348
|
+
const report = await buildDoctorReport(opts);
|
|
3349
|
+
return {
|
|
3350
|
+
output: opts.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report),
|
|
3351
|
+
hasErrors: report.errors.length > 0
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
// src/cli/images.ts
|
|
3356
|
+
import { mkdir as mkdir5, mkdtemp, readFile as readFile8, readdir as readdir6, rename, rm as rm2, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
|
|
3357
|
+
import { tmpdir } from "os";
|
|
3358
|
+
import { dirname as dirname4, join as join8, resolve as resolve7 } from "path";
|
|
3359
|
+
|
|
3360
|
+
// src/cli/redact.ts
|
|
3361
|
+
var KEY_QUERY = /([?&](?:api_)?key=)[^&\s"'<>]*/gi;
|
|
3362
|
+
var CLIENT_SECRET_QUERY = /(client_secret=)[^&\s"'<>]*/gi;
|
|
3363
|
+
function redactSecrets(text, knownSecrets = []) {
|
|
3364
|
+
let out = text;
|
|
3365
|
+
const secrets = knownSecrets.filter((s) => s.length >= 6).sort((a, b) => b.length - a.length);
|
|
3366
|
+
for (const secret of secrets) {
|
|
3367
|
+
out = out.split(secret).join("[redacted]");
|
|
3368
|
+
}
|
|
3369
|
+
out = out.replace(KEY_QUERY, "$1[redacted]");
|
|
3370
|
+
out = out.replace(CLIENT_SECRET_QUERY, "$1[redacted]");
|
|
3371
|
+
return out;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// src/cli/image-openverse.ts
|
|
3375
|
+
var OPENVERSE_SEARCH_URL = "https://api.openverse.org/v1/images/";
|
|
3376
|
+
var OPENVERSE_TOKEN_URL = "https://api.openverse.org/v1/auth_tokens/token/";
|
|
3377
|
+
var OPENVERSE_PAGE_SIZE = 8;
|
|
3378
|
+
var TOKEN_SKEW_MS = 3e4;
|
|
3379
|
+
var defaultSleep = (ms) => new Promise((resolve8) => setTimeout(resolve8, ms));
|
|
3380
|
+
var tokenCache = /* @__PURE__ */ new Map();
|
|
3381
|
+
function userAgent() {
|
|
3382
|
+
return `pptwise/${VERSION} (+https://pptwise.com)`;
|
|
3383
|
+
}
|
|
3384
|
+
function asRecord(value) {
|
|
3385
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
3386
|
+
return value;
|
|
3387
|
+
}
|
|
3388
|
+
return null;
|
|
3389
|
+
}
|
|
3390
|
+
function asString(value) {
|
|
3391
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
3392
|
+
}
|
|
3393
|
+
function asNumber(value) {
|
|
3394
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3395
|
+
}
|
|
3396
|
+
function isCc0OrPdm(license) {
|
|
3397
|
+
const token = (license ?? "").toLowerCase();
|
|
3398
|
+
return token === "cc0" || token === "pdm";
|
|
3399
|
+
}
|
|
3400
|
+
function retryDelayMs(res, attempt) {
|
|
3401
|
+
const raw = res.headers.get("Retry-After");
|
|
3402
|
+
if (raw !== null && raw !== "") {
|
|
3403
|
+
const seconds = Number(raw);
|
|
3404
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
|
|
3405
|
+
}
|
|
3406
|
+
return attempt === 0 ? 500 : 1500;
|
|
3407
|
+
}
|
|
3408
|
+
async function fetchOpenverseJson(url, init, fetchImpl, secrets, sleep) {
|
|
3409
|
+
let attempt = 0;
|
|
3410
|
+
while (true) {
|
|
3411
|
+
let res;
|
|
3412
|
+
try {
|
|
3413
|
+
res = await fetchImpl(url, init);
|
|
3414
|
+
} catch (e) {
|
|
3415
|
+
throw new PptwiseError(
|
|
3416
|
+
redactSecrets(`stock image request failed: ${e instanceof Error ? e.message : String(e)}`, secrets)
|
|
3417
|
+
);
|
|
3418
|
+
}
|
|
3419
|
+
const text = await res.text();
|
|
3420
|
+
if (res.status === 429) {
|
|
3421
|
+
if (attempt >= 2) {
|
|
3422
|
+
throw new PptwiseError(
|
|
3423
|
+
`stock image API rate-limited (HTTP 429): ${redactSecrets(text.slice(0, 800), secrets)}`
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
await sleep(retryDelayMs(res, attempt));
|
|
3427
|
+
attempt += 1;
|
|
3428
|
+
continue;
|
|
3429
|
+
}
|
|
3430
|
+
if (!res.ok) {
|
|
3431
|
+
throw new PptwiseError(`stock image API HTTP ${res.status}: ${redactSecrets(text.slice(0, 800), secrets)}`);
|
|
3432
|
+
}
|
|
3433
|
+
try {
|
|
3434
|
+
return JSON.parse(text);
|
|
3435
|
+
} catch {
|
|
3436
|
+
throw new PptwiseError(`stock image API returned non-JSON (HTTP ${res.status})`);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
async function getOpenverseAccessToken(clientId, clientSecret, fetchImpl, secrets, sleep, now = Date.now) {
|
|
3441
|
+
const cached = tokenCache.get(clientId);
|
|
3442
|
+
if (cached && now() < cached.expiresAt - TOKEN_SKEW_MS) return cached.accessToken;
|
|
3443
|
+
const body = new URLSearchParams({
|
|
3444
|
+
grant_type: "client_credentials",
|
|
3445
|
+
client_id: clientId,
|
|
3446
|
+
client_secret: clientSecret
|
|
3447
|
+
}).toString();
|
|
3448
|
+
const json = await fetchOpenverseJson(
|
|
3449
|
+
OPENVERSE_TOKEN_URL,
|
|
3450
|
+
{
|
|
3451
|
+
method: "POST",
|
|
3452
|
+
headers: {
|
|
3453
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3454
|
+
"User-Agent": userAgent()
|
|
3455
|
+
},
|
|
3456
|
+
body
|
|
3457
|
+
},
|
|
3458
|
+
fetchImpl,
|
|
3459
|
+
secrets,
|
|
3460
|
+
sleep
|
|
3461
|
+
);
|
|
3462
|
+
const rec = asRecord(json);
|
|
3463
|
+
const accessToken = asString(rec?.access_token);
|
|
3464
|
+
const expiresIn = asNumber(rec?.expires_in) ?? 3600;
|
|
3465
|
+
if (!accessToken) throw new PptwiseError("Openverse token response was missing access_token");
|
|
3466
|
+
tokenCache.set(clientId, { accessToken, expiresAt: now() + expiresIn * 1e3 });
|
|
3467
|
+
return accessToken;
|
|
3468
|
+
}
|
|
3469
|
+
async function authHeaders(creds, fetchImpl, secrets, sleep) {
|
|
3470
|
+
const headers = { "User-Agent": userAgent() };
|
|
3471
|
+
if (creds.clientId && creds.clientSecret) {
|
|
3472
|
+
const token = await getOpenverseAccessToken(creds.clientId, creds.clientSecret, fetchImpl, secrets, sleep);
|
|
3473
|
+
headers.Authorization = `Bearer ${token}`;
|
|
3474
|
+
}
|
|
3475
|
+
return headers;
|
|
3476
|
+
}
|
|
3477
|
+
function parseHit(raw) {
|
|
3478
|
+
const photo = asRecord(raw);
|
|
3479
|
+
if (!photo) return null;
|
|
3480
|
+
const id = asString(photo.id);
|
|
3481
|
+
const url = asString(photo.url);
|
|
3482
|
+
if (!id || !url) return null;
|
|
3483
|
+
const license = (asString(photo.license) ?? "").toLowerCase();
|
|
3484
|
+
if (!isCc0OrPdm(license)) return null;
|
|
3485
|
+
const source = asString(photo.source) ?? asString(photo.provider) ?? "openverse";
|
|
3486
|
+
return {
|
|
3487
|
+
id,
|
|
3488
|
+
url,
|
|
3489
|
+
foreignLandingUrl: asString(photo.foreign_landing_url) ?? "",
|
|
3490
|
+
creator: asString(photo.creator) ?? "unknown",
|
|
3491
|
+
license,
|
|
3492
|
+
attribution: asString(photo.attribution) ?? "",
|
|
3493
|
+
source,
|
|
3494
|
+
width: asNumber(photo.width) ?? 0,
|
|
3495
|
+
height: asNumber(photo.height) ?? 0,
|
|
3496
|
+
thumbnail: asString(photo.thumbnail) ?? ""
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
function matchesOrientation(hit, orientation) {
|
|
3500
|
+
if (!orientation) return true;
|
|
3501
|
+
if (orientation === "landscape") return hit.width > hit.height;
|
|
3502
|
+
if (orientation === "portrait") return hit.height > hit.width;
|
|
3503
|
+
return hit.width === hit.height;
|
|
3504
|
+
}
|
|
3505
|
+
async function searchOpenverse(opts) {
|
|
3506
|
+
const url = new URL(OPENVERSE_SEARCH_URL);
|
|
3507
|
+
url.searchParams.set("q", opts.query);
|
|
3508
|
+
url.searchParams.set("license_type", "commercial");
|
|
3509
|
+
url.searchParams.set("license", "cc0,pdm");
|
|
3510
|
+
url.searchParams.set("page_size", String(OPENVERSE_PAGE_SIZE));
|
|
3511
|
+
const headers = await authHeaders(
|
|
3512
|
+
{ clientId: opts.clientId, clientSecret: opts.clientSecret },
|
|
3513
|
+
opts.fetch,
|
|
3514
|
+
opts.secrets,
|
|
3515
|
+
opts.sleep
|
|
3516
|
+
);
|
|
3517
|
+
const json = await fetchOpenverseJson(url.toString(), { headers }, opts.fetch, opts.secrets, opts.sleep);
|
|
3518
|
+
const root = asRecord(json);
|
|
3519
|
+
const list = Array.isArray(root?.results) ? root.results : [];
|
|
3520
|
+
const hits = [];
|
|
3521
|
+
for (const raw of list) {
|
|
3522
|
+
const hit = parseHit(raw);
|
|
3523
|
+
if (!hit) continue;
|
|
3524
|
+
if (opts.minWidth !== void 0 && hit.width < opts.minWidth) continue;
|
|
3525
|
+
if (opts.minHeight !== void 0 && hit.height < opts.minHeight) continue;
|
|
3526
|
+
if (!matchesOrientation(hit, opts.orientation)) continue;
|
|
3527
|
+
hits.push(hit);
|
|
3528
|
+
if (hits.length >= OPENVERSE_PAGE_SIZE) break;
|
|
3529
|
+
}
|
|
3530
|
+
return hits;
|
|
3531
|
+
}
|
|
3532
|
+
async function loadOpenverseDetail(id, creds, fetchImpl, secrets, sleep) {
|
|
3533
|
+
const url = `${OPENVERSE_SEARCH_URL}${encodeURIComponent(id)}/`;
|
|
3534
|
+
const headers = await authHeaders(creds, fetchImpl, secrets, sleep);
|
|
3535
|
+
const json = await fetchOpenverseJson(url, { headers }, fetchImpl, secrets, sleep);
|
|
3536
|
+
const license = asString(asRecord(json)?.license);
|
|
3537
|
+
if (!isCc0OrPdm(license)) {
|
|
3538
|
+
throw new PptwiseError(
|
|
3539
|
+
`Openverse photo ${id} is licensed "${license ?? "unknown"}", not cc0/pdm \u2014 refusing to download`
|
|
3540
|
+
);
|
|
3541
|
+
}
|
|
3542
|
+
const hit = parseHit(json);
|
|
3543
|
+
if (!hit) throw new PptwiseError(`Openverse photo ${id} was not found`);
|
|
3544
|
+
return hit;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
// src/cli/ssrf.ts
|
|
3548
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
3549
|
+
import { isIP } from "net";
|
|
3550
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
3551
|
+
async function defaultDnsLookup(hostname) {
|
|
3552
|
+
return dnsLookup(hostname, { all: true, verbatim: true });
|
|
3553
|
+
}
|
|
3554
|
+
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
3555
|
+
"localhost",
|
|
3556
|
+
"localhost.localdomain",
|
|
3557
|
+
"metadata.google.internal",
|
|
3558
|
+
"metadata.amazonaws.com",
|
|
3559
|
+
"metadata.azure.internal"
|
|
3560
|
+
]);
|
|
3561
|
+
function isBlockedHostname(hostname) {
|
|
3562
|
+
const normalized = hostname.trim().toLowerCase();
|
|
3563
|
+
if (!normalized) return true;
|
|
3564
|
+
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
|
|
3565
|
+
if (normalized.endsWith(".localhost")) return true;
|
|
3566
|
+
return false;
|
|
3567
|
+
}
|
|
3568
|
+
function isPrivateIpAddress(ipAddress) {
|
|
3569
|
+
const normalized = ipAddress.trim().toLowerCase();
|
|
3570
|
+
const family = isIP(normalized);
|
|
3571
|
+
if (family === 4) return isPrivateIPv4(normalized);
|
|
3572
|
+
if (family === 6) return isPrivateIPv6(normalized);
|
|
3573
|
+
return true;
|
|
3574
|
+
}
|
|
3575
|
+
async function assertSafeRemoteTarget(url, lookup) {
|
|
3576
|
+
if (isBlockedHostname(url.hostname)) {
|
|
3577
|
+
throw new Error(blockedMessage(url.hostname));
|
|
3578
|
+
}
|
|
3579
|
+
const hostname = stripIpv6Brackets(url.hostname);
|
|
3580
|
+
const ipFamily = isIP(hostname);
|
|
3581
|
+
if (ipFamily > 0) {
|
|
3582
|
+
if (isPrivateIpAddress(hostname)) throw new Error(blockedMessage(hostname));
|
|
3583
|
+
return { hostname, address: hostname, family: ipFamily };
|
|
3584
|
+
}
|
|
3585
|
+
if (!lookup) return { hostname, address: hostname, family: 0 };
|
|
3586
|
+
let resolved;
|
|
3587
|
+
try {
|
|
3588
|
+
resolved = await lookup(hostname);
|
|
3589
|
+
} catch (error) {
|
|
3590
|
+
throw new Error(
|
|
3591
|
+
`DNS lookup failed for host ${hostname}: ${error instanceof Error ? error.message : String(error)}`
|
|
3592
|
+
);
|
|
3593
|
+
}
|
|
3594
|
+
if (resolved.length === 0) {
|
|
3595
|
+
throw new Error(`Host ${hostname} did not resolve to any IP address.`);
|
|
3596
|
+
}
|
|
3597
|
+
const blocked = resolved.find((record) => isPrivateIpAddress(record.address));
|
|
3598
|
+
if (blocked) throw new Error(blockedMessage(`${hostname} -> ${blocked.address}`));
|
|
3599
|
+
const [chosen] = resolved;
|
|
3600
|
+
return { hostname, address: chosen.address, family: chosen.family };
|
|
3601
|
+
}
|
|
3602
|
+
async function pinnedFetch(url, pin, init) {
|
|
3603
|
+
const dispatcher = new Agent({
|
|
3604
|
+
connect: {
|
|
3605
|
+
lookup: (_hostname, options, callback) => {
|
|
3606
|
+
const record = { address: pin.address, family: pin.family };
|
|
3607
|
+
if (options?.all) callback(null, [record]);
|
|
3608
|
+
else callback(null, pin.address, pin.family);
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
});
|
|
3612
|
+
try {
|
|
3613
|
+
const response = await undiciFetch(url, {
|
|
3614
|
+
...init,
|
|
3615
|
+
dispatcher
|
|
3616
|
+
});
|
|
3617
|
+
const buffered = Buffer.from(await response.arrayBuffer());
|
|
3618
|
+
await dispatcher.close();
|
|
3619
|
+
return new Response(buffered, {
|
|
3620
|
+
status: response.status,
|
|
3621
|
+
statusText: response.statusText,
|
|
3622
|
+
headers: response.headers
|
|
3623
|
+
});
|
|
3624
|
+
} catch (error) {
|
|
3625
|
+
await dispatcher.close().catch(() => {
|
|
3626
|
+
});
|
|
3627
|
+
throw error;
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
function blockedMessage(target) {
|
|
3631
|
+
return `Blocked private or reserved download target: ${target}. pptwise does not download from private addresses.`;
|
|
3632
|
+
}
|
|
3633
|
+
function stripIpv6Brackets(hostname) {
|
|
3634
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return hostname.slice(1, -1);
|
|
3635
|
+
return hostname;
|
|
3636
|
+
}
|
|
3637
|
+
function isPrivateIPv4(ipAddress) {
|
|
3638
|
+
const octets = ipAddress.split(".").map((part) => Number.parseInt(part, 10));
|
|
3639
|
+
if (octets.length !== 4 || octets.some((value2) => !Number.isFinite(value2) || value2 < 0 || value2 > 255)) {
|
|
3640
|
+
return true;
|
|
3641
|
+
}
|
|
3642
|
+
const value = octets[0] * 256 ** 3 + octets[1] * 256 ** 2 + octets[2] * 256 + octets[3];
|
|
3643
|
+
return inRange(value, "0.0.0.0", "0.255.255.255") || inRange(value, "10.0.0.0", "10.255.255.255") || inRange(value, "100.64.0.0", "100.127.255.255") || inRange(value, "127.0.0.0", "127.255.255.255") || inRange(value, "169.254.0.0", "169.254.255.255") || inRange(value, "172.16.0.0", "172.31.255.255") || inRange(value, "192.0.0.0", "192.0.0.255") || inRange(value, "192.168.0.0", "192.168.255.255") || inRange(value, "198.18.0.0", "198.19.255.255") || inRange(value, "224.0.0.0", "255.255.255.255");
|
|
3644
|
+
}
|
|
3645
|
+
function inRange(value, start, end) {
|
|
3646
|
+
return value >= ipv4ToNumber(start) && value <= ipv4ToNumber(end);
|
|
3647
|
+
}
|
|
3648
|
+
function ipv4ToNumber(ipAddress) {
|
|
3649
|
+
const octets = ipAddress.split(".").map((part) => Number.parseInt(part, 10));
|
|
3650
|
+
return octets[0] * 256 ** 3 + octets[1] * 256 ** 2 + octets[2] * 256 + octets[3];
|
|
3651
|
+
}
|
|
3652
|
+
function isPrivateIPv6(ipAddress) {
|
|
3653
|
+
const groups = expandIpv6(ipAddress);
|
|
3654
|
+
if (groups !== null && hasMappedV4Prefix(groups)) {
|
|
3655
|
+
const mapped2 = [groups[6] >> 8, groups[6] & 255, groups[7] >> 8, groups[7] & 255].join(".");
|
|
3656
|
+
return isPrivateIPv4(mapped2);
|
|
3657
|
+
}
|
|
3658
|
+
const normalized = ipAddress.split("%")[0];
|
|
3659
|
+
const mapped = extractMappedIpv4(normalized);
|
|
3660
|
+
if (mapped && isPrivateIPv4(mapped)) return true;
|
|
3661
|
+
const value = ipv6ToBigInt(normalized);
|
|
3662
|
+
if (value === null) return true;
|
|
3663
|
+
return inIpv6Range(value, "::", 128) || inIpv6Range(value, "::1", 128) || inIpv6Range(value, "fc00::", 7) || inIpv6Range(value, "fe80::", 10) || inIpv6Range(value, "ff00::", 8) || inIpv6Range(value, "2001:db8::", 32);
|
|
3664
|
+
}
|
|
3665
|
+
function hasMappedV4Prefix(groups) {
|
|
3666
|
+
return groups.slice(0, 5).every((group) => group === 0) && groups[5] === 65535;
|
|
3667
|
+
}
|
|
3668
|
+
function extractMappedIpv4(ipAddress) {
|
|
3669
|
+
const lower = ipAddress.toLowerCase();
|
|
3670
|
+
const marker = "::ffff:";
|
|
3671
|
+
if (!lower.startsWith(marker)) return null;
|
|
3672
|
+
const candidate = lower.slice(marker.length);
|
|
3673
|
+
return isIP(candidate) === 4 ? candidate : null;
|
|
3674
|
+
}
|
|
3675
|
+
function inIpv6Range(value, start, prefixLength) {
|
|
3676
|
+
const startValue = ipv6ToBigInt(start);
|
|
3677
|
+
if (startValue === null) return false;
|
|
3678
|
+
const mask = prefixLength === 0 ? 0n : (1n << BigInt(prefixLength)) - 1n << BigInt(128 - prefixLength);
|
|
3679
|
+
return (value & mask) === (startValue & mask);
|
|
3680
|
+
}
|
|
3681
|
+
function ipv6ToBigInt(ipAddress) {
|
|
3682
|
+
const expanded = expandIpv6(ipAddress);
|
|
3683
|
+
if (!expanded) return null;
|
|
3684
|
+
return expanded.reduce((acc, group) => (acc << 16n) + BigInt(group), 0n);
|
|
3685
|
+
}
|
|
3686
|
+
function expandIpv6(ipAddress) {
|
|
3687
|
+
const value = ipAddress.toLowerCase();
|
|
3688
|
+
if (value.includes("::")) {
|
|
3689
|
+
const [left, right] = value.split("::");
|
|
3690
|
+
const leftGroups = left ? left.split(":").filter(Boolean) : [];
|
|
3691
|
+
const rightGroups = right ? right.split(":").filter(Boolean) : [];
|
|
3692
|
+
if (leftGroups.length + rightGroups.length > 8) return null;
|
|
3693
|
+
const middle = new Array(8 - leftGroups.length - rightGroups.length).fill("0");
|
|
3694
|
+
return parseIpv6Groups([...leftGroups, ...middle, ...rightGroups]);
|
|
3695
|
+
}
|
|
3696
|
+
return parseIpv6Groups(value.split(":"));
|
|
3697
|
+
}
|
|
3698
|
+
function parseIpv6Groups(groups) {
|
|
3699
|
+
if (groups.length !== 8) return null;
|
|
3700
|
+
const parsed = groups.map((group) => Number.parseInt(group || "0", 16));
|
|
3701
|
+
if (parsed.some((value) => !Number.isFinite(value) || value < 0 || value > 65535)) return null;
|
|
3702
|
+
return parsed;
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// src/cli/images.ts
|
|
3706
|
+
var BYTE_CAP = 15 * 1024 * 1024;
|
|
3707
|
+
var MAX_LONG_EDGE = 1920;
|
|
3708
|
+
var PER_PAGE = 8;
|
|
3709
|
+
function userAgent2() {
|
|
3710
|
+
return `pptwise/${VERSION} (+https://pptwise.com)`;
|
|
3711
|
+
}
|
|
3712
|
+
function asRecord2(value) {
|
|
3713
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
3714
|
+
return value;
|
|
3715
|
+
}
|
|
3716
|
+
return null;
|
|
3717
|
+
}
|
|
3718
|
+
function asString2(value) {
|
|
3719
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
3720
|
+
}
|
|
3721
|
+
function asNumber2(value) {
|
|
3722
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3723
|
+
}
|
|
3724
|
+
async function loadKeys(env) {
|
|
3725
|
+
const hit = await findUserConfig();
|
|
3726
|
+
return resolveImageKeys({ file: hit?.config ?? null, env });
|
|
3727
|
+
}
|
|
3728
|
+
async function fetchJson(url, init, fetchImpl, secrets) {
|
|
3729
|
+
let res;
|
|
3730
|
+
try {
|
|
3731
|
+
res = await fetchImpl(url, init);
|
|
3732
|
+
} catch (e) {
|
|
3733
|
+
throw new PptwiseError(
|
|
3734
|
+
redactSecrets(`stock image request failed: ${e instanceof Error ? e.message : String(e)}`, secrets)
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
const text = await res.text();
|
|
3738
|
+
const redacted = redactSecrets(text.slice(0, 800), secrets);
|
|
3739
|
+
if (!res.ok) {
|
|
3740
|
+
if (res.status === 429) {
|
|
3741
|
+
throw new PptwiseError(`stock image API rate-limited (HTTP 429): ${redacted}`);
|
|
3742
|
+
}
|
|
3743
|
+
throw new PptwiseError(`stock image API HTTP ${res.status}: ${redacted}`);
|
|
3744
|
+
}
|
|
3745
|
+
try {
|
|
3746
|
+
return JSON.parse(text);
|
|
3747
|
+
} catch {
|
|
3748
|
+
throw new PptwiseError(`stock image API returned non-JSON (HTTP ${res.status})`);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
function pexelsHeaders(apiKey) {
|
|
3752
|
+
return { Authorization: apiKey, "User-Agent": userAgent2() };
|
|
3753
|
+
}
|
|
3754
|
+
var ORIENTATIONS = /* @__PURE__ */ new Set(["landscape", "portrait", "square"]);
|
|
3755
|
+
function parseOrientation(raw) {
|
|
3756
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
3757
|
+
if (!ORIENTATIONS.has(raw)) {
|
|
3758
|
+
throw new PptwiseError(`invalid --orientation "${raw}" \u2014 expected landscape, portrait, or square`);
|
|
3759
|
+
}
|
|
3760
|
+
return raw;
|
|
3761
|
+
}
|
|
3762
|
+
function clientFilter(hits, minWidth, minHeight) {
|
|
3763
|
+
return hits.filter((hit) => {
|
|
3764
|
+
if (minWidth !== void 0 && hit.width < minWidth) return false;
|
|
3765
|
+
if (minHeight !== void 0 && hit.height < minHeight) return false;
|
|
3766
|
+
return true;
|
|
3767
|
+
});
|
|
3768
|
+
}
|
|
3769
|
+
function parsePexelsPhotos(json) {
|
|
3770
|
+
const root = asRecord2(json);
|
|
3771
|
+
const photos = Array.isArray(root?.photos) ? root.photos : [];
|
|
3772
|
+
const hits = [];
|
|
3773
|
+
for (const raw of photos) {
|
|
3774
|
+
const photo = asRecord2(raw);
|
|
3775
|
+
if (!photo) continue;
|
|
3776
|
+
const id = photo.id;
|
|
3777
|
+
const photoId = typeof id === "number" || typeof id === "string" ? String(id) : "";
|
|
3778
|
+
if (!photoId) continue;
|
|
3779
|
+
const src = asRecord2(photo.src) ?? {};
|
|
3780
|
+
const author = asString2(photo.photographer) ?? "unknown";
|
|
3781
|
+
const pageUrl = asString2(photo.url) ?? `https://www.pexels.com/photo/${photoId}/`;
|
|
3782
|
+
hits.push({
|
|
3783
|
+
id: `pexels:${photoId}`,
|
|
3784
|
+
provider: "pexels",
|
|
3785
|
+
photoId,
|
|
3786
|
+
thumb: asString2(src.medium) ?? asString2(src.tiny) ?? "",
|
|
3787
|
+
width: asNumber2(photo.width) ?? 0,
|
|
3788
|
+
height: asNumber2(photo.height) ?? 0,
|
|
3789
|
+
author,
|
|
3790
|
+
license: "Pexels License",
|
|
3791
|
+
pageUrl,
|
|
3792
|
+
attribution: `Photo by ${author} on Pexels`
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
return hits.slice(0, PER_PAGE);
|
|
3796
|
+
}
|
|
3797
|
+
function parsePixabayHits(json) {
|
|
3798
|
+
const root = asRecord2(json);
|
|
3799
|
+
const list = Array.isArray(root?.hits) ? root.hits : [];
|
|
3800
|
+
const hits = [];
|
|
3801
|
+
for (const raw of list) {
|
|
3802
|
+
const photo = asRecord2(raw);
|
|
3803
|
+
if (!photo) continue;
|
|
3804
|
+
const photoId = photo.id !== void 0 ? String(photo.id) : "";
|
|
3805
|
+
if (!photoId) continue;
|
|
3806
|
+
const author = asString2(photo.user) ?? "unknown";
|
|
3807
|
+
const pageUrl = asString2(photo.pageURL) ?? `https://pixabay.com/photos/${photoId}/`;
|
|
3808
|
+
hits.push({
|
|
3809
|
+
id: `pixabay:${photoId}`,
|
|
3810
|
+
provider: "pixabay",
|
|
3811
|
+
photoId,
|
|
3812
|
+
thumb: asString2(photo.previewURL) ?? "",
|
|
3813
|
+
width: asNumber2(photo.imageWidth) ?? 0,
|
|
3814
|
+
height: asNumber2(photo.imageHeight) ?? 0,
|
|
3815
|
+
author,
|
|
3816
|
+
license: "Pixabay License",
|
|
3817
|
+
pageUrl,
|
|
3818
|
+
attribution: `Photo by ${author} on Pixabay`
|
|
3819
|
+
});
|
|
3820
|
+
}
|
|
3821
|
+
return hits.slice(0, PER_PAGE);
|
|
3822
|
+
}
|
|
3823
|
+
async function searchPexels(query, apiKey, opts, fetchImpl, secrets) {
|
|
3824
|
+
const url = new URL("https://api.pexels.com/v1/search");
|
|
3825
|
+
url.searchParams.set("query", query);
|
|
3826
|
+
url.searchParams.set("locale", "zh-CN");
|
|
3827
|
+
url.searchParams.set("per_page", String(PER_PAGE));
|
|
3828
|
+
const orientation = parseOrientation(opts.orientation);
|
|
3829
|
+
if (orientation) url.searchParams.set("orientation", orientation);
|
|
3830
|
+
if (opts.color) url.searchParams.set("color", opts.color);
|
|
3831
|
+
const json = await fetchJson(url.toString(), { headers: pexelsHeaders(apiKey) }, fetchImpl, secrets);
|
|
3832
|
+
return clientFilter(parsePexelsPhotos(json), opts.minWidth, opts.minHeight);
|
|
3833
|
+
}
|
|
3834
|
+
function pixabayOrientation(orientation) {
|
|
3835
|
+
if (orientation === "landscape") return "horizontal";
|
|
3836
|
+
if (orientation === "portrait") return "vertical";
|
|
3837
|
+
return void 0;
|
|
3838
|
+
}
|
|
3839
|
+
async function searchPixabay(query, apiKey, opts, fetchImpl, secrets) {
|
|
3840
|
+
const url = new URL("https://pixabay.com/api/");
|
|
3841
|
+
url.searchParams.set("key", apiKey);
|
|
3842
|
+
url.searchParams.set("q", query.slice(0, 100));
|
|
3843
|
+
url.searchParams.set("lang", "zh");
|
|
3844
|
+
url.searchParams.set("per_page", String(PER_PAGE));
|
|
3845
|
+
url.searchParams.set("safesearch", "true");
|
|
3846
|
+
const mapped = pixabayOrientation(parseOrientation(opts.orientation));
|
|
3847
|
+
if (mapped) url.searchParams.set("orientation", mapped);
|
|
3848
|
+
if (opts.color) url.searchParams.set("colors", opts.color);
|
|
3849
|
+
if (opts.minWidth !== void 0) url.searchParams.set("min_width", String(opts.minWidth));
|
|
3850
|
+
if (opts.minHeight !== void 0) url.searchParams.set("min_height", String(opts.minHeight));
|
|
3851
|
+
const json = await fetchJson(url.toString(), { headers: { "User-Agent": userAgent2() } }, fetchImpl, secrets);
|
|
3852
|
+
return parsePixabayHits(json);
|
|
3853
|
+
}
|
|
3854
|
+
function formatHits(hits) {
|
|
3855
|
+
return hits.map((hit) => {
|
|
3856
|
+
const thumb = hit.thumb ? `
|
|
3857
|
+
${hit.thumb}` : "";
|
|
3858
|
+
const source = hit.source ? ` ${hit.source}` : "";
|
|
3859
|
+
return `${hit.id} ${hit.width}x${hit.height} ${hit.author} ${hit.license}${source}${thumb}
|
|
3860
|
+
${hit.attribution}
|
|
3861
|
+
${hit.pageUrl}`;
|
|
3862
|
+
}).join("\n");
|
|
3863
|
+
}
|
|
3864
|
+
function openverseNotes(anonymous, pixabaySkipped) {
|
|
3865
|
+
const lines = ["Openverse does not verify individual licenses. Results are filtered to cc0/pdm."];
|
|
3866
|
+
if (anonymous) {
|
|
3867
|
+
lines.push(
|
|
3868
|
+
"Anonymous Openverse quota is very low. Set credentials with `pptwise config set openverse.clientId` and `pptwise config set openverse.clientSecret`."
|
|
3869
|
+
);
|
|
3870
|
+
}
|
|
3871
|
+
if (pixabaySkipped) {
|
|
3872
|
+
lines.push("Pixabay is unconfigured \u2014 pptwise config set pixabay.apiKey");
|
|
3873
|
+
}
|
|
3874
|
+
return lines;
|
|
3875
|
+
}
|
|
3876
|
+
async function runImagesSearch(query, opts = {}) {
|
|
3877
|
+
const q = query.trim();
|
|
3878
|
+
if (q === "") throw new PptwiseError("search query must not be empty");
|
|
3879
|
+
const env = opts.env ?? process.env;
|
|
3880
|
+
const keys = await loadKeys(env);
|
|
3881
|
+
const secrets = knownSecretsFrom(keys);
|
|
3882
|
+
const fetchImpl = opts.fetch ?? proxyFetch;
|
|
3883
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
3884
|
+
if (keys.pexels.apiKey) {
|
|
3885
|
+
const pexelsHits = await searchPexels(q, keys.pexels.apiKey, opts, fetchImpl, secrets);
|
|
3886
|
+
if (pexelsHits.length > 0) return formatHits(pexelsHits);
|
|
3887
|
+
}
|
|
3888
|
+
if (keys.pixabay.apiKey) {
|
|
3889
|
+
const pixabayHits = await searchPixabay(q, keys.pixabay.apiKey, opts, fetchImpl, secrets);
|
|
3890
|
+
if (pixabayHits.length > 0) return formatHits(pixabayHits);
|
|
3891
|
+
}
|
|
3892
|
+
const orientation = parseOrientation(opts.orientation);
|
|
3893
|
+
const ovHits = await searchOpenverse({
|
|
3894
|
+
query: q,
|
|
3895
|
+
orientation,
|
|
3896
|
+
minWidth: opts.minWidth,
|
|
3897
|
+
minHeight: opts.minHeight,
|
|
3898
|
+
clientId: keys.openverse.ready ? keys.openverse.clientId : void 0,
|
|
3899
|
+
clientSecret: keys.openverse.ready ? keys.openverse.clientSecret : void 0,
|
|
3900
|
+
fetch: fetchImpl,
|
|
3901
|
+
secrets,
|
|
3902
|
+
sleep
|
|
3903
|
+
});
|
|
3904
|
+
const notes = openverseNotes(!keys.openverse.ready, !keys.pixabay.apiKey);
|
|
3905
|
+
if (ovHits.length === 0) {
|
|
3906
|
+
return ["No photos found.", ...notes].join("\n");
|
|
3907
|
+
}
|
|
3908
|
+
const hits = ovHits.map((hit) => ({
|
|
3909
|
+
id: `openverse:${hit.id}`,
|
|
3910
|
+
provider: "openverse",
|
|
3911
|
+
photoId: hit.id,
|
|
3912
|
+
thumb: hit.thumbnail,
|
|
3913
|
+
width: hit.width,
|
|
3914
|
+
height: hit.height,
|
|
3915
|
+
author: hit.creator,
|
|
3916
|
+
license: hit.license,
|
|
3917
|
+
pageUrl: hit.foreignLandingUrl,
|
|
3918
|
+
attribution: hit.attribution,
|
|
3919
|
+
source: hit.source
|
|
3920
|
+
}));
|
|
3921
|
+
return [...notes, formatHits(hits)].join("\n");
|
|
3922
|
+
}
|
|
3923
|
+
function parsePhotoRef(ref) {
|
|
3924
|
+
const m = /^(pexels|pixabay|openverse):(.+)$/.exec(ref.trim());
|
|
3925
|
+
if (!m) {
|
|
3926
|
+
throw new PptwiseError(`invalid photo ref "${ref}" \u2014 expected pexels:<id>, pixabay:<id>, or openverse:<id>`);
|
|
3927
|
+
}
|
|
3928
|
+
const photoId = m[2].trim();
|
|
3929
|
+
if (photoId === "" || photoId.includes("/") || photoId.includes("\\") || photoId.includes("..")) {
|
|
3930
|
+
throw new PptwiseError(`invalid photo id in "${ref}"`);
|
|
3931
|
+
}
|
|
3932
|
+
return { provider: m[1], photoId };
|
|
3933
|
+
}
|
|
3934
|
+
async function resolveDeckWorkspace(deckArg, cwd) {
|
|
3935
|
+
const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
|
|
3936
|
+
const decksDirSource = projectHit?.config.decksDir !== void 0 ? { decksDir: resolve7(dirname4(projectHit.path), projectHit.config.decksDir) } : userHit?.config;
|
|
3937
|
+
const target = await resolveDeckTarget(deckArg, decksDirSource, cwd);
|
|
3938
|
+
const isDir = await isDeckDirectory(target);
|
|
3939
|
+
const location = resolveWorkspaceLocation({
|
|
3940
|
+
cwd,
|
|
3941
|
+
projectConfigPath: projectHit?.path,
|
|
3942
|
+
outDir: projectHit?.config.outDir,
|
|
3943
|
+
target,
|
|
3944
|
+
isDir
|
|
3945
|
+
});
|
|
3946
|
+
return { location, assetsDir: join8(location.dir, ASSETS_DIRNAME) };
|
|
3947
|
+
}
|
|
3948
|
+
function assertSafeDownloadUrl(url) {
|
|
3949
|
+
let parsed;
|
|
3950
|
+
try {
|
|
3951
|
+
parsed = new URL(url);
|
|
3952
|
+
} catch {
|
|
3953
|
+
throw new PptwiseError("invalid download URL");
|
|
3954
|
+
}
|
|
3955
|
+
if (parsed.protocol !== "https:") {
|
|
3956
|
+
throw new PptwiseError("refusing non-HTTPS download URL");
|
|
3957
|
+
}
|
|
3958
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
3959
|
+
throw new PptwiseError("refusing download URL with embedded userinfo");
|
|
3960
|
+
}
|
|
3961
|
+
return parsed;
|
|
3962
|
+
}
|
|
3963
|
+
async function downloadBytes(url, fetchImpl, secrets, lookup) {
|
|
3964
|
+
const parsed = assertSafeDownloadUrl(url);
|
|
3965
|
+
let pin;
|
|
3966
|
+
try {
|
|
3967
|
+
pin = await assertSafeRemoteTarget(parsed, lookup);
|
|
3968
|
+
} catch (e) {
|
|
3969
|
+
throw new PptwiseError(redactSecrets(e instanceof Error ? e.message : String(e), secrets));
|
|
3970
|
+
}
|
|
3971
|
+
let res;
|
|
3972
|
+
try {
|
|
3973
|
+
const init = { headers: { "User-Agent": userAgent2() } };
|
|
3974
|
+
res = fetchImpl ? await fetchImpl(parsed.toString(), init) : await pinnedFetch(parsed, pin, init);
|
|
3975
|
+
} catch (e) {
|
|
3976
|
+
throw new PptwiseError(
|
|
3977
|
+
redactSecrets(`download failed: ${e instanceof Error ? e.message : String(e)}`, secrets)
|
|
3978
|
+
);
|
|
3979
|
+
}
|
|
3980
|
+
if (!res.ok) {
|
|
3981
|
+
throw new PptwiseError(`download HTTP ${res.status}`);
|
|
3982
|
+
}
|
|
3983
|
+
const declared = Number(res.headers.get("content-length") ?? "0");
|
|
3984
|
+
if (declared > BYTE_CAP) {
|
|
3985
|
+
throw new PptwiseError(`download exceeds the ${BYTE_CAP} byte cap`);
|
|
3986
|
+
}
|
|
3987
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
3988
|
+
if (buf.byteLength > BYTE_CAP) {
|
|
3989
|
+
throw new PptwiseError(`download exceeds the ${BYTE_CAP} byte cap`);
|
|
3990
|
+
}
|
|
3991
|
+
return buf;
|
|
3992
|
+
}
|
|
3993
|
+
async function defaultResizeToJpeg(bytes, maxLongEdge) {
|
|
3994
|
+
let sharpMod;
|
|
3995
|
+
try {
|
|
3996
|
+
sharpMod = (await import("sharp")).default;
|
|
3997
|
+
} catch (e) {
|
|
3998
|
+
if (isMissingModuleError(e)) {
|
|
3999
|
+
throw new PptwiseError(`Resizing stock photos requires the optional dependency "sharp" (npm i sharp)`);
|
|
4000
|
+
}
|
|
4001
|
+
throw e;
|
|
4002
|
+
}
|
|
4003
|
+
const image = sharpMod(bytes);
|
|
4004
|
+
const meta = await image.metadata();
|
|
4005
|
+
const width = meta.width ?? 0;
|
|
4006
|
+
const height = meta.height ?? 0;
|
|
4007
|
+
const long = Math.max(width, height);
|
|
4008
|
+
const pipeline = long > maxLongEdge ? width >= height ? image.resize({ width: maxLongEdge }) : image.resize({ height: maxLongEdge }) : image;
|
|
4009
|
+
return pipeline.jpeg({ quality: 85 }).toBuffer();
|
|
4010
|
+
}
|
|
4011
|
+
async function toJpeg(bytes, apiLongEdge, resize) {
|
|
4012
|
+
const format = sniffImageFormat(bytes);
|
|
4013
|
+
if (format === null) {
|
|
4014
|
+
throw new PptwiseError("downloaded bytes are not a recognized image (png/jpeg/gif/webp)");
|
|
4015
|
+
}
|
|
4016
|
+
if (format === "jpeg" && apiLongEdge !== void 0 && apiLongEdge <= MAX_LONG_EDGE) {
|
|
4017
|
+
return bytes;
|
|
4018
|
+
}
|
|
4019
|
+
return resize(bytes, MAX_LONG_EDGE);
|
|
4020
|
+
}
|
|
4021
|
+
async function loadPexelsPhoto(photoId, apiKey, fetchImpl, secrets) {
|
|
4022
|
+
const url = `https://api.pexels.com/v1/photos/${encodeURIComponent(photoId)}`;
|
|
4023
|
+
const json = await fetchJson(url, { headers: pexelsHeaders(apiKey) }, fetchImpl, secrets);
|
|
4024
|
+
const photo = asRecord2(json);
|
|
4025
|
+
if (!photo) throw new PptwiseError(`Pexels photo ${photoId} was not found`);
|
|
4026
|
+
const src = asRecord2(photo.src) ?? {};
|
|
4027
|
+
const original = asString2(src.original);
|
|
4028
|
+
const large2x = asString2(src.large2x);
|
|
4029
|
+
const downloadUrl = original ?? large2x;
|
|
4030
|
+
if (!downloadUrl) throw new PptwiseError(`Pexels photo ${photoId} has no download URL`);
|
|
4031
|
+
const author = asString2(photo.photographer) ?? "unknown";
|
|
4032
|
+
return {
|
|
4033
|
+
author,
|
|
4034
|
+
pageUrl: asString2(photo.url) ?? `https://www.pexels.com/photo/${photoId}/`,
|
|
4035
|
+
license: "Pexels License",
|
|
4036
|
+
downloadUrl,
|
|
4037
|
+
fallbackUrl: original ? large2x : void 0,
|
|
4038
|
+
width: asNumber2(photo.width),
|
|
4039
|
+
height: asNumber2(photo.height)
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
async function loadPixabayPhoto(photoId, apiKey, fetchImpl, secrets) {
|
|
4043
|
+
const url = new URL("https://pixabay.com/api/");
|
|
4044
|
+
url.searchParams.set("key", apiKey);
|
|
4045
|
+
url.searchParams.set("id", photoId);
|
|
4046
|
+
const json = await fetchJson(url.toString(), { headers: { "User-Agent": userAgent2() } }, fetchImpl, secrets);
|
|
4047
|
+
const hits = parsePixabayHits(json);
|
|
4048
|
+
const hit = hits[0];
|
|
4049
|
+
const root = asRecord2(json);
|
|
4050
|
+
const rawHits = Array.isArray(root?.hits) ? root.hits : [];
|
|
4051
|
+
const raw = asRecord2(rawHits[0]);
|
|
4052
|
+
const downloadUrl = asString2(raw?.largeImageURL);
|
|
4053
|
+
if (!hit || !downloadUrl) throw new PptwiseError(`Pixabay photo ${photoId} was not found`);
|
|
4054
|
+
return {
|
|
4055
|
+
author: hit.author,
|
|
4056
|
+
pageUrl: hit.pageUrl,
|
|
4057
|
+
license: "Pixabay License",
|
|
4058
|
+
downloadUrl,
|
|
4059
|
+
width: hit.width,
|
|
4060
|
+
height: hit.height
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
async function readSidecar(path2) {
|
|
4064
|
+
try {
|
|
4065
|
+
const raw = JSON.parse(await readFile8(path2, "utf8"));
|
|
4066
|
+
const rec = asRecord2(raw);
|
|
4067
|
+
if (!rec) return null;
|
|
4068
|
+
const provider = asString2(rec.provider);
|
|
4069
|
+
const known = provider === "pexels" || provider === "pixabay" || provider === "openverse" || provider === "grok" || provider === "codex" || provider === "antigravity";
|
|
4070
|
+
if (!known || !provider) return null;
|
|
4071
|
+
const photoId = asString2(rec.photo_id);
|
|
4072
|
+
if ((provider === "pexels" || provider === "pixabay") && !photoId) return null;
|
|
4073
|
+
return {
|
|
4074
|
+
provider,
|
|
4075
|
+
photo_id: photoId,
|
|
4076
|
+
license: asString2(rec.license) ?? "",
|
|
4077
|
+
author: asString2(rec.author),
|
|
4078
|
+
page_url: asString2(rec.page_url),
|
|
4079
|
+
attribution: asString2(rec.attribution),
|
|
4080
|
+
source: asString2(rec.source),
|
|
4081
|
+
query: asString2(rec.query),
|
|
4082
|
+
prompt: asString2(rec.prompt),
|
|
4083
|
+
downloaded_at: asString2(rec.downloaded_at),
|
|
4084
|
+
generated_at: asString2(rec.generated_at)
|
|
4085
|
+
};
|
|
4086
|
+
} catch {
|
|
4087
|
+
return null;
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
async function runImagesFetch(ref, opts) {
|
|
4091
|
+
const { provider, photoId } = parsePhotoRef(ref);
|
|
4092
|
+
assertSafeFileSegment(opts.as, "asset id");
|
|
4093
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
4094
|
+
const env = opts.env ?? process.env;
|
|
4095
|
+
const keys = await loadKeys(env);
|
|
4096
|
+
const secrets = knownSecretsFrom(keys);
|
|
4097
|
+
if (provider !== "openverse") {
|
|
4098
|
+
const apiKey = keys[provider].apiKey;
|
|
4099
|
+
if (!apiKey) throw missingKeysError(provider);
|
|
4100
|
+
}
|
|
4101
|
+
const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
|
|
4102
|
+
const jpgPath = join8(assetsDir, `${opts.as}.jpg`);
|
|
4103
|
+
const jsonPath = join8(assetsDir, `${opts.as}.json`);
|
|
4104
|
+
if (await pathExists(jpgPath) && await pathExists(jsonPath)) {
|
|
4105
|
+
const existing = await readSidecar(jsonPath);
|
|
4106
|
+
if (existing && existing.photo_id === photoId && existing.provider === provider) {
|
|
4107
|
+
return `already pinned ${provider}:${photoId} as ${opts.as} \u2014 skipped`;
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
const fetchImpl = opts.fetch ?? proxyFetch;
|
|
4111
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
4112
|
+
const downloadFetch = opts.fetch;
|
|
4113
|
+
const lookup = opts.lookup ?? (opts.fetch ? void 0 : defaultDnsLookup);
|
|
4114
|
+
let meta;
|
|
4115
|
+
if (provider === "openverse") {
|
|
4116
|
+
const hit = await loadOpenverseDetail(
|
|
4117
|
+
photoId,
|
|
4118
|
+
{
|
|
4119
|
+
clientId: keys.openverse.ready ? keys.openverse.clientId : void 0,
|
|
4120
|
+
clientSecret: keys.openverse.ready ? keys.openverse.clientSecret : void 0
|
|
4121
|
+
},
|
|
4122
|
+
fetchImpl,
|
|
4123
|
+
secrets,
|
|
4124
|
+
sleep
|
|
4125
|
+
);
|
|
4126
|
+
meta = {
|
|
4127
|
+
author: hit.creator,
|
|
4128
|
+
pageUrl: hit.foreignLandingUrl,
|
|
4129
|
+
license: hit.license,
|
|
4130
|
+
downloadUrl: hit.url,
|
|
4131
|
+
width: hit.width,
|
|
4132
|
+
height: hit.height,
|
|
4133
|
+
attribution: hit.attribution,
|
|
4134
|
+
source: hit.source
|
|
4135
|
+
};
|
|
4136
|
+
} else {
|
|
4137
|
+
const apiKey = keys[provider].apiKey;
|
|
4138
|
+
meta = provider === "pexels" ? await loadPexelsPhoto(photoId, apiKey, fetchImpl, secrets) : await loadPixabayPhoto(photoId, apiKey, fetchImpl, secrets);
|
|
4139
|
+
}
|
|
4140
|
+
let bytes;
|
|
4141
|
+
try {
|
|
4142
|
+
bytes = await downloadBytes(meta.downloadUrl, downloadFetch, secrets, lookup);
|
|
4143
|
+
} catch (e) {
|
|
4144
|
+
if (meta.fallbackUrl) {
|
|
4145
|
+
bytes = await downloadBytes(meta.fallbackUrl, downloadFetch, secrets, lookup);
|
|
4146
|
+
} else {
|
|
4147
|
+
throw e;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
const apiLong = meta.width !== void 0 && meta.height !== void 0 ? Math.max(meta.width, meta.height) : void 0;
|
|
4151
|
+
const resize = opts.resizeToJpeg ?? defaultResizeToJpeg;
|
|
4152
|
+
const jpeg = await toJpeg(bytes, apiLong, resize);
|
|
4153
|
+
await mkdir5(assetsDir, { recursive: true });
|
|
4154
|
+
await writeFile4(jpgPath, jpeg);
|
|
4155
|
+
const sidecar = {
|
|
4156
|
+
provider,
|
|
4157
|
+
photo_id: photoId,
|
|
4158
|
+
license: meta.license,
|
|
4159
|
+
author: meta.author,
|
|
4160
|
+
page_url: meta.pageUrl,
|
|
4161
|
+
downloaded_at: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
|
|
4162
|
+
};
|
|
4163
|
+
if (opts.query) sidecar.query = opts.query;
|
|
4164
|
+
if (meta.attribution) sidecar.attribution = meta.attribution;
|
|
4165
|
+
if (meta.source) sidecar.source = meta.source;
|
|
4166
|
+
const json = JSON.stringify(sidecar, null, 2) + "\n";
|
|
4167
|
+
if (/"apiKey"\s*:/.test(json) || /"key"\s*:/.test(json) || /"clientSecret"\s*:/.test(json)) {
|
|
4168
|
+
throw new PptwiseError("internal error: sidecar would have contained a key field");
|
|
4169
|
+
}
|
|
4170
|
+
await writeFile4(jsonPath, json);
|
|
4171
|
+
return `pinned ${provider}:${photoId} as ${opts.as} \u2192 ${jpgPath}`;
|
|
4172
|
+
}
|
|
4173
|
+
async function runImagesList(opts) {
|
|
4174
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
4175
|
+
const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
|
|
4176
|
+
let names;
|
|
4177
|
+
try {
|
|
4178
|
+
names = (await readdir6(assetsDir)).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort();
|
|
4179
|
+
} catch (e) {
|
|
4180
|
+
if (e.code === "ENOENT") return "No pinned stock photos.";
|
|
4181
|
+
throw e;
|
|
4182
|
+
}
|
|
4183
|
+
const lines = [];
|
|
4184
|
+
for (const name of names) {
|
|
4185
|
+
const sidecar = await readSidecar(join8(assetsDir, name));
|
|
4186
|
+
if (!sidecar) continue;
|
|
4187
|
+
const assetId = name.slice(0, -".json".length);
|
|
4188
|
+
const id = sidecar.photo_id ? `${sidecar.provider}:${sidecar.photo_id}` : sidecar.provider;
|
|
4189
|
+
const rest = [sidecar.author, sidecar.license, sidecar.page_url ?? sidecar.generated_at].filter(Boolean);
|
|
4190
|
+
lines.push(`${assetId} ${id} ${rest.join(" ")}`);
|
|
4191
|
+
}
|
|
4192
|
+
return lines.length === 0 ? "No pinned stock photos." : lines.join("\n");
|
|
4193
|
+
}
|
|
4194
|
+
async function writePinnedAsset(assetsDir, assetId, jpeg, sidecar) {
|
|
4195
|
+
await mkdir5(assetsDir, { recursive: true });
|
|
4196
|
+
const jpgPath = join8(assetsDir, `${assetId}.jpg`);
|
|
4197
|
+
const jsonPath = join8(assetsDir, `${assetId}.json`);
|
|
4198
|
+
const tmpJpg = join8(assetsDir, `.${assetId}.jpg.tmp`);
|
|
4199
|
+
const tmpJson = join8(assetsDir, `.${assetId}.json.tmp`);
|
|
4200
|
+
const json = JSON.stringify(sidecar, null, 2) + "\n";
|
|
4201
|
+
if (/"apiKey"\s*:/.test(json) || /"key"\s*:/.test(json) || /"clientSecret"\s*:/.test(json)) {
|
|
4202
|
+
throw new PptwiseError("internal error: sidecar would have contained a key field");
|
|
4203
|
+
}
|
|
4204
|
+
try {
|
|
4205
|
+
await writeFile4(tmpJpg, jpeg);
|
|
4206
|
+
await writeFile4(tmpJson, json);
|
|
4207
|
+
await rename(tmpJpg, jpgPath);
|
|
4208
|
+
await rename(tmpJson, jsonPath);
|
|
4209
|
+
} catch (e) {
|
|
4210
|
+
await unlink2(tmpJpg).catch(() => void 0);
|
|
4211
|
+
await unlink2(tmpJson).catch(() => void 0);
|
|
4212
|
+
await unlink2(jpgPath).catch(() => void 0);
|
|
4213
|
+
throw e;
|
|
4214
|
+
}
|
|
4215
|
+
return jpgPath;
|
|
4216
|
+
}
|
|
4217
|
+
async function defaultResolvePrompt(opts) {
|
|
4218
|
+
const ir = await loadValidatedDeckIr(opts.deck, opts.cwd);
|
|
4219
|
+
const brief = buildAssetBrief(ir);
|
|
4220
|
+
const item = brief.items.find((entry) => entry.asset_id === opts.as && entry.suggested_prompt.trim() !== "");
|
|
4221
|
+
return item?.suggested_prompt;
|
|
4222
|
+
}
|
|
4223
|
+
async function runImagesGenerate(opts) {
|
|
4224
|
+
assertSafeFileSegment(opts.as, "asset id");
|
|
4225
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
4226
|
+
const env = opts.env ?? process.env;
|
|
4227
|
+
const hit = await findUserConfig();
|
|
4228
|
+
const gens = resolveGenerators({ file: hit?.config ?? null });
|
|
4229
|
+
const fromFlag = opts.prompt?.trim();
|
|
4230
|
+
const prompt = fromFlag && fromFlag !== "" ? fromFlag : await (opts.resolvePrompt ?? defaultResolvePrompt)({ deck: opts.deck, as: opts.as, cwd });
|
|
4231
|
+
if (!prompt) {
|
|
4232
|
+
throw new PptwiseError(`no prompt for asset "${opts.as}" \u2014 pass --prompt`);
|
|
4233
|
+
}
|
|
4234
|
+
const bins = {};
|
|
4235
|
+
for (const id of GENERATOR_IDS) {
|
|
4236
|
+
bins[id] = await locateGeneratorBin(id, env);
|
|
4237
|
+
}
|
|
4238
|
+
const anyEnabled = GENERATOR_IDS.some((id) => gens.enabled[id]);
|
|
4239
|
+
if (!anyEnabled) {
|
|
4240
|
+
const foundDisabled = GENERATOR_IDS.filter((id) => bins[id] !== null);
|
|
4241
|
+
if (foundDisabled.length === 0) {
|
|
4242
|
+
throw new PptwiseError(
|
|
4243
|
+
"No image generator is enabled. Looked for grok, codex, antigravity. None were found on PATH."
|
|
4244
|
+
);
|
|
4245
|
+
}
|
|
4246
|
+
const listed = foundDisabled.map((id) => `${id} \u2014 pptwise config set images.generators.${id}.enabled true`).join("; ");
|
|
4247
|
+
throw new PptwiseError(`No image generator is enabled. Found but disabled: ${listed}`);
|
|
4248
|
+
}
|
|
4249
|
+
const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
|
|
4250
|
+
const workdir = await mkdtemp(join8(tmpdir(), "pptwise-gen-"));
|
|
4251
|
+
const dest = join8(workdir, "generated.jpg");
|
|
4252
|
+
const run = opts.run ?? defaultProcessRunner;
|
|
4253
|
+
const attempts = [];
|
|
4254
|
+
try {
|
|
4255
|
+
for (const id of gens.order) {
|
|
4256
|
+
if (!gens.enabled[id]) continue;
|
|
4257
|
+
const bin = bins[id];
|
|
4258
|
+
if (!bin) continue;
|
|
4259
|
+
try {
|
|
4260
|
+
await GENERATOR_ADAPTERS[id]({
|
|
4261
|
+
bin,
|
|
4262
|
+
workdir,
|
|
4263
|
+
dest,
|
|
4264
|
+
prompt,
|
|
4265
|
+
timeoutMs: gens.timeoutMs,
|
|
4266
|
+
run
|
|
4267
|
+
});
|
|
4268
|
+
const bytes = await readFile8(dest);
|
|
4269
|
+
if (sniffImageFormat(bytes) === null) {
|
|
4270
|
+
throw new PptwiseError("produced bytes that are not a recognized image");
|
|
4271
|
+
}
|
|
4272
|
+
const resize = opts.resizeToJpeg ?? defaultResizeToJpeg;
|
|
4273
|
+
const jpeg = await toJpeg(bytes, void 0, resize);
|
|
4274
|
+
const sidecar = {
|
|
4275
|
+
provider: id,
|
|
4276
|
+
license: "user-generated",
|
|
4277
|
+
prompt,
|
|
4278
|
+
generated_at: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
|
|
4279
|
+
};
|
|
4280
|
+
const pinned = await writePinnedAsset(assetsDir, opts.as, jpeg, sidecar);
|
|
4281
|
+
return `pinned ${id} as ${opts.as} \u2192 ${pinned}`;
|
|
4282
|
+
} catch (e) {
|
|
4283
|
+
attempts.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
if (attempts.length === 0) {
|
|
4287
|
+
throw new PptwiseError(
|
|
4288
|
+
"No enabled image generator was found on PATH. Looked for grok, codex, antigravity."
|
|
4289
|
+
);
|
|
4290
|
+
}
|
|
4291
|
+
throw new PptwiseError(`All image generators failed: ${attempts.join("; ")}`);
|
|
4292
|
+
} finally {
|
|
4293
|
+
await rm2(workdir, { recursive: true, force: true });
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// src/cli/serve.ts
|
|
4298
|
+
import { watch } from "fs";
|
|
4299
|
+
import { createServer } from "http";
|
|
4300
|
+
import { platform as osPlatform } from "os";
|
|
4301
|
+
import { join as join9 } from "path";
|
|
4302
|
+
var DEFAULT_PORT = 4400;
|
|
4303
|
+
var DEBOUNCE_MS = 200;
|
|
4304
|
+
var HEARTBEAT_MS = 3e4;
|
|
4305
|
+
function watchRoots(resolvedTarget, isDir, extra = []) {
|
|
4306
|
+
const roots = isDir ? [join9(resolvedTarget, SPEC_FILENAME), join9(resolvedTarget, PAGES_DIRNAME), join9(resolvedTarget, ASSETS_DIRNAME)] : [resolvedTarget];
|
|
4307
|
+
return [...roots, ...extra];
|
|
4308
|
+
}
|
|
4309
|
+
var SERVE_CLIENT_SCRIPT_ID = "pptwise-serve-client";
|
|
4310
|
+
var SERVE_CLIENT_JS = `
|
|
4311
|
+
(function () {
|
|
4312
|
+
// Live reload is the whole of this client (the revision-request submit
|
|
4313
|
+
// that used to sit beside it was removed on 2026-08-16 \u2014 see this
|
|
4314
|
+
// module's own header). It keeps its own function and its own try/catch
|
|
4315
|
+
// at the call site below: an EventSource construction that throws in some
|
|
4316
|
+
// unusual embedding must degrade to a page that simply does not
|
|
4317
|
+
// auto-refresh, not abort the IIFE.
|
|
4318
|
+
|
|
4319
|
+
function setUpLiveReload() {
|
|
4320
|
+
var es = new EventSource('/events')
|
|
4321
|
+
es.addEventListener('reload', function () { location.reload() })
|
|
4322
|
+
|
|
4323
|
+
var banner = document.createElement('div')
|
|
4324
|
+
banner.id = 'pptwise-serve-error-banner'
|
|
4325
|
+
banner.setAttribute('role', 'alert')
|
|
4326
|
+
banner.style.cssText =
|
|
4327
|
+
'display:none;position:fixed;top:0;left:0;right:0;z-index:2147483647;' +
|
|
4328
|
+
'background:#dc2626;color:#fff;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;' +
|
|
4329
|
+
'padding:8px 16px;text-align:center'
|
|
4330
|
+
document.body.appendChild(banner)
|
|
4331
|
+
|
|
4332
|
+
function showBanner(message) {
|
|
4333
|
+
banner.textContent = 'pptwise serve: ' + message
|
|
4334
|
+
banner.style.display = 'block'
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
es.addEventListener('error', function (e) {
|
|
4338
|
+
if (!e || typeof e.data !== 'string') return // a real connection hiccup, not the server's own rebuild-failed frame
|
|
4339
|
+
var message = 'rebuild failed'
|
|
4340
|
+
try {
|
|
4341
|
+
var parsed = JSON.parse(e.data)
|
|
4342
|
+
if (parsed && typeof parsed.message === 'string') message = parsed.message
|
|
4343
|
+
} catch (err) {}
|
|
4344
|
+
showBanner(message)
|
|
4345
|
+
})
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
try {
|
|
4349
|
+
setUpLiveReload()
|
|
4350
|
+
} catch (e) {
|
|
4351
|
+
console.error('pptwise serve: failed to set up live reload', e)
|
|
4352
|
+
}
|
|
4353
|
+
})()
|
|
4354
|
+
`.trim();
|
|
4355
|
+
function buildServeClientScriptTag() {
|
|
4356
|
+
return `<script id="${SERVE_CLIENT_SCRIPT_ID}">${SERVE_CLIENT_JS}</script>`;
|
|
4357
|
+
}
|
|
4358
|
+
function injectServeClient(html) {
|
|
4359
|
+
if (html.includes(SERVE_CLIENT_SCRIPT_ID)) return html;
|
|
4360
|
+
return html.replace("</body>", `${buildServeClientScriptTag()}
|
|
4361
|
+
</body>`);
|
|
4362
|
+
}
|
|
4363
|
+
async function createServeServer(options) {
|
|
4364
|
+
const cwd = options.cwd ?? process.cwd();
|
|
4365
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
4366
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
|
|
4367
|
+
throw new PptwiseError(`invalid port ${requestedPort} \u2014 expected an integer between 0 and 65535`);
|
|
4368
|
+
}
|
|
4369
|
+
const initial = await buildDeckPreview(options.target, { cwd, themeFilePath: options.themeFilePath });
|
|
4370
|
+
let cachedHtml = injectServeClient(initial.html);
|
|
4371
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
4372
|
+
function writeToAll(chunk) {
|
|
4373
|
+
for (const res of sseClients) {
|
|
4374
|
+
try {
|
|
4375
|
+
res.write(chunk);
|
|
4376
|
+
} catch {
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
function broadcast(event, data) {
|
|
4381
|
+
writeToAll(`event: ${event}
|
|
4382
|
+
data: ${JSON.stringify(data)}
|
|
4383
|
+
|
|
4384
|
+
`);
|
|
4385
|
+
}
|
|
4386
|
+
async function rebuild() {
|
|
4387
|
+
try {
|
|
4388
|
+
const result = await buildDeckPreview(options.target, { cwd, themeFilePath: options.themeFilePath });
|
|
4389
|
+
cachedHtml = injectServeClient(result.html);
|
|
4390
|
+
broadcast("reload", {});
|
|
4391
|
+
} catch (e) {
|
|
4392
|
+
broadcast("error", { message: e instanceof Error ? e.message : String(e) });
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
const server = createServer((req, res) => {
|
|
4396
|
+
const pathname = (req.url ?? "/").split("?")[0];
|
|
4397
|
+
if (req.method === "GET" && pathname === "/") {
|
|
4398
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
4399
|
+
res.end(cachedHtml);
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
if (req.method === "GET" && pathname === "/events") {
|
|
4403
|
+
res.writeHead(200, {
|
|
4404
|
+
"Content-Type": "text/event-stream",
|
|
4405
|
+
"Cache-Control": "no-cache, no-transform",
|
|
4406
|
+
Connection: "keep-alive"
|
|
4407
|
+
});
|
|
4408
|
+
res.write("retry: 2000\n\n");
|
|
4409
|
+
sseClients.add(res);
|
|
4410
|
+
res.on("close", () => sseClients.delete(res));
|
|
4411
|
+
res.on("error", () => sseClients.delete(res));
|
|
4412
|
+
return;
|
|
4413
|
+
}
|
|
4414
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
4415
|
+
res.end("not found");
|
|
4416
|
+
});
|
|
4417
|
+
const heartbeat = setInterval(() => writeToAll(": heartbeat\n\n"), HEARTBEAT_MS);
|
|
4418
|
+
let debounceTimer;
|
|
4419
|
+
function scheduleRebuild() {
|
|
4420
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
4421
|
+
debounceTimer = setTimeout(() => {
|
|
4422
|
+
debounceTimer = void 0;
|
|
4423
|
+
void rebuild();
|
|
4424
|
+
}, DEBOUNCE_MS);
|
|
4425
|
+
}
|
|
4426
|
+
const projectHit = await findConfig(cwd);
|
|
4427
|
+
const workspaceAssets = join9(
|
|
4428
|
+
resolveWorkspaceLocation({
|
|
4429
|
+
cwd,
|
|
4430
|
+
projectConfigPath: projectHit?.path,
|
|
4431
|
+
outDir: projectHit?.config.outDir,
|
|
4432
|
+
target: initial.resolvedTarget,
|
|
4433
|
+
isDir: initial.isDir
|
|
4434
|
+
}).dir,
|
|
4435
|
+
ASSETS_DIRNAME
|
|
4436
|
+
);
|
|
4437
|
+
const watchers = [];
|
|
4438
|
+
for (const path2 of watchRoots(initial.resolvedTarget, initial.isDir, [workspaceAssets])) {
|
|
4439
|
+
try {
|
|
4440
|
+
watchers.push(watch(path2, () => scheduleRebuild()));
|
|
4441
|
+
} catch (e) {
|
|
4442
|
+
if (e.code !== "ENOENT") throw e;
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
function teardownWatchersAndTimers() {
|
|
4446
|
+
clearInterval(heartbeat);
|
|
4447
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
4448
|
+
for (const w of watchers) w.close();
|
|
4449
|
+
}
|
|
4450
|
+
try {
|
|
4451
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
4452
|
+
const onError = (err) => {
|
|
4453
|
+
server.removeListener("listening", onListening);
|
|
4454
|
+
rejectListen(err);
|
|
4455
|
+
};
|
|
4456
|
+
const onListening = () => {
|
|
4457
|
+
server.removeListener("error", onError);
|
|
4458
|
+
resolveListen();
|
|
4459
|
+
};
|
|
4460
|
+
server.once("error", onError);
|
|
4461
|
+
server.once("listening", onListening);
|
|
4462
|
+
server.listen(requestedPort, "127.0.0.1");
|
|
4463
|
+
});
|
|
4464
|
+
} catch (e) {
|
|
4465
|
+
teardownWatchersAndTimers();
|
|
4466
|
+
if (e.code === "EADDRINUSE") {
|
|
4467
|
+
throw new PptwiseError(`port ${requestedPort} is already in use \u2014 pick a different one with --port`);
|
|
4468
|
+
}
|
|
4469
|
+
throw e;
|
|
4470
|
+
}
|
|
4471
|
+
const address = server.address();
|
|
4472
|
+
const actualPort = typeof address === "object" && address !== null ? address.port : requestedPort;
|
|
4473
|
+
let closed = false;
|
|
4474
|
+
async function close() {
|
|
4475
|
+
if (closed) return;
|
|
4476
|
+
closed = true;
|
|
4477
|
+
teardownWatchersAndTimers();
|
|
4478
|
+
for (const res of sseClients) res.end();
|
|
4479
|
+
sseClients.clear();
|
|
4480
|
+
if (typeof server.closeIdleConnections === "function") server.closeIdleConnections();
|
|
4481
|
+
if (typeof server.closeAllConnections === "function") server.closeAllConnections();
|
|
4482
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
4483
|
+
server.close((err) => err ? rejectClose(err) : resolveClose());
|
|
4484
|
+
});
|
|
4485
|
+
}
|
|
4486
|
+
return { server, rebuild, close, url: `http://127.0.0.1:${actualPort}`, port: actualPort };
|
|
4487
|
+
}
|
|
4488
|
+
function openBrowser(url) {
|
|
4489
|
+
const command = osPlatform() === "darwin" ? "open" : "xdg-open";
|
|
4490
|
+
try {
|
|
4491
|
+
const child = spawnHidden(command, [url], { stdio: "ignore", detached: true });
|
|
4492
|
+
child.on("error", () => {
|
|
4493
|
+
});
|
|
4494
|
+
child.unref();
|
|
4495
|
+
} catch {
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
async function runServe(target, opts = {}) {
|
|
4499
|
+
const handle = await createServeServer({ target, port: opts.port, cwd: opts.cwd, themeFilePath: opts.themeFilePath });
|
|
4500
|
+
console.log(`pptwise serve: ${handle.url} (Ctrl+C to stop)`);
|
|
4501
|
+
if (opts.open !== false) openBrowser(handle.url);
|
|
4502
|
+
process.on("SIGINT", () => {
|
|
4503
|
+
void handle.close().then(
|
|
4504
|
+
() => process.exit(0),
|
|
4505
|
+
() => process.exit(1)
|
|
4506
|
+
);
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
// src/cli.ts
|
|
4511
|
+
installNodePlatform();
|
|
4512
|
+
var program = new Command();
|
|
4513
|
+
program.name("pptwise").description("Stable, editable PPTX generation for AI agents \u2014 semantic IR in, native DrawingML out").version(VERSION);
|
|
4514
|
+
function fail(e) {
|
|
4515
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
4516
|
+
process.exit(1);
|
|
4517
|
+
}
|
|
4518
|
+
program.command("render").description("Render an IR JSON file, deck project directory, or bare deck name to a .pptx").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <file>", "output .pptx path (default: .pptwise/<deck>/<deck>.pptx under the project root)").option("--theme <id>", "override the deck theme (see `pptwise themes`)").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and render with it").option("--style <path>", "style overrides JSON re-coloring the theme (see `pptwise schema --style`)").option("--draft", "allow unfilled placeholder pages (skip the draft gate)").option(
|
|
4519
|
+
"--allow-dropped-content",
|
|
4520
|
+
"export anyway when a page holds more than fits and the layout drops blocks (skip the content-drop gate)"
|
|
4521
|
+
).option("--no-git-ignore", "do not add .pptwise/ to this repository's local exclude file").action(
|
|
4522
|
+
async (target, opts) => {
|
|
4523
|
+
try {
|
|
4524
|
+
console.log(
|
|
4525
|
+
await runRender(target, {
|
|
4526
|
+
output: opts.output,
|
|
4527
|
+
theme: opts.theme,
|
|
4528
|
+
themeFilePath: opts.themeFile,
|
|
4529
|
+
stylePath: opts.style,
|
|
4530
|
+
draft: opts.draft,
|
|
4531
|
+
allowDroppedContent: opts.allowDroppedContent,
|
|
4532
|
+
gitIgnore: opts.gitIgnore,
|
|
4533
|
+
cwd: process.cwd()
|
|
4534
|
+
})
|
|
4535
|
+
);
|
|
4536
|
+
} catch (e) {
|
|
4537
|
+
fail(e);
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
);
|
|
4541
|
+
program.command("validate").description("Validate an IR JSON file, deck project directory, or bare deck name against the schema").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) before validating").action(async (target, opts) => {
|
|
4542
|
+
try {
|
|
4543
|
+
console.log(await runValidate(target, process.cwd(), { themeFilePath: opts.themeFile }));
|
|
4544
|
+
} catch (e) {
|
|
4545
|
+
fail(e);
|
|
4546
|
+
}
|
|
4547
|
+
});
|
|
4548
|
+
program.command("audit").description(
|
|
4549
|
+
"Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap, content-truncated, content-dropped), plus an optional --pixels contrast pass \u2014 exits 1 when it finds anything"
|
|
4550
|
+
).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--json", "machine-readable output (the full AuditReport)").option("--pixels", "also run the optional pixel-contrast pass over image-backed text (requires sharp)").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and audit with it").action(async (target, opts) => {
|
|
4551
|
+
try {
|
|
4552
|
+
const { output, hasFindings } = await runAudit(target, {
|
|
4553
|
+
json: opts.json,
|
|
4554
|
+
pixels: opts.pixels,
|
|
4555
|
+
themeFilePath: opts.themeFile
|
|
4556
|
+
});
|
|
4557
|
+
console.log(output);
|
|
4558
|
+
if (hasFindings) process.exit(1);
|
|
4559
|
+
} catch (e) {
|
|
4560
|
+
fail(e);
|
|
4561
|
+
}
|
|
4562
|
+
});
|
|
4563
|
+
program.command("asset-brief").description(
|
|
4564
|
+
"Image-generation brief for every image slot in a deck: the real rendered frame, fit/crop mode, suggested pixel size, theme palette/mood, and a paste-ready prompt"
|
|
4565
|
+
).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--json", "machine-readable output (the full AssetBrief)").action(async (target, opts) => {
|
|
4566
|
+
try {
|
|
4567
|
+
console.log(await runAssetBrief(target, { json: opts.json }));
|
|
4568
|
+
} catch (e) {
|
|
4569
|
+
fail(e);
|
|
4570
|
+
}
|
|
4571
|
+
});
|
|
4572
|
+
program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").option("--style", "print the style-override schema instead").option("--spec", "print the deck spec schema instead").option("--plan", "removed \u2014 use --spec instead").action((opts) => {
|
|
4573
|
+
if (opts.plan) {
|
|
4574
|
+
fail(new Error("`pptwise schema --plan` has been renamed to `pptwise schema --spec` \u2014 run `pptwise schema --spec` instead"));
|
|
4575
|
+
}
|
|
4576
|
+
console.log(runSchema(opts.spec ? "spec" : opts.style ? "style" : void 0));
|
|
4577
|
+
});
|
|
4578
|
+
var plan = program.command("plan").description("Removed \u2014 use `pptwise spec` instead");
|
|
4579
|
+
plan.command("validate").description("Removed \u2014 use `pptwise spec validate` instead").argument("<file>").action(() => {
|
|
4580
|
+
fail(new Error("`pptwise plan validate` has been renamed to `pptwise spec validate` \u2014 run `pptwise spec validate <file>` instead"));
|
|
4581
|
+
});
|
|
4582
|
+
var spec = program.command("spec").description("Deck spec commands (spec \xA76)");
|
|
4583
|
+
spec.command("validate").description("Validate a deck spec JSON file against the schema and strategy-aware hard gates").argument("<spec.json>").action(async (specPath) => {
|
|
4584
|
+
try {
|
|
4585
|
+
console.log(await runSpecValidate(specPath));
|
|
4586
|
+
} catch (e) {
|
|
4587
|
+
fail(e);
|
|
4588
|
+
}
|
|
4589
|
+
});
|
|
4590
|
+
program.command("assemble").description("Assemble a deck project directory (deck.spec.json + pages/ + assets/) into an IR JSON file").argument("<dir|name>", "deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <file>", "output IR JSON path (default: <dir>/deck.json)").action(async (target, opts) => {
|
|
4591
|
+
try {
|
|
4592
|
+
console.log(await runAssemble(target, { output: opts.output }));
|
|
4593
|
+
} catch (e) {
|
|
4594
|
+
fail(e);
|
|
4595
|
+
}
|
|
4596
|
+
});
|
|
4597
|
+
program.command("disassemble").description("Split an IR JSON file into a deck project directory (deck.spec.json + pages/)").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <dir>", "output deck project directory").action(async (irPath, opts) => {
|
|
4598
|
+
try {
|
|
4599
|
+
console.log(await runDisassemble(irPath, opts.output));
|
|
4600
|
+
} catch (e) {
|
|
4601
|
+
fail(e);
|
|
4602
|
+
}
|
|
4603
|
+
});
|
|
4604
|
+
program.command("migrate").description("Convert a v3 IR file to v4, rewrite chrome \u2192 branding, bloom \u2192 classroom, logo_wall \u2192 image_grid, or banner-heading \u2192 two-column on a v4 IR or deck spec, or convert a deck.plan.json project directory to deck.spec.json \u2014 deterministic, no model").argument("<input>", "IR v3 JSON file, a v4 IR or deck spec still carrying chrome, bloom, logo_wall, or banner-heading, or a deck project directory containing deck.plan.json").requiredOption("-o, --output <output>", "output path \u2014 an IR JSON file for a file input, a directory for a deck-project-directory input").action(async (input, opts) => {
|
|
4605
|
+
try {
|
|
4606
|
+
console.log(await runMigrate(input, opts.output));
|
|
4607
|
+
} catch (e) {
|
|
4608
|
+
fail(e);
|
|
4609
|
+
}
|
|
4610
|
+
});
|
|
4611
|
+
program.command("themes").description("List built-in themes").option("--json", "machine-readable output").action((opts) => console.log(runThemes(Boolean(opts.json))));
|
|
4612
|
+
var brand = program.command("brand").description("Brand asset commands \u2014 extract your company's colors/fonts from an Office template");
|
|
4613
|
+
brand.command("extract").description(
|
|
4614
|
+
"Extract brand colors and fonts from a .thmx/.potx/.pptx file into a pptwise theme file \u2014 runs entirely locally, the file never leaves your machine"
|
|
4615
|
+
).argument("<file>", "a .thmx theme, .potx template, or .pptx presentation").requiredOption("-o, --output <file>", "output theme JSON path (e.g. my-brand.theme.json)").option("--id <id>", "theme id to register under (default: slug of the output filename)").option("--label <label>", "human-readable theme label (default: the source theme's color-scheme name)").action(async (file, opts) => {
|
|
4616
|
+
try {
|
|
4617
|
+
console.log(await runBrandExtract(file, { output: opts.output, id: opts.id, label: opts.label }));
|
|
4618
|
+
} catch (e) {
|
|
4619
|
+
fail(e);
|
|
4620
|
+
}
|
|
4621
|
+
});
|
|
4622
|
+
program.command("narratives").description("List named narrative presets (strategy/pacing/audience axes + theme recommendations)").option("--json", "machine-readable output").action((opts) => console.log(runNarratives(Boolean(opts.json))));
|
|
4623
|
+
program.command("scenarios").description("Removed \u2014 use `pptwise narratives` instead").action(() => {
|
|
4624
|
+
fail(new Error("`pptwise scenarios` has been renamed to `pptwise narratives` \u2014 run `pptwise narratives` instead"));
|
|
4625
|
+
});
|
|
4626
|
+
var config = program.command("config").description("User-level settings (API keys for optional stock-photo search)");
|
|
4627
|
+
config.command("set <key> [value]").description("Set a user config value. Omit the value for an apiKey or clientSecret to enter it at a hidden prompt").action(async (key, value) => {
|
|
4628
|
+
try {
|
|
4629
|
+
console.log(await runConfigSet(key, value));
|
|
4630
|
+
} catch (e) {
|
|
4631
|
+
fail(e);
|
|
4632
|
+
}
|
|
4633
|
+
});
|
|
4634
|
+
config.command("show").description("Show the effective user config (API keys masked)").action(async () => {
|
|
4635
|
+
try {
|
|
4636
|
+
console.log(await runConfigShow());
|
|
4637
|
+
} catch (e) {
|
|
4638
|
+
fail(e);
|
|
4639
|
+
}
|
|
4640
|
+
});
|
|
4641
|
+
function parsePositiveInt(raw, flag) {
|
|
4642
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
4643
|
+
fail(new Error(`invalid ${flag} "${raw}" \u2014 expected a positive integer`));
|
|
4644
|
+
}
|
|
4645
|
+
return Number(raw);
|
|
4646
|
+
}
|
|
4647
|
+
var images = program.command("images").description("Search and pin stock photos into workspace assets");
|
|
4648
|
+
images.command("search <query>").description("Search Pexels, then Pixabay, then Openverse (cc0/pdm) and print attribution lines").option("--orientation <orientation>", "landscape, portrait, or square").option("--color <color>", "color name or hex for the search API").option("--min-width <px>", "client-side minimum width in pixels").option("--min-height <px>", "client-side minimum height in pixels").action(
|
|
4649
|
+
async (query, opts) => {
|
|
4650
|
+
try {
|
|
4651
|
+
console.log(
|
|
4652
|
+
await runImagesSearch(query, {
|
|
4653
|
+
orientation: opts.orientation,
|
|
4654
|
+
color: opts.color,
|
|
4655
|
+
minWidth: opts.minWidth !== void 0 ? parsePositiveInt(opts.minWidth, "--min-width") : void 0,
|
|
4656
|
+
minHeight: opts.minHeight !== void 0 ? parsePositiveInt(opts.minHeight, "--min-height") : void 0
|
|
4657
|
+
})
|
|
4658
|
+
);
|
|
4659
|
+
} catch (e) {
|
|
4660
|
+
fail(e);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
);
|
|
4664
|
+
images.command("fetch <ref>").description("Download a photo (pexels:<id>, pixabay:<id>, or openverse:<id>) into .pptwise/<deck>/assets/").requiredOption("--deck <dir>", "deck project directory, path, or bare name").requiredOption("--as <asset_id>", "local asset id (filename without extension)").option("--query <text>", "search query that produced this pick (stored in the sidecar)").action(async (ref, opts) => {
|
|
4665
|
+
try {
|
|
4666
|
+
console.log(
|
|
4667
|
+
await runImagesFetch(ref, { deck: opts.deck, as: opts.as, query: opts.query, cwd: process.cwd() })
|
|
4668
|
+
);
|
|
4669
|
+
} catch (e) {
|
|
4670
|
+
fail(e);
|
|
4671
|
+
}
|
|
4672
|
+
});
|
|
4673
|
+
images.command("list").description("List pinned stock photos for a deck").requiredOption("--deck <dir>", "deck project directory, path, or bare name").action(async (opts) => {
|
|
4674
|
+
try {
|
|
4675
|
+
console.log(await runImagesList({ deck: opts.deck, cwd: process.cwd() }));
|
|
4676
|
+
} catch (e) {
|
|
4677
|
+
fail(e);
|
|
4678
|
+
}
|
|
4679
|
+
});
|
|
4680
|
+
images.command("generate").description("Generate an image with a local CLI (grok, codex, or antigravity) and pin it").requiredOption("--deck <dir>", "deck project directory, path, or bare name").requiredOption("--as <asset_id>", "local asset id (filename without extension)").option("--prompt <text>", "image prompt (otherwise taken from asset-brief)").action(async (opts) => {
|
|
4681
|
+
try {
|
|
4682
|
+
console.log(
|
|
4683
|
+
await runImagesGenerate({ deck: opts.deck, as: opts.as, prompt: opts.prompt, cwd: process.cwd() })
|
|
4684
|
+
);
|
|
4685
|
+
} catch (e) {
|
|
4686
|
+
fail(e);
|
|
4687
|
+
}
|
|
4688
|
+
});
|
|
4689
|
+
program.command("init").description("Scaffold a pptwise.config.json in the current directory").action(async () => {
|
|
4690
|
+
try {
|
|
4691
|
+
console.log(await runInit());
|
|
4692
|
+
} catch (e) {
|
|
4693
|
+
fail(e);
|
|
4694
|
+
}
|
|
4695
|
+
});
|
|
4696
|
+
program.command("preview").description("Render each slide to an SVG file for visual self-check").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <dir>", "output directory (default: .pptwise/<deck>/ under the project root)").option("--html", "also write a self-contained preview.html (all slides inlined \u2014 thumbnail strip, keyboard navigation) for human review").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and preview with it").option("--no-git-ignore", "do not add .pptwise/ to this repository's local exclude file").action(async (target, opts) => {
|
|
4697
|
+
try {
|
|
4698
|
+
console.log(
|
|
4699
|
+
await runPreview(target, opts.output, {
|
|
4700
|
+
htmlOut: opts.html,
|
|
4701
|
+
themeFilePath: opts.themeFile,
|
|
4702
|
+
gitIgnore: opts.gitIgnore,
|
|
4703
|
+
cwd: process.cwd()
|
|
4704
|
+
})
|
|
4705
|
+
);
|
|
4706
|
+
} catch (e) {
|
|
4707
|
+
fail(e);
|
|
4708
|
+
}
|
|
4709
|
+
});
|
|
4710
|
+
program.command("serve").description("Serve a live-reloading HTML preview of an IR JSON file, deck project directory, or bare deck name over HTTP").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--port <number>", `port to listen on (default ${DEFAULT_PORT})`).option("--no-open", "do not open the URL in a browser after starting").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and serve with it").action(async (target, opts) => {
|
|
4711
|
+
try {
|
|
4712
|
+
let port;
|
|
4713
|
+
if (opts.port !== void 0) {
|
|
4714
|
+
port = Number(opts.port);
|
|
4715
|
+
if (!Number.isInteger(port)) {
|
|
4716
|
+
fail(new Error(`invalid --port value "${opts.port}" \u2014 expected an integer`));
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
await runServe(target, { port, open: opts.open, themeFilePath: opts.themeFile });
|
|
4720
|
+
} catch (e) {
|
|
4721
|
+
fail(e);
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4724
|
+
program.command("doctor").description(
|
|
4725
|
+
"Diagnose this machine's install: installed skill copies and what each pins, dsh plugin status, Node/Bun against the engines floor, the optional sharp/soffice capabilities, and a self-test render \u2014 exits 1 only on a hard failure"
|
|
4726
|
+
).option("--json", "machine-readable output (the full DoctorReport)").action(async (opts) => {
|
|
4727
|
+
try {
|
|
4728
|
+
const { output, hasErrors } = await runDoctor({ json: opts.json });
|
|
4729
|
+
console.log(output);
|
|
4730
|
+
if (hasErrors) process.exit(1);
|
|
4731
|
+
} catch (e) {
|
|
4732
|
+
fail(e);
|
|
4733
|
+
}
|
|
4734
|
+
});
|
|
4735
|
+
program.command("check-update").description("Check npm for a newer pptwise release").action(async () => {
|
|
4736
|
+
const info = await checkForUpdate({ currentVersion: VERSION });
|
|
4737
|
+
if (!info.checked) fail(new Error(`update check failed: ${info.error}`));
|
|
4738
|
+
console.log(
|
|
4739
|
+
info.updateAvailable ? `update available: ${info.currentVersion} \u2192 ${info.latestVersion} (run \`pptwise self-update\`)` : `pptwise ${info.currentVersion} is up to date`
|
|
4740
|
+
);
|
|
4741
|
+
});
|
|
4742
|
+
program.command("self-update").description("Update the global pptwise install to the latest release").action(async () => {
|
|
4743
|
+
try {
|
|
4744
|
+
const result = await createSelfUpdater()({ currentVersion: VERSION });
|
|
4745
|
+
console.log(
|
|
4746
|
+
result.updated ? `updated: ${result.currentVersion} \u2192 ${result.latestVersion}` : `already at the latest version (${result.currentVersion})`
|
|
4747
|
+
);
|
|
4748
|
+
} catch (e) {
|
|
4749
|
+
fail(e);
|
|
4750
|
+
}
|
|
4751
|
+
});
|
|
4752
|
+
program.parseAsync(process.argv, { from: "node" }).catch(fail);
|
|
4753
|
+
//# sourceMappingURL=cli.js.map
|