@lutrin/core 1.0.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 +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.deckkit` archives — pack, download, extract (see the "Kits" section of the
|
|
3
|
+
* README, and SECURITY.md for the threat model).
|
|
4
|
+
*
|
|
5
|
+
* Lives in `src/kit/` and not in `src/deck/`: this module depends on JSZip, and
|
|
6
|
+
* the `deck/` core knows no third-party library of that kind
|
|
7
|
+
* (boundary.test.mjs). The distinction is also right on the merits —
|
|
8
|
+
* compiling a deck knows nothing of archives; only the CLI installs. The
|
|
9
|
+
* MANIFEST, for its part, stays in `deck/kit.mjs`: theme resolution needs it,
|
|
10
|
+
* and it is pure.
|
|
11
|
+
*
|
|
12
|
+
* This is the ONLY place in the project that writes bytes coming from the
|
|
13
|
+
* outside onto the user's disk. Everything that follows is a safeguard, and
|
|
14
|
+
* none of them is optional:
|
|
15
|
+
*
|
|
16
|
+
* 1. PATH TRAVERSAL — any entry whose normalized path leaves the target
|
|
17
|
+
* directory is refused, absolute paths and `..` included. It is the
|
|
18
|
+
* classic bug of every unzipper ("zip slip").
|
|
19
|
+
* 2. EXTENSION ALLOWLIST — a kit is DATA. No `.mjs`, `.js` or `.node` gets
|
|
20
|
+
* in. It is this property, and it alone, that makes installing from a URL
|
|
21
|
+
* defensible: nothing that is installed will ever be executed.
|
|
22
|
+
* 3. LIMITS — archive size, uncompressed total, entry count. Without them,
|
|
23
|
+
* 1 MB of archive can write 10 GB ("zip bomb").
|
|
24
|
+
* 4. HTTPS ONLY, and never a redirect to another protocol.
|
|
25
|
+
* 5. DIGEST — the SHA-256 of the archive is displayed and kept, so that the
|
|
26
|
+
* author of a kit can state what they published. It is NOT a signature:
|
|
27
|
+
* it does not authenticate the source, it allows comparison.
|
|
28
|
+
* 6. ATOMIC EXTRACTION — everything is written to a neighboring temporary
|
|
29
|
+
* directory, validated, then swapped in by a rename(). An interrupted
|
|
30
|
+
* installation never leaves a half-written kit.
|
|
31
|
+
*
|
|
32
|
+
* The uncompressed total is checked DURING decompression (nodeStream), not
|
|
33
|
+
* after: the size an archive announces is a declaration by the archive, hence
|
|
34
|
+
* by a third party, and a single 10 GB file would exhaust memory before an
|
|
35
|
+
* after-the-fact check ever got a word in.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import crypto from 'node:crypto';
|
|
41
|
+
import https from 'node:https';
|
|
42
|
+
import JSZip from 'jszip';
|
|
43
|
+
import { parseKitManifest, escapesKit, KIT_MANIFEST, KIT_EXT } from '../deck/kit.mjs';
|
|
44
|
+
import { remoteUrlRefusal } from '../deck/assets.mjs';
|
|
45
|
+
|
|
46
|
+
/** Safety limits. Generous for a real kit (a complete kit with its fonts and
|
|
47
|
+
* logos weighs ~500 KB), tight for a hostile archive. */
|
|
48
|
+
export const LIMITS = {
|
|
49
|
+
archiveBytes: 20 * 1024 * 1024,
|
|
50
|
+
extractedBytes: 100 * 1024 * 1024,
|
|
51
|
+
entries: 500,
|
|
52
|
+
redirects: 5,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Tooling directories, never packed: they belong to the kit's repository, not
|
|
56
|
+
* to the kit. `test/` is still walked — its .mjs files are skipped by the
|
|
57
|
+
* allowlist, but a .json or .png fixture has its place there. */
|
|
58
|
+
export const SKIP_DIRS = new Set(['node_modules', '.git', '.github', '.vscode', '.idea', 'dist']);
|
|
59
|
+
|
|
60
|
+
/** What a kit is allowed to contain. Data only: nothing executable. */
|
|
61
|
+
export const ALLOWED_EXT = new Set([
|
|
62
|
+
'.json',
|
|
63
|
+
'.md',
|
|
64
|
+
'.txt',
|
|
65
|
+
'.woff2',
|
|
66
|
+
'.woff',
|
|
67
|
+
'.ttf',
|
|
68
|
+
'.otf',
|
|
69
|
+
'.svg',
|
|
70
|
+
'.png',
|
|
71
|
+
'.jpg',
|
|
72
|
+
'.jpeg',
|
|
73
|
+
'.webp',
|
|
74
|
+
'.gif',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
class KitArchiveError extends Error {
|
|
78
|
+
constructor(message) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'KitArchiveError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fail = (msg) => {
|
|
85
|
+
throw new KitArchiveError(msg);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Reading an archive
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Checks an archive entry and returns its safe relative path.
|
|
96
|
+
*
|
|
97
|
+
* IMPORTANT NOTE — do not remove this check believing it dead. JSZip
|
|
98
|
+
* normalizes entry names in loadAsync: "../../etc/passwd" becomes
|
|
99
|
+
* "etc/passwd" there BEFORE reaching this point, including for an archive
|
|
100
|
+
* built outside JSZip. Protection against path traversal therefore rests
|
|
101
|
+
* today on the dependency, and this check is the SECOND line: it covers a
|
|
102
|
+
* change in JSZip's behaviour, a replacement of the library, and the
|
|
103
|
+
* extensions that are not admitted (which JSZip, for its part, lets through).
|
|
104
|
+
* It is tested directly in kit-archive.test.mjs, for want of being testable
|
|
105
|
+
* end to end.
|
|
106
|
+
*
|
|
107
|
+
* @returns {string|null} POSIX relative path, or null if the entry is to be ignored
|
|
108
|
+
*/
|
|
109
|
+
export function safeEntryPath(name) {
|
|
110
|
+
// archives built under Windows carry `\` separators: under POSIX those are
|
|
111
|
+
// NAME characters, so `..\..\x` would pass for a harmless file — escapesKit
|
|
112
|
+
// normalizes them before judging
|
|
113
|
+
if (name.endsWith('/')) return null; // directory: created implicitly
|
|
114
|
+
if (escapesKit(name))
|
|
115
|
+
fail(
|
|
116
|
+
`Entry refused: "${name}" escapes the kit (absolute path or ".." traversal). Archive rejected.`,
|
|
117
|
+
);
|
|
118
|
+
const ext = path.extname(name).toLowerCase();
|
|
119
|
+
if (!ALLOWED_EXT.has(ext))
|
|
120
|
+
fail(
|
|
121
|
+
`Entry refused: "${name}" — extension "${ext || '(none)'}" not allowed in a kit. ` +
|
|
122
|
+
`A kit contains only data (${[...ALLOWED_EXT].join(' ')}), never code.`,
|
|
123
|
+
);
|
|
124
|
+
return name.replace(/\\/g, '/');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Decompresses an entry while watching its REAL size as it goes. */
|
|
128
|
+
function readEntry(entry, budget) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
const stream = entry.nodeStream('nodebuffer');
|
|
133
|
+
stream.on('data', (chunk) => {
|
|
134
|
+
size += chunk.length;
|
|
135
|
+
if (size > budget.remaining) {
|
|
136
|
+
stream.destroy();
|
|
137
|
+
reject(
|
|
138
|
+
new KitArchiveError(
|
|
139
|
+
`Archive refused: the uncompressed content exceeds ${Math.round(LIMITS.extractedBytes / 1024 / 1024)} MB ` +
|
|
140
|
+
`(entry "${entry.name}"). Archive rejected.`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
});
|
|
147
|
+
stream.on('error', reject);
|
|
148
|
+
stream.on('end', () => {
|
|
149
|
+
budget.remaining -= size;
|
|
150
|
+
resolve(Buffer.concat(chunks));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reads a `.deckkit` archive IN MEMORY and validates everything that can be
|
|
157
|
+
* validated before a single byte touches the disk: entries, manifest, limits.
|
|
158
|
+
*
|
|
159
|
+
* @param {Buffer} buf
|
|
160
|
+
* @returns {Promise<{ manifest: object, files: Map<string, Buffer>,
|
|
161
|
+
* digest: string, diagnostics: Array }>}
|
|
162
|
+
*/
|
|
163
|
+
export async function readKitArchive(buf) {
|
|
164
|
+
if (!Buffer.isBuffer(buf) || !buf.length) fail('Empty archive.');
|
|
165
|
+
if (buf.length > LIMITS.archiveBytes)
|
|
166
|
+
fail(
|
|
167
|
+
`Archive refused: ${Math.round(buf.length / 1024 / 1024)} MB > ${Math.round(LIMITS.archiveBytes / 1024 / 1024)} MB.`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
let zip;
|
|
171
|
+
try {
|
|
172
|
+
zip = await JSZip.loadAsync(buf);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
fail(`Archive could not be read: ${e?.message ?? e} — a ${KIT_EXT} file is a zip archive.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
|
178
|
+
if (entries.length > LIMITS.entries)
|
|
179
|
+
fail(`Archive refused: ${entries.length} entries > ${LIMITS.entries}.`);
|
|
180
|
+
if (!entries.length) fail('Empty archive: no file.');
|
|
181
|
+
|
|
182
|
+
const budget = { remaining: LIMITS.extractedBytes };
|
|
183
|
+
const files = new Map();
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const rel = safeEntryPath(entry.name);
|
|
186
|
+
if (rel === null) continue;
|
|
187
|
+
files.set(rel, await readEntry(entry, budget));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// the manifest must be at the ROOT: an archive that wraps its kit in a
|
|
191
|
+
// directory (the reflex of many compression tools) is a frequent and honest
|
|
192
|
+
// case — saying so precisely is worth more than an "invalid"
|
|
193
|
+
const raw = files.get(KIT_MANIFEST);
|
|
194
|
+
if (!raw) {
|
|
195
|
+
const elsewhere = [...files.keys()].find((f) => f.endsWith(`/${KIT_MANIFEST}`));
|
|
196
|
+
fail(
|
|
197
|
+
elsewhere
|
|
198
|
+
? `Archive refused: ${KIT_MANIFEST} found in "${elsewhere}" and not at the root. Compress the CONTENTS of the kit, not the directory that holds it.`
|
|
199
|
+
: `Archive refused: no ${KIT_MANIFEST} at the root.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let json;
|
|
204
|
+
try {
|
|
205
|
+
json = JSON.parse(raw.toString('utf8'));
|
|
206
|
+
} catch (e) {
|
|
207
|
+
fail(`Archive refused: ${KIT_MANIFEST} — invalid JSON (${e?.message ?? e}).`);
|
|
208
|
+
}
|
|
209
|
+
const { manifest, diagnostics } = parseKitManifest(json, { where: `${KIT_MANIFEST} (archive)` });
|
|
210
|
+
if (!manifest) {
|
|
211
|
+
const err = diagnostics.find((d) => d.severity === 'error');
|
|
212
|
+
fail(`Archive refused: ${err?.message ?? 'invalid manifest'}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { manifest, files, digest: sha256(buf), diagnostics };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Writing an archive
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Packs the kit directory `dir` into a `.deckkit` archive.
|
|
224
|
+
*
|
|
225
|
+
* Applies the SAME rules as extraction: what we would refuse to install must
|
|
226
|
+
* not be producible here — otherwise the author of a kit discovers the refusal
|
|
227
|
+
* at their users' end rather than at their own.
|
|
228
|
+
*
|
|
229
|
+
* @returns {Promise<{ buffer: Buffer, manifest: object, entries: string[], skipped: string[] }>}
|
|
230
|
+
*/
|
|
231
|
+
export async function packKit(dir) {
|
|
232
|
+
const root = path.resolve(dir);
|
|
233
|
+
const manifestPath = path.join(root, KIT_MANIFEST);
|
|
234
|
+
if (!fs.existsSync(manifestPath))
|
|
235
|
+
fail(`${root} contains no ${KIT_MANIFEST} — this is not a kit.`);
|
|
236
|
+
|
|
237
|
+
let json;
|
|
238
|
+
try {
|
|
239
|
+
json = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
240
|
+
} catch (e) {
|
|
241
|
+
fail(`${KIT_MANIFEST} could not be read: ${e?.message ?? e}`);
|
|
242
|
+
}
|
|
243
|
+
const { manifest } = parseKitManifest(json, { where: manifestPath });
|
|
244
|
+
if (!manifest) fail(`${KIT_MANIFEST} invalid — fix the manifest before packing.`);
|
|
245
|
+
|
|
246
|
+
const zip = new JSZip();
|
|
247
|
+
const entries = [];
|
|
248
|
+
const skipped = [];
|
|
249
|
+
let total = 0;
|
|
250
|
+
|
|
251
|
+
const walk = (abs, rel) => {
|
|
252
|
+
for (const e of fs
|
|
253
|
+
.readdirSync(abs, { withFileTypes: true })
|
|
254
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
255
|
+
const childAbs = path.join(abs, e.name);
|
|
256
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
257
|
+
// a symbolic link would be followed outside the kit: never packed
|
|
258
|
+
if (e.isSymbolicLink()) {
|
|
259
|
+
skipped.push(`${childRel} (symbolic link)`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// kit DEVELOPMENT tooling: never in the archive. Without this, a kit
|
|
263
|
+
// that has its own tests carries all of node_modules — including the
|
|
264
|
+
// dependencies' package.json files, which the ".json" allowlist lets
|
|
265
|
+
// through without blinking. Silent: this is not "skipped", it is beside
|
|
266
|
+
// the point, and reporting it would drown the real warnings.
|
|
267
|
+
if (e.isDirectory() && SKIP_DIRS.has(e.name)) continue;
|
|
268
|
+
if (e.isDirectory()) {
|
|
269
|
+
walk(childAbs, childRel);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!e.isFile()) {
|
|
273
|
+
skipped.push(`${childRel} (special file)`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!ALLOWED_EXT.has(path.extname(e.name).toLowerCase())) {
|
|
277
|
+
skipped.push(`${childRel} (extension not allowed)`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const buf = fs.readFileSync(childAbs);
|
|
281
|
+
total += buf.length;
|
|
282
|
+
if (total > LIMITS.extractedBytes)
|
|
283
|
+
fail(`Kit too large: > ${Math.round(LIMITS.extractedBytes / 1024 / 1024)} MB.`);
|
|
284
|
+
if (entries.length >= LIMITS.entries) fail(`Kit has too many files: > ${LIMITS.entries}.`);
|
|
285
|
+
zip.file(childRel, buf);
|
|
286
|
+
entries.push(childRel);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
walk(root, '');
|
|
290
|
+
|
|
291
|
+
// fixed date: two packings of the same content give the same bytes, hence
|
|
292
|
+
// the same digest — otherwise the published SHA-256 changes on every
|
|
293
|
+
// `kit create` and no longer means anything
|
|
294
|
+
const buffer = await zip.generateAsync({
|
|
295
|
+
type: 'nodebuffer',
|
|
296
|
+
compression: 'DEFLATE',
|
|
297
|
+
compressionOptions: { level: 9 },
|
|
298
|
+
date: new Date(0),
|
|
299
|
+
});
|
|
300
|
+
if (buffer.length > LIMITS.archiveBytes)
|
|
301
|
+
fail(`Produced archive too large: ${Math.round(buffer.length / 1024 / 1024)} MB.`);
|
|
302
|
+
return { buffer, manifest, entries, skipped };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Download
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Downloads an archive over HTTPS. Refuses every other protocol, including
|
|
311
|
+
* after a redirect: an https URL that sends you to file:// or http:// is an
|
|
312
|
+
* attack, not a convenience.
|
|
313
|
+
*
|
|
314
|
+
* The SAME address guard as remote images (remoteUrlRefusal: DNS resolution +
|
|
315
|
+
* refusal of private/local addresses) is replayed before every connection and
|
|
316
|
+
* at every redirect hop — without it, an https URL pointing at 127.0.0.1 or an
|
|
317
|
+
* internal address would serve as a probe of the victim's network. The https
|
|
318
|
+
* check below applies in addition.
|
|
319
|
+
*
|
|
320
|
+
* @returns {Promise<Buffer>}
|
|
321
|
+
*/
|
|
322
|
+
export function fetchKitArchive(url, { redirectsLeft = LIMITS.redirects } = {}) {
|
|
323
|
+
let parsed;
|
|
324
|
+
try {
|
|
325
|
+
parsed = new URL(url);
|
|
326
|
+
} catch {
|
|
327
|
+
return Promise.reject(new KitArchiveError(`Invalid URL: ${url}`));
|
|
328
|
+
}
|
|
329
|
+
if (parsed.protocol !== 'https:')
|
|
330
|
+
return Promise.reject(
|
|
331
|
+
new KitArchiveError(
|
|
332
|
+
`Protocol refused: ${parsed.protocol}// — only https is accepted for installing a kit.`,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return remoteUrlRefusal(url).then(
|
|
337
|
+
(refusal) =>
|
|
338
|
+
new Promise((resolve, reject) => {
|
|
339
|
+
if (refusal) {
|
|
340
|
+
reject(new KitArchiveError(`Address refused: ${refusal}`));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const req = https.get(parsed, (res) => {
|
|
344
|
+
const { statusCode, headers } = res;
|
|
345
|
+
|
|
346
|
+
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
347
|
+
res.resume(); // release the socket
|
|
348
|
+
if (redirectsLeft <= 0) {
|
|
349
|
+
reject(new KitArchiveError(`Too many redirects (> ${LIMITS.redirects}): ${url}`));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const next = new URL(headers.location, parsed).toString();
|
|
353
|
+
resolve(fetchKitArchive(next, { redirectsLeft: redirectsLeft - 1 }));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (statusCode !== 200) {
|
|
357
|
+
res.resume();
|
|
358
|
+
reject(new KitArchiveError(`Download failed: HTTP ${statusCode} — ${url}`));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Content-Length is only an announcement: refusing early avoids the
|
|
363
|
+
// transfer, the running total below is what actually protects
|
|
364
|
+
const declared = Number(headers['content-length']);
|
|
365
|
+
if (Number.isFinite(declared) && declared > LIMITS.archiveBytes) {
|
|
366
|
+
res.destroy();
|
|
367
|
+
reject(
|
|
368
|
+
new KitArchiveError(
|
|
369
|
+
`Archive refused: ${Math.round(declared / 1024 / 1024)} MB announced > ${Math.round(LIMITS.archiveBytes / 1024 / 1024)} MB.`,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const chunks = [];
|
|
376
|
+
let size = 0;
|
|
377
|
+
res.on('data', (c) => {
|
|
378
|
+
size += c.length;
|
|
379
|
+
if (size > LIMITS.archiveBytes) {
|
|
380
|
+
res.destroy();
|
|
381
|
+
reject(
|
|
382
|
+
new KitArchiveError(
|
|
383
|
+
`Archive refused: download > ${Math.round(LIMITS.archiveBytes / 1024 / 1024)} MB, interrupted.`,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
chunks.push(c);
|
|
389
|
+
});
|
|
390
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
391
|
+
res.on('error', reject);
|
|
392
|
+
});
|
|
393
|
+
req.on('error', (e) => reject(new KitArchiveError(`Download failed: ${e?.message ?? e}`)));
|
|
394
|
+
req.setTimeout(30_000, () => {
|
|
395
|
+
req.destroy();
|
|
396
|
+
reject(new KitArchiveError(`Download failed: timed out (30 s) — ${url}`));
|
|
397
|
+
});
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Installation
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Installs an already-read archive under `kitsDir/<manifest.name>`.
|
|
408
|
+
*
|
|
409
|
+
* The installation name comes from the MANIFEST, never from the file name: the
|
|
410
|
+
* latter is chosen by whoever distributes the archive, and `parseKitManifest`
|
|
411
|
+
* has already proved that `manifest.name` cannot traverse the file system.
|
|
412
|
+
*
|
|
413
|
+
* @returns {{ dir: string, replaced: boolean }}
|
|
414
|
+
*/
|
|
415
|
+
export function installKitArchive({ manifest, files, digest }, kitsDir, { force = false } = {}) {
|
|
416
|
+
const dest = path.join(path.resolve(kitsDir), manifest.name);
|
|
417
|
+
const replaced = fs.existsSync(dest);
|
|
418
|
+
if (replaced && !force)
|
|
419
|
+
fail(
|
|
420
|
+
`The kit "${manifest.name}" is already installed (${dest}). Re-run with --force to replace it.`,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
424
|
+
// NEIGHBORING temporary directory: a rename() is atomic only within the same
|
|
425
|
+
// file system, which /tmp does not guarantee
|
|
426
|
+
const staging = fs.mkdtempSync(`${dest}.tmp-`);
|
|
427
|
+
try {
|
|
428
|
+
for (const [rel, buf] of files) {
|
|
429
|
+
const abs = path.join(staging, rel);
|
|
430
|
+
// belt AND braces: safeEntryPath has already judged every path, we
|
|
431
|
+
// re-check against the REAL directory before writing
|
|
432
|
+
const r = path.relative(staging, abs);
|
|
433
|
+
if (r.startsWith('..') || path.isAbsolute(r)) fail(`Entry refused at write time: "${rel}".`);
|
|
434
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
435
|
+
fs.writeFileSync(abs, buf);
|
|
436
|
+
}
|
|
437
|
+
fs.writeFileSync(path.join(staging, '.integrity'), `${digest}\n`);
|
|
438
|
+
|
|
439
|
+
if (replaced) fs.rmSync(dest, { recursive: true, force: true });
|
|
440
|
+
fs.renameSync(staging, dest);
|
|
441
|
+
return { dir: dest, replaced };
|
|
442
|
+
} catch (e) {
|
|
443
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
444
|
+
throw e;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export { KitArchiveError };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection of the entrance animations into the .pptx (post-processing).
|
|
3
|
+
*
|
|
4
|
+
* PptxGenJS cannot produce animations, but the OOXML format describes them
|
|
5
|
+
* in the `<p:timing>` tree of each slideN.xml. So we reopen the zip written
|
|
6
|
+
* by PptxGenJS and inject into it, for every animated slide, a main "on
|
|
7
|
+
* click" sequence of native entrance effects (the very XML PowerPoint
|
|
8
|
+
* writes for itself):
|
|
9
|
+
* - one step = one click; all the objects of a step appear together
|
|
10
|
+
* (the first as clickEffect, the following ones as withEffect);
|
|
11
|
+
* - a bullet list is built "by paragraph" (`<p:bldP build="p">` + one
|
|
12
|
+
* `<p:pRg>` range per item): each bullet appears on its own click,
|
|
13
|
+
* exactly like PowerPoint's native animation;
|
|
14
|
+
* - the effect is chosen by the semantics of the block (PRESET_BY_KIND):
|
|
15
|
+
* fade for text, wipe for the panels of structured layouts, zoom for
|
|
16
|
+
* timeline milestones and metrics — unless an effect is imposed on the
|
|
17
|
+
* slide by `<!-- animate: fade|wipe|zoom|appear -->`.
|
|
18
|
+
*
|
|
19
|
+
* Objects are matched to shapes by order of appearance in the `<p:spTree>`:
|
|
20
|
+
* the renderer records one entry per addText / addShape / addImage /
|
|
21
|
+
* addTable call (null = chrome that is not animated), in the exact order in
|
|
22
|
+
* which PptxGenJS writes the shapes. The page-number placeholder
|
|
23
|
+
* (type="sldNum"), added by the master, is excluded from the count. At the
|
|
24
|
+
* slightest structural mismatch, the slide is left exactly as it is —
|
|
25
|
+
* never a broken .pptx.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import JSZip from 'jszip';
|
|
30
|
+
|
|
31
|
+
/** Effect per block type (default: fade). A panel is revealed by a wipe
|
|
32
|
+
* upwards, a milestone or a metric bursts in with a zoom — the brand's
|
|
33
|
+
* visual hierarchy, translated into movement. */
|
|
34
|
+
export const PRESET_BY_KIND = {
|
|
35
|
+
panel: 'wipe',
|
|
36
|
+
'timeline-dot': 'zoom',
|
|
37
|
+
metric: 'zoom',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const presetFor = (kind, override) => override ?? PRESET_BY_KIND[kind] ?? 'fade';
|
|
41
|
+
|
|
42
|
+
/** Top-level shapes of the spTree, in document order. */
|
|
43
|
+
const SHAPE_RE = /<p:(sp|pic|graphicFrame)>([\s\S]*?)<\/p:\1>/g;
|
|
44
|
+
|
|
45
|
+
/** `cNvPr` ids of the animatable shapes of a slideN.xml, in write order. */
|
|
46
|
+
function shapeIds(xml) {
|
|
47
|
+
const ids = [];
|
|
48
|
+
for (const m of xml.matchAll(SHAPE_RE)) {
|
|
49
|
+
if (m[1] === 'sp' && /type="sldNum"/.test(m[2])) continue; // placeholder from the master
|
|
50
|
+
const id = m[2].match(/<p:cNvPr id="(\d+)"/);
|
|
51
|
+
if (id) ids.push(Number(id[1]));
|
|
52
|
+
}
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Target element of a behaviour: whole shape, or paragraph range. */
|
|
57
|
+
function tgtEl(target) {
|
|
58
|
+
const txEl =
|
|
59
|
+
target.para != null ? `<p:txEl><p:pRg st="${target.para}" end="${target.para}"/></p:txEl>` : '';
|
|
60
|
+
return `<p:tgtEl><p:spTgt spid="${target.spid}">${txEl}</p:spTgt></p:tgtEl>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "Make visible" behaviour of a target (whole shape or paragraph). */
|
|
64
|
+
function setVisible(nid, target) {
|
|
65
|
+
return `<p:set><p:cBhvr><p:cTn id="${nid()}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>${tgtEl(target)}<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Filtered entrance transition (fade, wipe) on a target. */
|
|
69
|
+
function animEffect(nid, target, filter, dur = 400) {
|
|
70
|
+
return `<p:animEffect transition="in" filter="${filter}"><p:cBhvr><p:cTn id="${nid()}" dur="${dur}"/>${tgtEl(target)}</p:cBhvr></p:animEffect>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Growth from 40 % to 100 % of a target (zoom effect). */
|
|
74
|
+
function animScale(nid, target, dur = 400) {
|
|
75
|
+
return `<p:animScale><p:cBhvr><p:cTn id="${nid()}" dur="${dur}" fill="hold"/>${tgtEl(target)}</p:cBhvr><p:from x="40000" y="40000"/><p:to x="100000" y="100000"/></p:animScale>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Entrance effects: presetID/presetSubtype = the label shown in
|
|
79
|
+
* PowerPoint's Animations pane; the child behaviours do the effect. */
|
|
80
|
+
const EFFECTS = {
|
|
81
|
+
appear: { presetID: 1, subtype: 0, extra: () => '' },
|
|
82
|
+
fade: { presetID: 10, subtype: 0, extra: (nid, t) => animEffect(nid, t, 'fade') },
|
|
83
|
+
wipe: { presetID: 22, subtype: 1, extra: (nid, t) => animEffect(nid, t, 'wipe(up)') },
|
|
84
|
+
zoom: {
|
|
85
|
+
presetID: 23,
|
|
86
|
+
subtype: 0,
|
|
87
|
+
extra: (nid, t) => animEffect(nid, t, 'fade') + animScale(nid, t),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Entrance effect of a target, according to its preset (resolved by presetFor). */
|
|
92
|
+
function effectPar(nid, target, nodeType) {
|
|
93
|
+
const fx = EFFECTS[target.preset] ?? EFFECTS.appear;
|
|
94
|
+
return `<p:par><p:cTn id="${nid()}" presetID="${fx.presetID}" presetClass="entr" presetSubtype="${fx.subtype}" fill="hold" grpId="0" nodeType="${nodeType}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${setVisible(nid, target)}${fx.extra(nid, target)}</p:childTnLst></p:cTn></p:par>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One click: all the targets of the step appear together. */
|
|
98
|
+
function clickPar(nid, targets) {
|
|
99
|
+
const effects = targets
|
|
100
|
+
.map((t, k) => effectPar(nid, t, k === 0 ? 'clickEffect' : 'withEffect'))
|
|
101
|
+
.join('');
|
|
102
|
+
return `<p:par><p:cTn id="${nid()}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="${nid()}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${effects}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Complete `<p:timing>` tree of a slide. */
|
|
106
|
+
function timingXml(stepTargets) {
|
|
107
|
+
let id = 0;
|
|
108
|
+
const nid = () => ++id;
|
|
109
|
+
const rootId = nid();
|
|
110
|
+
const seqId = nid();
|
|
111
|
+
const clicks = stepTargets.map((targets) => clickPar(nid, targets)).join('');
|
|
112
|
+
|
|
113
|
+
// <p:bldLst>: build mode per shape — "by paragraph" for lists, whole shape
|
|
114
|
+
// (background included) for the rest
|
|
115
|
+
const byPara = new Map();
|
|
116
|
+
for (const targets of stepTargets)
|
|
117
|
+
for (const t of targets) byPara.set(t.spid, (byPara.get(t.spid) ?? false) || t.para != null);
|
|
118
|
+
const bld = [...byPara]
|
|
119
|
+
.map(([spid, p]) =>
|
|
120
|
+
p
|
|
121
|
+
? `<p:bldP spid="${spid}" grpId="0" build="p"/>`
|
|
122
|
+
: `<p:bldP spid="${spid}" grpId="0" animBg="1"/>`,
|
|
123
|
+
)
|
|
124
|
+
.join('');
|
|
125
|
+
|
|
126
|
+
return `<p:timing><p:tnLst><p:par><p:cTn id="${rootId}" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="${seqId}" dur="indefinite" nodeType="mainSeq"><p:childTnLst>${clicks}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst>${bld}</p:bldLst></p:timing>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Injects the entrance animations into a .pptx already written to disk.
|
|
131
|
+
* Idempotent; any slide with an unexpected structure is ignored.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} pptxPath path of the .pptx to modify in place
|
|
134
|
+
* @param {Map<number, {entries: Array<null|{step:number, paras?:number, kind?:string}>, preset: string|null}>} slideAnims
|
|
135
|
+
* by slide number (1-based): `entries` = one entry per shape written
|
|
136
|
+
* (in the order of the addText/addShape/addImage/addTable calls) —
|
|
137
|
+
* null = chrome that is not animated; {step, kind} = entrance step
|
|
138
|
+
* and block type (choice of the effect); {step, paras: n} = text
|
|
139
|
+
* built by paragraph (n items); `preset` = effect imposed on the
|
|
140
|
+
* slide (otherwise PRESET_BY_KIND decides per shape).
|
|
141
|
+
* @returns {Promise<{count:number, warnings:string[]}>} animated slides and
|
|
142
|
+
* warnings — every slide left untouched is reported, never
|
|
143
|
+
* silently ignored.
|
|
144
|
+
*/
|
|
145
|
+
export async function embedAnimations(pptxPath, slideAnims) {
|
|
146
|
+
const warnings = [];
|
|
147
|
+
if (!slideAnims?.size) return { count: 0, warnings };
|
|
148
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
|
|
149
|
+
let done = 0;
|
|
150
|
+
|
|
151
|
+
for (const [n, { entries, preset }] of slideAnims) {
|
|
152
|
+
const name = `ppt/slides/slide${n}.xml`;
|
|
153
|
+
const file = zip.file(name);
|
|
154
|
+
if (!file) {
|
|
155
|
+
warnings.push(`slide ${n}: ${name} missing from the .pptx — animations ignored`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const xml = await file.async('string');
|
|
159
|
+
if (xml.includes('<p:timing>')) continue; // already done (idempotence, not a failure)
|
|
160
|
+
|
|
161
|
+
const ids = shapeIds(xml);
|
|
162
|
+
if (ids.length !== entries.length) {
|
|
163
|
+
// unexpected structure: break nothing, but say so
|
|
164
|
+
warnings.push(
|
|
165
|
+
`slide ${n}: unexpected structure (${ids.length} shapes in the XML, ` +
|
|
166
|
+
`${entries.length} expected) — animations ignored`,
|
|
167
|
+
);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// step → targets (one bullet = one step; several shapes = the same click)
|
|
172
|
+
const stepTargets = [];
|
|
173
|
+
entries.forEach((entry, k) => {
|
|
174
|
+
if (!entry) return;
|
|
175
|
+
const fx = presetFor(entry.kind, preset);
|
|
176
|
+
if (entry.paras > 1) {
|
|
177
|
+
for (let p = 0; p < entry.paras; p++)
|
|
178
|
+
(stepTargets[entry.step + p] ??= []).push({ spid: ids[k], para: p, preset: fx });
|
|
179
|
+
} else {
|
|
180
|
+
(stepTargets[entry.step] ??= []).push({ spid: ids[k], preset: fx });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const steps = stepTargets.filter(Boolean);
|
|
184
|
+
if (!steps.length) continue;
|
|
185
|
+
|
|
186
|
+
zip.file(name, xml.replace('</p:sld>', `${timingXml(steps)}</p:sld>`));
|
|
187
|
+
done++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (done)
|
|
191
|
+
fs.writeFileSync(
|
|
192
|
+
pptxPath,
|
|
193
|
+
await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
|
|
194
|
+
);
|
|
195
|
+
return { count: done, warnings };
|
|
196
|
+
}
|