gutterpress 0.11.21 → 0.11.22-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/api/index.d.ts +5 -1
- package/dist/api/index.js +68 -47
- package/dist/{build-0neyk5h6.js → build-84retk4k.js} +16 -10
- package/dist/{index-sth219v1.js → cli-2ppw6q9v.js} +26 -6
- package/dist/cli-3twpesca.js +103 -0
- package/dist/cli-5et23z83.js +2089 -0
- package/dist/{cli-j79451n6.js → cli-5qhjwne5.js} +5 -76
- package/dist/cli-80t93x2r.js +75 -0
- package/dist/{cli-j0drzb6f.js → cli-885pcze8.js} +885 -5481
- package/dist/{cli-b57adna7.js → cli-9pk65fqf.js} +9 -201
- package/dist/{cli-sqkccnm7.js → cli-d8xjns66.js} +1 -102
- package/dist/cli-h3phdstf.js +200 -0
- package/dist/{index-qf7y4d85.js → cli-habkc5ng.js} +12 -258
- package/dist/cli-hndkg7bn.js +196 -0
- package/dist/cli-pn3z0y9p.js +3073 -0
- package/dist/cli.js +15 -13
- package/dist/{doctor-fptq8tvn.js → doctor-fmzpg7ag.js} +7 -4
- package/dist/engine/shared/print-area.d.ts +77 -1
- package/dist/{engine-x07q927h.js → engine-5p8tn9b4.js} +4 -3
- package/dist/{engine-0b8bt05n.js → engine-9710cd7x.js} +3 -2
- package/dist/{ext-gp624t5c.js → ext-hsdnad5c.js} +21 -14
- package/dist/extension-restore-05pae3cp.js +16 -0
- package/dist/extension-restore-3r7phztr.js +20 -0
- package/dist/{gutterpress-viewer-w7njnkj7.js → gutterpress-viewer-w9jzx6sb.js} +56 -52
- package/dist/{index-t7r8g75c.js → index-2d8j0enc.js} +4 -2
- package/dist/{cli-sth219v1.js → index-2ppw6q9v.js} +26 -6
- package/dist/{index-mr7zs8mv.js → index-3wcr69ba.js} +5579 -10055
- package/dist/{cli-5qr3z0qa.js → index-93pqkvty.js} +63 -207
- package/dist/{index-mr72vt8n.js → index-apc8w9w8.js} +55 -267
- package/dist/index-ew7cyk7c.js +2208 -0
- package/dist/index-jsz0s3dy.js +213 -0
- package/dist/index-kx03e4m4.js +3244 -0
- package/dist/index-r7jcsk6h.js +200 -0
- package/dist/index.js +71 -50
- package/dist/lib/extension-manifest.d.ts +22 -1
- package/dist/lib/markdown/assemble.d.ts +3 -2
- package/dist/lib/markdown/plugins.d.ts +20 -0
- package/dist/lib/markdown/two-versions.d.ts +32 -0
- package/dist/lib/page-size.d.ts +3 -4
- package/dist/lib/pdf-inspect.d.ts +12 -0
- package/dist/lib/project-scaffold.d.ts +3 -3
- package/dist/lib/publish/plugin-provider.d.ts +16 -0
- package/dist/lib/publish/run-publish.d.ts +13 -5
- package/dist/lib/publish/types.d.ts +34 -2
- package/dist/lib/text-diff.d.ts +43 -0
- package/dist/lib/two-versions-markers.d.ts +26 -0
- package/dist/lib/version-changes.d.ts +87 -0
- package/dist/{manifest.schema-n292sb77.json → manifest.schema-nvawq0xv.json} +2 -2
- package/dist/{new-dre6aw05.js → new-fqtt022k.js} +23 -16
- package/dist/{pdf-inspect-zdmy7qca.js → pdf-inspect-wnw4za71.js} +1 -1
- package/dist/plugins-gfvsgnxb.js +24 -0
- package/dist/plugins-nfxr831f.js +26 -0
- package/dist/{preflight-1k1zkg1p.js → preflight-gr9mscjw.js} +18 -11
- package/dist/{preview-dtrksvxf.js → preview-52w9pdps.js} +19 -13
- package/dist/{publish-g1ytnrmr.js → publish-esmcyhpb.js} +40 -23
- package/dist/render.js +706 -0
- package/dist/schema/manifest.types.d.ts +8 -3
- package/dist/{source-provider-pzmz0br9.js → source-provider-2r74a7wp.js} +2 -1
- package/dist/{source-provider-qbthnmqw.js → source-provider-qbfknhyy.js} +2 -1
- package/dist/{validate-yrrq8639.js → validate-vt1d7h6k.js} +16 -10
- package/package.json +1 -1
|
@@ -0,0 +1,2208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXTENSION_MANIFEST_FILENAME,
|
|
3
|
+
TWO_VERSIONS_OPEN,
|
|
4
|
+
extensionCarries,
|
|
5
|
+
extensionStyleList,
|
|
6
|
+
loadPlugin,
|
|
7
|
+
log,
|
|
8
|
+
prettify,
|
|
9
|
+
readExtensionMeta,
|
|
10
|
+
resolveExtension,
|
|
11
|
+
slugify
|
|
12
|
+
} from "./index-kx03e4m4.js";
|
|
13
|
+
import {
|
|
14
|
+
BuildError,
|
|
15
|
+
EXIT_CODES,
|
|
16
|
+
getAssetsDir
|
|
17
|
+
} from "./index-jsz0s3dy.js";
|
|
18
|
+
import {
|
|
19
|
+
PLUGINS_DIR,
|
|
20
|
+
VENDORED_NPM_DIR,
|
|
21
|
+
assertWindowsSafeRelativePath,
|
|
22
|
+
isExactNpmVersion,
|
|
23
|
+
parseNpmPluginSpec,
|
|
24
|
+
resolvePackageEntry,
|
|
25
|
+
resolveVendoredPluginInstallRoot,
|
|
26
|
+
vendoredNpmPluginPackageDir,
|
|
27
|
+
vendoredNpmPluginRoot,
|
|
28
|
+
windowsPathKey
|
|
29
|
+
} from "./index-r7jcsk6h.js";
|
|
30
|
+
import {
|
|
31
|
+
__require
|
|
32
|
+
} from "./index-37x76zdn.js";
|
|
33
|
+
|
|
34
|
+
// src/lib/extension-restore.ts
|
|
35
|
+
import path5 from "node:path";
|
|
36
|
+
|
|
37
|
+
// src/lib/fetch-timeout.ts
|
|
38
|
+
class FetchPassthroughError extends Error {
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class FriendlyHttpError extends FetchPassthroughError {
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class FetchUnavailableError extends Error {
|
|
45
|
+
}
|
|
46
|
+
function composeSignal(signal, timeoutMs) {
|
|
47
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
48
|
+
return {
|
|
49
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
50
|
+
timeout
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function withFetchTimeout(options, run) {
|
|
54
|
+
const composed = composeSignal(options.signal, options.timeoutMs);
|
|
55
|
+
try {
|
|
56
|
+
return await run(composed.signal);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
if (cause instanceof FetchPassthroughError)
|
|
59
|
+
throw cause;
|
|
60
|
+
if (options.signal?.aborted)
|
|
61
|
+
throw cause;
|
|
62
|
+
const offline = typeof options.offlineMessage === "function" ? options.offlineMessage(cause) : options.offlineMessage;
|
|
63
|
+
const timedOut = composed.timeout.aborted || cause instanceof Error && cause.name === "TimeoutError";
|
|
64
|
+
if (cause instanceof Error && cause.name === "AbortError" && !timedOut)
|
|
65
|
+
throw cause;
|
|
66
|
+
throw new FetchUnavailableError(timedOut ? options.timeoutMessage ?? offline : offline, { cause });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/lib/extension-manager.ts
|
|
71
|
+
import { cp, mkdir as mkdir3, readdir, readFile as readFile5, rm as rm3, stat } from "node:fs/promises";
|
|
72
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
73
|
+
import path4 from "node:path";
|
|
74
|
+
import { isSeq as isSeq2, isMap, isScalar, YAMLMap, Scalar } from "yaml";
|
|
75
|
+
|
|
76
|
+
// src/lib/manifest-doc.ts
|
|
77
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
78
|
+
import { randomUUID } from "node:crypto";
|
|
79
|
+
import { mkdir, open, readFile as readFile2, rename, rm } from "node:fs/promises";
|
|
80
|
+
import path from "node:path";
|
|
81
|
+
import { parseDocument, isSeq, YAMLSeq } from "yaml";
|
|
82
|
+
|
|
83
|
+
// src/lib/manifest.ts
|
|
84
|
+
import { readFile } from "node:fs/promises";
|
|
85
|
+
import { existsSync, statSync } from "node:fs";
|
|
86
|
+
import { resolve, dirname } from "node:path";
|
|
87
|
+
import { parse as parseYaml, YAMLParseError } from "yaml";
|
|
88
|
+
|
|
89
|
+
// src/lib/extension-specifier.ts
|
|
90
|
+
var BUNDLED_EXTENSIONS = [
|
|
91
|
+
"markdown-it-mark",
|
|
92
|
+
"markdown-it-sub",
|
|
93
|
+
"markdown-it-sup",
|
|
94
|
+
"markdown-it-abbr",
|
|
95
|
+
"gutterpress-gfm-alerts"
|
|
96
|
+
];
|
|
97
|
+
function isBundledExtension(name) {
|
|
98
|
+
return BUNDLED_EXTENSIONS.includes(name);
|
|
99
|
+
}
|
|
100
|
+
function isPathSpecifier(use) {
|
|
101
|
+
return use.startsWith("./") || use.startsWith("../") || use.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(use);
|
|
102
|
+
}
|
|
103
|
+
function parseExtensionSpecifier(use) {
|
|
104
|
+
const spec = use.trim();
|
|
105
|
+
if (!spec) {
|
|
106
|
+
throw new Error("An extension specifier is required — an npm package name, or a ./ path.");
|
|
107
|
+
}
|
|
108
|
+
if (isPathSpecifier(spec))
|
|
109
|
+
return { kind: "path", path: spec };
|
|
110
|
+
if (/\.(m?js|cjs)$/i.test(spec) || !spec.startsWith("@") && spec.includes("/")) {
|
|
111
|
+
throw new Error(`"${spec}" looks like a path — write it as "./${spec}". A specifier that does not ` + "start with ./, ../ or / is an npm package name.");
|
|
112
|
+
}
|
|
113
|
+
const parsed = parseNpmPluginSpec(spec);
|
|
114
|
+
if (isBundledExtension(parsed.name)) {
|
|
115
|
+
if (parsed.selector !== undefined) {
|
|
116
|
+
throw new Error(`"${parsed.name}" is bundled with Gutterpress and always resolves to the bundled copy — ` + `drop "@${parsed.selector}".`);
|
|
117
|
+
}
|
|
118
|
+
return { kind: "bundled", name: parsed.name };
|
|
119
|
+
}
|
|
120
|
+
return parsed.selector === undefined ? { kind: "npm", name: parsed.name } : { kind: "npm", name: parsed.name, version: parsed.selector };
|
|
121
|
+
}
|
|
122
|
+
function pinnedNpmSpecifier(name, version) {
|
|
123
|
+
return `${name}@${version}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/constants.ts
|
|
127
|
+
var NETWORK = {
|
|
128
|
+
DEFAULT_PORT: 3579
|
|
129
|
+
};
|
|
130
|
+
var DEBOUNCE = {
|
|
131
|
+
FILE_WATCH: 100
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// src/lib/cli-args.ts
|
|
135
|
+
class UsageError extends Error {
|
|
136
|
+
exitCode;
|
|
137
|
+
constructor(message, exitCode = EXIT_CODES.USAGE) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "UsageError";
|
|
140
|
+
this.exitCode = exitCode;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function resolvePort(raw) {
|
|
144
|
+
if (raw === undefined)
|
|
145
|
+
return NETWORK.DEFAULT_PORT;
|
|
146
|
+
if (raw === "" || typeof raw === "string" && raw.trim() === "") {
|
|
147
|
+
throw new UsageError("--port requires a value.");
|
|
148
|
+
}
|
|
149
|
+
const n = typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN;
|
|
150
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 65535) {
|
|
151
|
+
throw new UsageError(`Invalid --port value: "${raw}". Expected an integer from 0 to 65535 (0 = OS-assigned).`);
|
|
152
|
+
}
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/lib/presets.ts
|
|
157
|
+
var PRESET_IDS = ["dtrpg", "book", "custom"];
|
|
158
|
+
var DTRPG_PRESET = {
|
|
159
|
+
id: "dtrpg",
|
|
160
|
+
spec: {
|
|
161
|
+
label: "DriveThruRPG — Quick Specifications for Print Books",
|
|
162
|
+
url: "https://help.drivethrupartners.com/hc/en-us/articles/12780800178583-Quick-Specifications-for-Print-Books"
|
|
163
|
+
},
|
|
164
|
+
defaultTargets: ["dtrpg"],
|
|
165
|
+
extensions: [],
|
|
166
|
+
source: {
|
|
167
|
+
files: null
|
|
168
|
+
},
|
|
169
|
+
print: { signature: 4, reserveLastPage: true, lastPage: undefined },
|
|
170
|
+
pdfx: {
|
|
171
|
+
flavor: "x1a",
|
|
172
|
+
icc: "profiles/CGATS21_CRPC1.icc",
|
|
173
|
+
stripAnnotations: true,
|
|
174
|
+
blackText: "k-only",
|
|
175
|
+
transparency: "flatten-page",
|
|
176
|
+
flattenPages: [],
|
|
177
|
+
rasterDpi: undefined
|
|
178
|
+
},
|
|
179
|
+
page: {
|
|
180
|
+
width: 621,
|
|
181
|
+
height: 810,
|
|
182
|
+
tolerance: 0.5,
|
|
183
|
+
bleed: 9,
|
|
184
|
+
safe: 36,
|
|
185
|
+
artSafe: 18,
|
|
186
|
+
gutter: 36
|
|
187
|
+
},
|
|
188
|
+
ink: {
|
|
189
|
+
maxTac: 240,
|
|
190
|
+
tacTolerance: 0.5
|
|
191
|
+
},
|
|
192
|
+
lint: {
|
|
193
|
+
enabled: true,
|
|
194
|
+
configPath: null,
|
|
195
|
+
rules: {}
|
|
196
|
+
},
|
|
197
|
+
validate: {
|
|
198
|
+
enabled: true,
|
|
199
|
+
checks: {
|
|
200
|
+
"pdf.structure.qpdf": {
|
|
201
|
+
enabled: true,
|
|
202
|
+
severity: "error"
|
|
203
|
+
},
|
|
204
|
+
"pdf.print.pdfx-markers": {
|
|
205
|
+
enabled: true,
|
|
206
|
+
severity: "error"
|
|
207
|
+
},
|
|
208
|
+
"pdf.print.pdfx-metadata": {
|
|
209
|
+
enabled: true,
|
|
210
|
+
severity: "error"
|
|
211
|
+
},
|
|
212
|
+
"pdf.print.embedded-fonts": {
|
|
213
|
+
enabled: true,
|
|
214
|
+
severity: "error"
|
|
215
|
+
},
|
|
216
|
+
"pdf.print.safe-margins": {
|
|
217
|
+
enabled: true,
|
|
218
|
+
severity: "error"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
source: {
|
|
222
|
+
markdownlint: null,
|
|
223
|
+
htmlhint: null,
|
|
224
|
+
stylelint: null,
|
|
225
|
+
cssOwnership: null
|
|
226
|
+
},
|
|
227
|
+
assets: {
|
|
228
|
+
maxImageSize: 1e7,
|
|
229
|
+
allowAlpha: false,
|
|
230
|
+
approvedFontFiles: [],
|
|
231
|
+
requireFontLicense: false
|
|
232
|
+
},
|
|
233
|
+
pdf: {
|
|
234
|
+
requireBookmarks: false,
|
|
235
|
+
requireTocLinks: false,
|
|
236
|
+
minImageResolution: 300,
|
|
237
|
+
forbidTransparency: true,
|
|
238
|
+
requireBleed: false,
|
|
239
|
+
bleedSize: 9
|
|
240
|
+
},
|
|
241
|
+
heuristics: {
|
|
242
|
+
maxDecorativeLayers: 5,
|
|
243
|
+
textDensityRange: { min: 200, max: 5000 },
|
|
244
|
+
maxParagraphsPerSection: 10
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
var BOOK_PRESET = {
|
|
249
|
+
id: "book",
|
|
250
|
+
defaultTargets: [],
|
|
251
|
+
extensions: [],
|
|
252
|
+
source: {
|
|
253
|
+
files: null
|
|
254
|
+
},
|
|
255
|
+
print: { signature: 1, reserveLastPage: false, lastPage: undefined },
|
|
256
|
+
pdfx: {
|
|
257
|
+
flavor: "x1a",
|
|
258
|
+
icc: "profiles/CGATS21_CRPC1.icc",
|
|
259
|
+
stripAnnotations: true,
|
|
260
|
+
blackText: "icc",
|
|
261
|
+
transparency: "flatten-page",
|
|
262
|
+
flattenPages: [],
|
|
263
|
+
rasterDpi: undefined
|
|
264
|
+
},
|
|
265
|
+
page: {
|
|
266
|
+
width: 432,
|
|
267
|
+
height: 648,
|
|
268
|
+
tolerance: 0.5,
|
|
269
|
+
bleed: undefined,
|
|
270
|
+
safe: 18,
|
|
271
|
+
artSafe: undefined,
|
|
272
|
+
gutter: undefined
|
|
273
|
+
},
|
|
274
|
+
ink: {
|
|
275
|
+
maxTac: 400,
|
|
276
|
+
tacTolerance: 0.5
|
|
277
|
+
},
|
|
278
|
+
lint: {
|
|
279
|
+
enabled: true,
|
|
280
|
+
configPath: null,
|
|
281
|
+
rules: {}
|
|
282
|
+
},
|
|
283
|
+
validate: {
|
|
284
|
+
enabled: true,
|
|
285
|
+
checks: {
|
|
286
|
+
"pdf.structure.qpdf": {
|
|
287
|
+
enabled: true,
|
|
288
|
+
severity: "error"
|
|
289
|
+
},
|
|
290
|
+
"pdf.print.embedded-fonts": {
|
|
291
|
+
enabled: true,
|
|
292
|
+
severity: "error"
|
|
293
|
+
},
|
|
294
|
+
"pdf.print.safe-margins": {
|
|
295
|
+
enabled: true,
|
|
296
|
+
severity: "warning"
|
|
297
|
+
},
|
|
298
|
+
"pdf.print.pdfx-markers": false,
|
|
299
|
+
"pdf.print.pdfx-metadata": false
|
|
300
|
+
},
|
|
301
|
+
source: {
|
|
302
|
+
markdownlint: null,
|
|
303
|
+
htmlhint: null,
|
|
304
|
+
stylelint: null,
|
|
305
|
+
cssOwnership: null
|
|
306
|
+
},
|
|
307
|
+
assets: {
|
|
308
|
+
maxImageSize: 1e7,
|
|
309
|
+
allowAlpha: true,
|
|
310
|
+
approvedFontFiles: [],
|
|
311
|
+
requireFontLicense: false
|
|
312
|
+
},
|
|
313
|
+
pdf: {
|
|
314
|
+
requireBookmarks: false,
|
|
315
|
+
requireTocLinks: false,
|
|
316
|
+
minImageResolution: 300,
|
|
317
|
+
forbidTransparency: false,
|
|
318
|
+
requireBleed: false,
|
|
319
|
+
bleedSize: 9
|
|
320
|
+
},
|
|
321
|
+
heuristics: {
|
|
322
|
+
maxDecorativeLayers: 5,
|
|
323
|
+
textDensityRange: { min: 200, max: 5000 },
|
|
324
|
+
maxParagraphsPerSection: 10
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
var CUSTOM_PRESET = {
|
|
329
|
+
...BOOK_PRESET,
|
|
330
|
+
id: "custom",
|
|
331
|
+
page: null,
|
|
332
|
+
defaultTargets: []
|
|
333
|
+
};
|
|
334
|
+
var PRESETS = {
|
|
335
|
+
dtrpg: DTRPG_PRESET,
|
|
336
|
+
book: BOOK_PRESET,
|
|
337
|
+
custom: CUSTOM_PRESET
|
|
338
|
+
};
|
|
339
|
+
var warnedIds = new Set;
|
|
340
|
+
function warnOnce(id, message, sink = defaultWarnSink) {
|
|
341
|
+
if (warnedIds.has(id))
|
|
342
|
+
return;
|
|
343
|
+
warnedIds.add(id);
|
|
344
|
+
sink(message);
|
|
345
|
+
}
|
|
346
|
+
function defaultWarnSink(message) {
|
|
347
|
+
console.warn(message);
|
|
348
|
+
}
|
|
349
|
+
function resolvePreset(presetName) {
|
|
350
|
+
if (presetName === undefined) {
|
|
351
|
+
warnOnce("no-preset-set", '[gutterpress] No `preset` in manifest.yaml — using "dtrpg" ' + "(DriveThruRPG print-ready defaults). Set `preset: dtrpg`, `book`, " + "or `custom` to choose explicitly.");
|
|
352
|
+
return DTRPG_PRESET;
|
|
353
|
+
}
|
|
354
|
+
const preset = PRESETS[presetName];
|
|
355
|
+
if (!preset) {
|
|
356
|
+
throw new UsageError(`Unknown preset "${presetName}". Known presets: ${Object.keys(PRESETS).join(", ")}.`);
|
|
357
|
+
}
|
|
358
|
+
return preset;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/lib/targets.ts
|
|
362
|
+
var DTRPG_TARGET = {
|
|
363
|
+
id: "dtrpg",
|
|
364
|
+
label: "DriveThruRPG",
|
|
365
|
+
overlay: {
|
|
366
|
+
pdfx: { flavor: DTRPG_PRESET.pdfx.flavor },
|
|
367
|
+
ink: {
|
|
368
|
+
maxTac: DTRPG_PRESET.ink.maxTac,
|
|
369
|
+
tacTolerance: DTRPG_PRESET.ink.tacTolerance
|
|
370
|
+
},
|
|
371
|
+
validate: {
|
|
372
|
+
checks: {
|
|
373
|
+
"pdf.structure.qpdf": { enabled: true, severity: "error" },
|
|
374
|
+
"pdf.print.pdfx-markers": { enabled: true, severity: "error" },
|
|
375
|
+
"pdf.print.pdfx-metadata": { enabled: true, severity: "error" },
|
|
376
|
+
"pdf.print.embedded-fonts": { enabled: true, severity: "error" },
|
|
377
|
+
"pdf.print.safe-margins": { enabled: true, severity: "error" }
|
|
378
|
+
},
|
|
379
|
+
pdf: {
|
|
380
|
+
forbidTransparency: DTRPG_PRESET.validate.pdf.forbidTransparency,
|
|
381
|
+
minImageResolution: DTRPG_PRESET.validate.pdf.minImageResolution
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
requiredChecks: [
|
|
386
|
+
"pdf.structure.qpdf",
|
|
387
|
+
"pdf.print.pdfx-markers",
|
|
388
|
+
"pdf.print.pdfx-metadata",
|
|
389
|
+
"pdf.print.embedded-fonts",
|
|
390
|
+
"pdf.print.safe-margins"
|
|
391
|
+
],
|
|
392
|
+
requiredTools: ["qpdf", "gs"]
|
|
393
|
+
};
|
|
394
|
+
var ITCH_TARGET = {
|
|
395
|
+
id: "itch",
|
|
396
|
+
label: "itch.io",
|
|
397
|
+
overlay: {
|
|
398
|
+
validate: {
|
|
399
|
+
checks: {
|
|
400
|
+
"pdf.structure.qpdf": { enabled: true, severity: "error" },
|
|
401
|
+
"pdf.print.embedded-fonts": { enabled: true, severity: "error" },
|
|
402
|
+
"pdf.print.pdfx-markers": false,
|
|
403
|
+
"pdf.print.pdfx-metadata": false,
|
|
404
|
+
"pdf.print.ink-coverage": false,
|
|
405
|
+
"pdf.print.safe-margins": false
|
|
406
|
+
},
|
|
407
|
+
assets: {
|
|
408
|
+
allowAlpha: true,
|
|
409
|
+
maxImageSize: 0
|
|
410
|
+
},
|
|
411
|
+
pdf: {
|
|
412
|
+
forbidTransparency: false,
|
|
413
|
+
requireBleed: false,
|
|
414
|
+
minImageResolution: 0
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
requiredChecks: ["pdf.structure.qpdf", "pdf.print.embedded-fonts"],
|
|
419
|
+
requiredTools: []
|
|
420
|
+
};
|
|
421
|
+
var TARGETS = {
|
|
422
|
+
dtrpg: DTRPG_TARGET,
|
|
423
|
+
itch: ITCH_TARGET
|
|
424
|
+
};
|
|
425
|
+
var TARGET_IDS = Object.keys(TARGETS);
|
|
426
|
+
function publishTargetFor(id) {
|
|
427
|
+
const target = TARGETS[id];
|
|
428
|
+
if (!target) {
|
|
429
|
+
throw new UsageError(`Unknown publish target "${id}". Known targets: ${TARGET_IDS.join(", ")}.`);
|
|
430
|
+
}
|
|
431
|
+
return target;
|
|
432
|
+
}
|
|
433
|
+
function resolveTargets(requested, fallback) {
|
|
434
|
+
const ids = requested ?? fallback;
|
|
435
|
+
const seen = new Set;
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const id of ids) {
|
|
438
|
+
publishTargetFor(id);
|
|
439
|
+
if (!seen.has(id)) {
|
|
440
|
+
seen.add(id);
|
|
441
|
+
out.push(id);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
function overlayPreset(preset, target) {
|
|
447
|
+
const o = target.overlay;
|
|
448
|
+
return {
|
|
449
|
+
...preset,
|
|
450
|
+
pdfx: { ...preset.pdfx, ...o.pdfx },
|
|
451
|
+
ink: { ...preset.ink, ...o.ink },
|
|
452
|
+
validate: {
|
|
453
|
+
...preset.validate,
|
|
454
|
+
checks: { ...preset.validate.checks, ...o.validate?.checks },
|
|
455
|
+
assets: { ...preset.validate.assets, ...o.validate?.assets },
|
|
456
|
+
pdf: { ...preset.validate.pdf, ...o.validate?.pdf }
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
var DEFAULT_STRICT_PDF_CHECKS = [
|
|
461
|
+
"pdf.structure.qpdf",
|
|
462
|
+
"pdf.print.pdfx-markers",
|
|
463
|
+
"pdf.print.pdfx-metadata",
|
|
464
|
+
"pdf.print.embedded-fonts"
|
|
465
|
+
];
|
|
466
|
+
function applyDefaultPdfStrictChecks(config) {
|
|
467
|
+
const next = structuredClone(config);
|
|
468
|
+
for (const checkId of DEFAULT_STRICT_PDF_CHECKS) {
|
|
469
|
+
if (next.validate.checks[checkId] === undefined) {
|
|
470
|
+
next.validate.checks[checkId] = { enabled: true, severity: "error" };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/lib/manifest.ts
|
|
477
|
+
var MANIFEST_FILENAMES = [
|
|
478
|
+
"manifest.yaml"
|
|
479
|
+
];
|
|
480
|
+
function hasProjectManifest(dir) {
|
|
481
|
+
return MANIFEST_FILENAMES.some((name) => {
|
|
482
|
+
const candidate = resolve(dir, name);
|
|
483
|
+
try {
|
|
484
|
+
return statSync(candidate).isFile();
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async function loadManifest(pathOrDir) {
|
|
491
|
+
const { manifest } = await loadManifestWithPath(pathOrDir);
|
|
492
|
+
return manifest;
|
|
493
|
+
}
|
|
494
|
+
async function loadManifestWithPath(pathOrDir, opts) {
|
|
495
|
+
const candidates = pathOrDir ? [resolve(pathOrDir), ...MANIFEST_FILENAMES.map((name) => resolve(pathOrDir, name))] : MANIFEST_FILENAMES.map((name) => resolve(name));
|
|
496
|
+
for (const p of candidates) {
|
|
497
|
+
if (existsSync(p) && statSync(p).isFile()) {
|
|
498
|
+
const raw = await readFile(p, "utf8");
|
|
499
|
+
if (raw.split(/\r?\n/).includes(TWO_VERSIONS_OPEN)) {
|
|
500
|
+
throw new UsageError(`"${p}" has two versions of a setting — yours and the online version — left by a sync. Open it, keep the one you want and delete the marker lines.`);
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const manifest = parseYaml(raw) ?? {};
|
|
504
|
+
return { manifest, manifestDir: dirname(p), manifestPath: p };
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (!(error instanceof YAMLParseError))
|
|
507
|
+
throw error;
|
|
508
|
+
const position = error.linePos?.[0];
|
|
509
|
+
const location = position ? ` at line ${position.line}, column ${position.col}` : "";
|
|
510
|
+
const summary = (error.message.split(`
|
|
511
|
+
`, 1)[0] ?? error.message).replace(/\s+at line \d+, column \d+:?$/, "");
|
|
512
|
+
throw new UsageError(`Invalid YAML in "${p}"${location}: ${summary}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (opts?.explicit && pathOrDir !== undefined) {
|
|
517
|
+
throw new UsageError(`manifest not found: ${pathOrDir}. Pass an existing file or a directory containing ${MANIFEST_FILENAMES.join(" or ")}.`);
|
|
518
|
+
}
|
|
519
|
+
const manifestDir = pathOrDir ? resolve(pathOrDir) : resolve(".");
|
|
520
|
+
return { manifest: {}, manifestDir, manifestPath: null };
|
|
521
|
+
}
|
|
522
|
+
var EXTENSION_ENTRY_KEYS = new Set(["use", "export", "options", "enabled"]);
|
|
523
|
+
function normalizeExtensionEntry(entry, index) {
|
|
524
|
+
const where = `extensions[${index}]`;
|
|
525
|
+
let use;
|
|
526
|
+
let rest;
|
|
527
|
+
if (typeof entry === "string") {
|
|
528
|
+
use = entry;
|
|
529
|
+
} else if (entry && typeof entry === "object") {
|
|
530
|
+
const obj = entry;
|
|
531
|
+
if ("priority" in obj) {
|
|
532
|
+
throw new UsageError(`${where}: \`priority\` was removed — the \`extensions:\` list order is the load order. ` + "An entry later in the list is registered later, sees earlier entries' output, and its " + "CSS wins ties; move the entry instead.");
|
|
533
|
+
}
|
|
534
|
+
if ("path" in obj || "name" in obj) {
|
|
535
|
+
throw new UsageError(`${where}: an \`extensions\` entry carries its specifier in \`use:\` — write ` + `\`use: ${String(obj.path ?? obj.name)}\` (or just the bare string).`);
|
|
536
|
+
}
|
|
537
|
+
const unknown = Object.keys(obj).filter((k) => !EXTENSION_ENTRY_KEYS.has(k));
|
|
538
|
+
if (unknown.length > 0) {
|
|
539
|
+
throw new UsageError(`${where}: unknown key(s) ${unknown.map((k) => `\`${k}\``).join(", ")} — an entry takes ` + "`use`, `options`, `export`, `enabled`.");
|
|
540
|
+
}
|
|
541
|
+
if (typeof obj.use !== "string" || !obj.use.trim()) {
|
|
542
|
+
throw new UsageError(`${where}: an object entry needs \`use: <specifier>\`.`);
|
|
543
|
+
}
|
|
544
|
+
use = obj.use;
|
|
545
|
+
rest = entry;
|
|
546
|
+
} else {
|
|
547
|
+
throw new UsageError(`${where}: expected a specifier string or an object with \`use:\`.`);
|
|
548
|
+
}
|
|
549
|
+
let parsed;
|
|
550
|
+
try {
|
|
551
|
+
parsed = parseExtensionSpecifier(use);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
throw new UsageError(`${where}: ${error instanceof Error ? error.message : String(error)}`);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
use: use.trim(),
|
|
557
|
+
...parsed.kind === "path" ? { path: parsed.path } : { name: parsed.name },
|
|
558
|
+
...parsed.kind === "npm" && parsed.version ? { version: parsed.version } : {},
|
|
559
|
+
...rest?.export ? { export: rest.export } : {},
|
|
560
|
+
options: rest?.options ?? {}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function pluginsRemovedMessage(raw) {
|
|
564
|
+
const specifierOf = (s) => isPathSpecifier(s) || s.startsWith("@") || !/[\\/]/.test(s) ? s : `./${s}`;
|
|
565
|
+
const priorityOf = (e) => e && typeof e === "object" && typeof e.priority === "number" ? e.priority : 100;
|
|
566
|
+
const entries = Array.isArray(raw) ? [...raw] : [];
|
|
567
|
+
entries.sort((a, b) => priorityOf(b) - priorityOf(a));
|
|
568
|
+
const lines = [];
|
|
569
|
+
for (const e of entries) {
|
|
570
|
+
if (typeof e === "string") {
|
|
571
|
+
lines.push(` - ${specifierOf(e)}`);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (!e || typeof e !== "object")
|
|
575
|
+
continue;
|
|
576
|
+
const o = e;
|
|
577
|
+
const spec = typeof o.path === "string" ? specifierOf(o.path) : typeof o.name === "string" ? `${o.name}${typeof o.version === "string" ? `@${o.version}` : ""}` : "<specifier>";
|
|
578
|
+
const extra = ["export", "options", "enabled"].filter((k) => (k in o) && (k !== "enabled" || o[k] === false));
|
|
579
|
+
if (extra.length === 0) {
|
|
580
|
+
lines.push(` - ${spec}`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
lines.push(` - use: ${spec}`);
|
|
584
|
+
for (const k of extra) {
|
|
585
|
+
lines.push(` ${k}: ${k === "options" ? JSON.stringify(o[k]) : String(o[k])}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return "Manifest field `plugins` was replaced by `extensions` — one list of bare specifiers in " + "load order (no `path:`/`name:` wrappers, no `priority`; a later entry loads later and " + "its CSS wins ties). Rewrite it as:" + `
|
|
589
|
+
` + "extensions:" + `
|
|
590
|
+
` + (lines.length > 0 ? lines.join(`
|
|
591
|
+
`) : " - ./plugins/my-plugin.js");
|
|
592
|
+
}
|
|
593
|
+
function isPlainObject(v) {
|
|
594
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
595
|
+
}
|
|
596
|
+
function mergeShape(cli, manifest, preset) {
|
|
597
|
+
const out = {};
|
|
598
|
+
for (const key of Object.keys(preset)) {
|
|
599
|
+
const presetValue = preset[key];
|
|
600
|
+
const manifestValue = manifest ? manifest[key] : undefined;
|
|
601
|
+
const cliValue = cli ? cli[key] : undefined;
|
|
602
|
+
out[key] = isPlainObject(presetValue) ? mergeShape(cliValue, manifestValue, presetValue) : cliValue ?? manifestValue ?? presetValue;
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
function resolveConfig(cliOverrides, manifest) {
|
|
607
|
+
const presetName = cliOverrides.preset ?? manifest.preset;
|
|
608
|
+
return resolveWithPreset(cliOverrides, manifest, resolvePreset(presetName));
|
|
609
|
+
}
|
|
610
|
+
function resolveConfigForTarget(cliOverrides, manifest, targetId) {
|
|
611
|
+
const presetName = cliOverrides.preset ?? manifest.preset;
|
|
612
|
+
const preset = resolvePreset(presetName);
|
|
613
|
+
return resolveWithPreset(cliOverrides, manifest, overlayPreset(preset, publishTargetFor(targetId)));
|
|
614
|
+
}
|
|
615
|
+
function resolveCustomPage(c, m) {
|
|
616
|
+
const width = c.page?.width ?? m.page?.width;
|
|
617
|
+
const height = c.page?.height ?? m.page?.height;
|
|
618
|
+
const missing = [
|
|
619
|
+
width === undefined ? "page.width" : null,
|
|
620
|
+
height === undefined ? "page.height" : null
|
|
621
|
+
].filter((f) => f !== null);
|
|
622
|
+
if (missing.length > 0) {
|
|
623
|
+
throw new UsageError(`preset: custom requires ${missing.join(" and ")} in manifest.yaml ` + `(points; 72pt = 1in). Example for US Letter:
|
|
624
|
+
` + ` page:
|
|
625
|
+
width: 612
|
|
626
|
+
height: 792`);
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
width,
|
|
630
|
+
height,
|
|
631
|
+
tolerance: c.page?.tolerance ?? m.page?.tolerance ?? 0.5,
|
|
632
|
+
bleed: c.page?.bleed ?? m.page?.bleed,
|
|
633
|
+
safe: c.page?.safe ?? m.page?.safe,
|
|
634
|
+
artSafe: c.page?.artSafe ?? m.page?.artSafe,
|
|
635
|
+
gutter: c.page?.gutter ?? m.page?.gutter
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function resolveWithPreset(cliOverrides, manifest, preset) {
|
|
639
|
+
const m = manifest;
|
|
640
|
+
const c = cliOverrides;
|
|
641
|
+
const legacy = m;
|
|
642
|
+
if (legacy.output !== undefined || legacy.source?.assets !== undefined) {
|
|
643
|
+
const stale = [
|
|
644
|
+
legacy.output !== undefined ? "`output`" : null,
|
|
645
|
+
legacy.source?.assets !== undefined ? "`source.assets`" : null
|
|
646
|
+
].filter(Boolean);
|
|
647
|
+
throw new UsageError(`Manifest field(s) ${stale.join(" and ")} are no longer supported — remove them.
|
|
648
|
+
` + " Output goes to `dist/<title-slug>/` automatically (use `--out` for a one-off location).\n" + " Assets are discovered from what your book actually references, so no list is needed.");
|
|
649
|
+
}
|
|
650
|
+
if ((m.validate?.source?.allowedCallouts?.length ?? 0) > 0 || (c.validate?.source?.allowedCallouts?.length ?? 0) > 0) {
|
|
651
|
+
warnOnce("allowed-callouts-deprecated", "[gutterpress] manifest field `validate.source.allowedCallouts` is " + "deprecated and ignored. The `:::` container syntax it gated was " + "removed 2026-05-17. See docs/migrations/2026-05-removing-container-syntax.md.");
|
|
652
|
+
}
|
|
653
|
+
for (const key of ["minImageDpi", "allowedColorSpaces"]) {
|
|
654
|
+
if (m.validate?.assets?.[key] !== undefined || c.validate?.assets?.[key] !== undefined) {
|
|
655
|
+
warnOnce(`assets-${key}-deprecated`, `[gutterpress] manifest field \`validate.assets.${key}\` is deprecated and ` + "ignored. The source-image check it configured was removed; the build " + "and the PDF checks measure what actually prints.");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const removed = m;
|
|
659
|
+
if (removed.engine !== undefined) {
|
|
660
|
+
throw new UsageError("Manifest field `engine` was removed — delete it. The Gutterpress engine " + "(native Chromium pagination) is the only engine, so there is nothing to select.");
|
|
661
|
+
}
|
|
662
|
+
if (removed.engineStyles !== undefined) {
|
|
663
|
+
throw new UsageError("Manifest field `engineStyles` was removed — move its entries to the END of " + "`styles:`. They were always loaded last; their place in that list is the " + "only thing the field ever expressed.");
|
|
664
|
+
}
|
|
665
|
+
const legacyPlugins = m.plugins;
|
|
666
|
+
if (legacyPlugins !== undefined) {
|
|
667
|
+
throw new UsageError(pluginsRemovedMessage(legacyPlugins));
|
|
668
|
+
}
|
|
669
|
+
const lastPage = c.print?.lastPage ?? m.print?.lastPage;
|
|
670
|
+
if (lastPage !== undefined && lastPage !== "blank" && lastPage !== "short-by-one") {
|
|
671
|
+
throw new UsageError(`Manifest field \`print.lastPage\` must be "blank" or "short-by-one", got ${JSON.stringify(lastPage)}.`);
|
|
672
|
+
}
|
|
673
|
+
const transparency = c.pdfx?.transparency ?? m.pdfx?.transparency;
|
|
674
|
+
if (transparency !== undefined && transparency !== "flatten-page" && transparency !== "preserve-text") {
|
|
675
|
+
throw new UsageError(`Manifest field \`pdfx.transparency\` must be "flatten-page" or "preserve-text", got ${JSON.stringify(transparency)}.`);
|
|
676
|
+
}
|
|
677
|
+
const flattenPages = c.pdfx?.flattenPages ?? m.pdfx?.flattenPages;
|
|
678
|
+
if (flattenPages !== undefined && !(Array.isArray(flattenPages) && flattenPages.every((n) => Number.isInteger(n) && n >= 1))) {
|
|
679
|
+
throw new UsageError(`Manifest field \`pdfx.flattenPages\` must be a list of page numbers (1, 2, …), got ${JSON.stringify(flattenPages)}.`);
|
|
680
|
+
}
|
|
681
|
+
const rasterDpi = c.pdfx?.rasterDpi ?? m.pdfx?.rasterDpi;
|
|
682
|
+
if (rasterDpi !== undefined && !(Number.isInteger(rasterDpi) && rasterDpi >= 72 && rasterDpi <= 1200)) {
|
|
683
|
+
throw new UsageError(`Manifest field \`pdfx.rasterDpi\` must be a whole number from 72 to 1200, got ${JSON.stringify(rasterDpi)}.`);
|
|
684
|
+
}
|
|
685
|
+
const rawExtensions = c.extensions ?? m.extensions ?? [];
|
|
686
|
+
if (!Array.isArray(rawExtensions)) {
|
|
687
|
+
throw new UsageError("Manifest field `extensions` must be a list of specifiers.");
|
|
688
|
+
}
|
|
689
|
+
const extensions = rawExtensions.map((entry, index) => ({ entry, index })).filter(({ entry }) => typeof entry === "string" || entry?.enabled !== false).map(({ entry, index }) => normalizeExtensionEntry(entry, index));
|
|
690
|
+
return {
|
|
691
|
+
title: c.title ?? m.title ?? "Document",
|
|
692
|
+
authors: c.authors ?? m.authors ?? [],
|
|
693
|
+
preset: preset.id,
|
|
694
|
+
spec: preset.spec,
|
|
695
|
+
styles: c.styles ?? m.styles,
|
|
696
|
+
extensions,
|
|
697
|
+
targets: resolveTargets(c.targets ?? m.targets, preset.defaultTargets),
|
|
698
|
+
source: mergeShape(c.source, m.source, preset.source),
|
|
699
|
+
pdfx: mergeShape(c.pdfx, m.pdfx, preset.pdfx),
|
|
700
|
+
print: mergeShape(c.print, m.print, preset.print),
|
|
701
|
+
page: preset.page ? mergeShape(c.page, m.page, preset.page) : resolveCustomPage(c, m),
|
|
702
|
+
ink: mergeShape(c.ink, m.ink, preset.ink),
|
|
703
|
+
lint: {
|
|
704
|
+
...mergeShape(c.lint, m.lint, preset.lint),
|
|
705
|
+
rules: { ...preset.lint.rules, ...m.lint?.rules, ...c.lint?.rules }
|
|
706
|
+
},
|
|
707
|
+
validate: {
|
|
708
|
+
enabled: c.validate?.enabled ?? m.validate?.enabled ?? preset.validate.enabled,
|
|
709
|
+
checks: { ...preset.validate.checks, ...m.validate?.checks, ...c.validate?.checks },
|
|
710
|
+
source: mergeShape(c.validate?.source, m.validate?.source, preset.validate.source),
|
|
711
|
+
assets: mergeShape(c.validate?.assets, m.validate?.assets, preset.validate.assets),
|
|
712
|
+
pdf: mergeShape(c.validate?.pdf, m.validate?.pdf, preset.validate.pdf),
|
|
713
|
+
heuristics: mergeShape(c.validate?.heuristics, m.validate?.heuristics, preset.validate.heuristics)
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/lib/manifest-doc.ts
|
|
719
|
+
function resolveManifestPath(projectDir) {
|
|
720
|
+
const existing = MANIFEST_FILENAMES.find((name) => existsSync2(path.join(projectDir, name)));
|
|
721
|
+
return path.join(projectDir, existing ?? MANIFEST_FILENAMES[0]);
|
|
722
|
+
}
|
|
723
|
+
async function loadManifestDoc(projectDir) {
|
|
724
|
+
const file = resolveManifestPath(projectDir);
|
|
725
|
+
let text = "";
|
|
726
|
+
try {
|
|
727
|
+
text = await readFile2(file, "utf8");
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (error.code !== "ENOENT")
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
return { doc: parseDocument(text), file };
|
|
733
|
+
}
|
|
734
|
+
async function writeManifestDoc(file, doc) {
|
|
735
|
+
const dir = path.dirname(file);
|
|
736
|
+
await mkdir(dir, { recursive: true });
|
|
737
|
+
const temporary = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${randomUUID()}`);
|
|
738
|
+
try {
|
|
739
|
+
const handle = await open(temporary, "wx", 438);
|
|
740
|
+
try {
|
|
741
|
+
await handle.writeFile(doc.toString(), "utf8");
|
|
742
|
+
await handle.sync();
|
|
743
|
+
} finally {
|
|
744
|
+
await handle.close();
|
|
745
|
+
}
|
|
746
|
+
await rename(temporary, file);
|
|
747
|
+
const directory = await open(dir, "r").catch(() => null);
|
|
748
|
+
if (directory) {
|
|
749
|
+
await directory.sync().catch(() => {});
|
|
750
|
+
await directory.close();
|
|
751
|
+
}
|
|
752
|
+
} finally {
|
|
753
|
+
await rm(temporary, { force: true });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function ensureSeq(doc, key) {
|
|
757
|
+
let seq = doc.get(key, true);
|
|
758
|
+
if (!isSeq(seq)) {
|
|
759
|
+
const fresh = new YAMLSeq(doc.schema);
|
|
760
|
+
doc.set(key, fresh);
|
|
761
|
+
seq = fresh;
|
|
762
|
+
}
|
|
763
|
+
return seq;
|
|
764
|
+
}
|
|
765
|
+
function scalarString(item) {
|
|
766
|
+
if (item && typeof item === "object" && "value" in item) {
|
|
767
|
+
const v = item.value;
|
|
768
|
+
return typeof v === "string" ? v : null;
|
|
769
|
+
}
|
|
770
|
+
return typeof item === "string" ? item : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/lib/npm-plugin-installer.ts
|
|
774
|
+
import { createHash, randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
775
|
+
import { createReadStream } from "node:fs";
|
|
776
|
+
import {
|
|
777
|
+
lstat,
|
|
778
|
+
mkdir as mkdir2,
|
|
779
|
+
mkdtemp,
|
|
780
|
+
open as open2,
|
|
781
|
+
readFile as readFile3,
|
|
782
|
+
realpath,
|
|
783
|
+
rename as rename2,
|
|
784
|
+
rm as rm2,
|
|
785
|
+
rmdir
|
|
786
|
+
} from "node:fs/promises";
|
|
787
|
+
import path2 from "node:path";
|
|
788
|
+
import { Transform } from "node:stream";
|
|
789
|
+
import { pipeline } from "node:stream/promises";
|
|
790
|
+
import { createGunzip } from "node:zlib";
|
|
791
|
+
import { maxSatisfying, satisfies, validRange } from "semver";
|
|
792
|
+
|
|
793
|
+
// src/lib/npm-registry.ts
|
|
794
|
+
var DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
795
|
+
var ENV_OVERRIDE = "GUTTERPRESS_NPM_REGISTRY";
|
|
796
|
+
function npmRegistryUrl() {
|
|
797
|
+
const configured = process.env[ENV_OVERRIDE]?.trim();
|
|
798
|
+
if (!configured)
|
|
799
|
+
return DEFAULT_NPM_REGISTRY;
|
|
800
|
+
let scheme;
|
|
801
|
+
try {
|
|
802
|
+
scheme = new URL(configured).protocol;
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error(`Invalid ${ENV_OVERRIDE} URL "${configured}".`);
|
|
805
|
+
}
|
|
806
|
+
if (scheme !== "http:" && scheme !== "https:") {
|
|
807
|
+
throw new Error(`${ENV_OVERRIDE} must be an http(s) URL — got "${scheme}".`);
|
|
808
|
+
}
|
|
809
|
+
return configured.replace(/\/+$/, "");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/lib/npm-plugin-installer.ts
|
|
813
|
+
var DEFAULT_LIMITS = {
|
|
814
|
+
metadataBytes: 15 * 1024 * 1024,
|
|
815
|
+
packageTarballBytes: 50 * 1024 * 1024,
|
|
816
|
+
totalNetworkBytes: 250 * 1024 * 1024,
|
|
817
|
+
packageFileBytes: 50 * 1024 * 1024,
|
|
818
|
+
totalUnpackedBytes: 512 * 1024 * 1024,
|
|
819
|
+
totalFiles: 50000,
|
|
820
|
+
totalPackages: 512,
|
|
821
|
+
dependencyDepth: 64
|
|
822
|
+
};
|
|
823
|
+
var METADATA_TIMEOUT_MS = 30000;
|
|
824
|
+
var TARBALL_TIMEOUT_MS = 120000;
|
|
825
|
+
|
|
826
|
+
class SecurityInstallError extends FetchPassthroughError {
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
class PackageUnavailableError extends Error {
|
|
830
|
+
}
|
|
831
|
+
function isOptionalAvailabilityError(error) {
|
|
832
|
+
return error instanceof PackageUnavailableError || error instanceof FriendlyHttpError || error instanceof FetchUnavailableError;
|
|
833
|
+
}
|
|
834
|
+
function object(value) {
|
|
835
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
836
|
+
}
|
|
837
|
+
function stringMap(value) {
|
|
838
|
+
const map = object(value);
|
|
839
|
+
if (!map)
|
|
840
|
+
return {};
|
|
841
|
+
const result = {};
|
|
842
|
+
for (const [name, selector] of Object.entries(map)) {
|
|
843
|
+
if (typeof selector === "string" && selector.trim())
|
|
844
|
+
result[name] = selector.trim();
|
|
845
|
+
}
|
|
846
|
+
return result;
|
|
847
|
+
}
|
|
848
|
+
function mb(bytes) {
|
|
849
|
+
return `${Math.ceil(bytes / 1024 / 1024)}MB`;
|
|
850
|
+
}
|
|
851
|
+
function isContained(root, candidate) {
|
|
852
|
+
const relative = path2.relative(root, candidate);
|
|
853
|
+
return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
|
|
854
|
+
}
|
|
855
|
+
function mergeLimits(overrides) {
|
|
856
|
+
const limits = { ...DEFAULT_LIMITS, ...overrides };
|
|
857
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
858
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
859
|
+
throw new Error(`Invalid npm plugin install limit ${name}: ${value}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return limits;
|
|
863
|
+
}
|
|
864
|
+
async function streamResponse(ctx, response, maxBytes, totalLimitMessage, onChunk) {
|
|
865
|
+
if (!response.body)
|
|
866
|
+
throw new Error("Registry response had no body.");
|
|
867
|
+
const declared = Number(response.headers.get("content-length"));
|
|
868
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
869
|
+
throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
|
|
870
|
+
}
|
|
871
|
+
if (Number.isFinite(declared) && declared > ctx.limits.totalNetworkBytes - ctx.totalNetworkBytes) {
|
|
872
|
+
throw new SecurityInstallError(totalLimitMessage);
|
|
873
|
+
}
|
|
874
|
+
const reader = response.body.getReader();
|
|
875
|
+
let total = 0;
|
|
876
|
+
try {
|
|
877
|
+
while (true) {
|
|
878
|
+
const { done, value } = await reader.read();
|
|
879
|
+
if (done)
|
|
880
|
+
break;
|
|
881
|
+
total += value.length;
|
|
882
|
+
if (total > maxBytes) {
|
|
883
|
+
await reader.cancel("size limit exceeded").catch(() => {});
|
|
884
|
+
throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
|
|
885
|
+
}
|
|
886
|
+
if (ctx.totalNetworkBytes + value.length > ctx.limits.totalNetworkBytes) {
|
|
887
|
+
await reader.cancel("total size limit exceeded").catch(() => {});
|
|
888
|
+
throw new SecurityInstallError(totalLimitMessage);
|
|
889
|
+
}
|
|
890
|
+
ctx.totalNetworkBytes += value.length;
|
|
891
|
+
await onChunk(value);
|
|
892
|
+
}
|
|
893
|
+
} finally {
|
|
894
|
+
reader.releaseLock();
|
|
895
|
+
}
|
|
896
|
+
return total;
|
|
897
|
+
}
|
|
898
|
+
async function fetchPackument(ctx, name) {
|
|
899
|
+
let pending = ctx.packuments.get(name);
|
|
900
|
+
if (pending)
|
|
901
|
+
return pending;
|
|
902
|
+
pending = withFetchTimeout({
|
|
903
|
+
timeoutMs: METADATA_TIMEOUT_MS,
|
|
904
|
+
signal: ctx.signal,
|
|
905
|
+
timeoutMessage: `Looking up ${name} on npm timed out. Check your connection and try again.`,
|
|
906
|
+
offlineMessage: (cause) => `Looking up ${name} on npm failed (${cause instanceof Error ? cause.message : String(cause)}).`
|
|
907
|
+
}, async (signal) => {
|
|
908
|
+
const response = await ctx.fetch(`${npmRegistryUrl()}/${encodeURIComponent(name)}`, {
|
|
909
|
+
signal,
|
|
910
|
+
redirect: "error",
|
|
911
|
+
headers: { accept: "application/vnd.npm.install-v1+json" }
|
|
912
|
+
});
|
|
913
|
+
if (response.status === 404) {
|
|
914
|
+
throw new FriendlyHttpError(`npm package "${name}" was not found.`);
|
|
915
|
+
}
|
|
916
|
+
if (!response.ok) {
|
|
917
|
+
throw new FriendlyHttpError(`Looking up ${name} on npm failed (HTTP ${response.status}).`);
|
|
918
|
+
}
|
|
919
|
+
const chunks = [];
|
|
920
|
+
const size = await streamResponse(ctx, response, ctx.limits.metadataBytes, `Plugin dependency metadata exceeds the ${mb(ctx.limits.totalNetworkBytes)} total download limit.`, (chunk) => {
|
|
921
|
+
chunks.push(chunk);
|
|
922
|
+
});
|
|
923
|
+
const bytes = new Uint8Array(size);
|
|
924
|
+
let offset = 0;
|
|
925
|
+
for (const chunk of chunks) {
|
|
926
|
+
bytes.set(chunk, offset);
|
|
927
|
+
offset += chunk.length;
|
|
928
|
+
}
|
|
929
|
+
let metadata = null;
|
|
930
|
+
try {
|
|
931
|
+
metadata = object(JSON.parse(new TextDecoder().decode(bytes)));
|
|
932
|
+
} catch {}
|
|
933
|
+
if (!metadata || metadata.name !== name) {
|
|
934
|
+
throw new FriendlyHttpError(`npm returned invalid metadata for "${name}".`);
|
|
935
|
+
}
|
|
936
|
+
return metadata;
|
|
937
|
+
}).catch((error) => {
|
|
938
|
+
ctx.packuments.delete(name);
|
|
939
|
+
throw error;
|
|
940
|
+
});
|
|
941
|
+
ctx.packuments.set(name, pending);
|
|
942
|
+
return pending;
|
|
943
|
+
}
|
|
944
|
+
function parseIntegrity(dist) {
|
|
945
|
+
const integrity = typeof dist.integrity === "string" ? dist.integrity.trim() : "";
|
|
946
|
+
const parsed = integrity.split(/\s+/).map((token) => token.match(/^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/]+={0,2})(?:\?.*)?$/)).filter((match) => Boolean(match?.[1] && match[2]));
|
|
947
|
+
for (const algorithm of ["sha512", "sha384", "sha256"]) {
|
|
948
|
+
const match = parsed.find((candidate) => candidate[1] === algorithm);
|
|
949
|
+
if (!match)
|
|
950
|
+
continue;
|
|
951
|
+
const digest = Buffer.from(match[2], "base64");
|
|
952
|
+
if (digest.length > 0) {
|
|
953
|
+
return { algorithm, digest, label: `${algorithm}-${match[2]}`, legacySha1: false };
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const sha1 = parsed.find((candidate) => candidate[1] === "sha1");
|
|
957
|
+
if (sha1) {
|
|
958
|
+
return {
|
|
959
|
+
algorithm: "sha1",
|
|
960
|
+
digest: Buffer.from(sha1[2], "base64"),
|
|
961
|
+
label: `sha1-${sha1[2]}`,
|
|
962
|
+
legacySha1: true
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (integrity) {
|
|
966
|
+
throw new SecurityInstallError("The npm registry supplied integrity data, but no supported SHA-256-or-stronger hash.");
|
|
967
|
+
}
|
|
968
|
+
const shasum = typeof dist.shasum === "string" ? dist.shasum.trim().toLowerCase() : "";
|
|
969
|
+
if (/^[a-f0-9]{40}$/.test(shasum)) {
|
|
970
|
+
return {
|
|
971
|
+
algorithm: "sha1",
|
|
972
|
+
digest: Buffer.from(shasum, "hex"),
|
|
973
|
+
label: `sha1-hex-${shasum}`,
|
|
974
|
+
legacySha1: true
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
throw new SecurityInstallError("The npm registry provided no usable integrity hash, so the package was not installed.");
|
|
978
|
+
}
|
|
979
|
+
function unsupportedSelector(selector) {
|
|
980
|
+
return /^(?:file:|git\+|https?:|ssh:|github:|workspace:|link:|npm:)/i.test(selector);
|
|
981
|
+
}
|
|
982
|
+
async function selectPackage(ctx, name, selector) {
|
|
983
|
+
if (selector && unsupportedSelector(selector)) {
|
|
984
|
+
throw new PackageUnavailableError(`Dependency selector "${selector}" for ${name} is not a registry semver range or dist-tag.`);
|
|
985
|
+
}
|
|
986
|
+
const metadata = await fetchPackument(ctx, name);
|
|
987
|
+
const versions = object(metadata.versions);
|
|
988
|
+
const tags = object(metadata["dist-tags"]);
|
|
989
|
+
if (!versions || !tags)
|
|
990
|
+
throw new PackageUnavailableError(`npm metadata for "${name}" has no versions.`);
|
|
991
|
+
let version;
|
|
992
|
+
if (!selector) {
|
|
993
|
+
if (typeof tags.latest === "string")
|
|
994
|
+
version = tags.latest;
|
|
995
|
+
} else if (object(versions[selector])) {
|
|
996
|
+
version = selector;
|
|
997
|
+
} else if (typeof tags[selector] === "string") {
|
|
998
|
+
version = tags[selector];
|
|
999
|
+
} else if (validRange(selector)) {
|
|
1000
|
+
version = maxSatisfying(Object.keys(versions), selector) ?? undefined;
|
|
1001
|
+
}
|
|
1002
|
+
if (!version || !isExactNpmVersion(version)) {
|
|
1003
|
+
throw new PackageUnavailableError(`No exact npm version of "${name}" satisfies "${selector ?? "latest"}".`);
|
|
1004
|
+
}
|
|
1005
|
+
const selected = object(versions[version]);
|
|
1006
|
+
if (!selected || selected.name !== name || selected.version !== version) {
|
|
1007
|
+
throw new SecurityInstallError(`npm metadata identity failed for "${name}@${version}".`);
|
|
1008
|
+
}
|
|
1009
|
+
const dist = object(selected.dist);
|
|
1010
|
+
if (!dist || typeof dist.tarball !== "string") {
|
|
1011
|
+
throw new PackageUnavailableError(`npm metadata for "${name}@${version}" has no tarball.`);
|
|
1012
|
+
}
|
|
1013
|
+
let tarball;
|
|
1014
|
+
try {
|
|
1015
|
+
tarball = new URL(dist.tarball);
|
|
1016
|
+
} catch {
|
|
1017
|
+
throw new SecurityInstallError(`npm returned an invalid tarball URL for "${name}@${version}".`);
|
|
1018
|
+
}
|
|
1019
|
+
if (tarball.origin !== new URL(npmRegistryUrl()).origin || tarball.username || tarball.password) {
|
|
1020
|
+
throw new SecurityInstallError(`npm returned an unexpected tarball host for "${name}@${version}".`);
|
|
1021
|
+
}
|
|
1022
|
+
return { name, version, tarball: tarball.href, integrity: parseIntegrity(dist) };
|
|
1023
|
+
}
|
|
1024
|
+
async function downloadTarball(ctx, pkg, archivePath) {
|
|
1025
|
+
const file = await open2(archivePath, "wx");
|
|
1026
|
+
const hash = createHash(pkg.integrity.algorithm);
|
|
1027
|
+
try {
|
|
1028
|
+
await withFetchTimeout({
|
|
1029
|
+
timeoutMs: TARBALL_TIMEOUT_MS,
|
|
1030
|
+
signal: ctx.signal,
|
|
1031
|
+
timeoutMessage: `Downloading ${pkg.name}@${pkg.version} timed out.`,
|
|
1032
|
+
offlineMessage: (cause) => `Downloading ${pkg.name}@${pkg.version} failed (${cause instanceof Error ? cause.message : String(cause)}).`
|
|
1033
|
+
}, async (signal) => {
|
|
1034
|
+
const response = await ctx.fetch(pkg.tarball, { signal, redirect: "error" });
|
|
1035
|
+
if (!response.ok) {
|
|
1036
|
+
throw new FriendlyHttpError(`Downloading ${pkg.name}@${pkg.version} failed (HTTP ${response.status}).`);
|
|
1037
|
+
}
|
|
1038
|
+
await streamResponse(ctx, response, ctx.limits.packageTarballBytes, `Plugin dependency downloads exceed the ${mb(ctx.limits.totalNetworkBytes)} total limit.`, async (chunk) => {
|
|
1039
|
+
hash.update(chunk);
|
|
1040
|
+
try {
|
|
1041
|
+
await file.write(chunk);
|
|
1042
|
+
} catch (cause) {
|
|
1043
|
+
throw new SecurityInstallError(`Could not store ${pkg.name}@${pkg.version} while downloading.`, { cause });
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
1047
|
+
} finally {
|
|
1048
|
+
await file.close();
|
|
1049
|
+
}
|
|
1050
|
+
const actual = hash.digest();
|
|
1051
|
+
if (actual.length !== pkg.integrity.digest.length || !timingSafeEqual(actual, pkg.integrity.digest)) {
|
|
1052
|
+
throw new SecurityInstallError(`The tarball for ${pkg.name}@${pkg.version} failed its registry integrity check.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function safeTarRelative(entryPath, packageName) {
|
|
1056
|
+
if (!entryPath || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
|
|
1057
|
+
throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath || "(empty)"}`);
|
|
1058
|
+
}
|
|
1059
|
+
const parts = entryPath.split("/");
|
|
1060
|
+
if (parts.at(-1) === "")
|
|
1061
|
+
parts.pop();
|
|
1062
|
+
const legacyTypesRoot = packageName.startsWith("@types/") ? packageName.slice("@types/".length) : null;
|
|
1063
|
+
if (parts.some((part) => !part || part === "." || part === "..") || parts[0] !== "package" && parts[0] !== legacyTypesRoot) {
|
|
1064
|
+
throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath}`);
|
|
1065
|
+
}
|
|
1066
|
+
const relative = parts.slice(1).join("/");
|
|
1067
|
+
if (!relative)
|
|
1068
|
+
return { relative, key: "" };
|
|
1069
|
+
assertWindowsSafeRelativePath(relative);
|
|
1070
|
+
if (relative.normalize("NFKC").split("/").some((part) => part.toLowerCase() === "node_modules")) {
|
|
1071
|
+
throw new SecurityInstallError(`The npm tarball contains bundled node_modules (${entryPath}); bundled dependencies are not accepted.`);
|
|
1072
|
+
}
|
|
1073
|
+
return { relative, key: windowsPathKey(relative) };
|
|
1074
|
+
}
|
|
1075
|
+
async function extractTarball(ctx, archivePath, packageDir, packageName) {
|
|
1076
|
+
const { extract } = await import("tar");
|
|
1077
|
+
let rejected = null;
|
|
1078
|
+
const seen = new Set;
|
|
1079
|
+
const metadataTypes = new Set([
|
|
1080
|
+
"GlobalExtendedHeader",
|
|
1081
|
+
"ExtendedHeader",
|
|
1082
|
+
"NextFileHasLongLinkpath",
|
|
1083
|
+
"NextFileHasLongPath",
|
|
1084
|
+
"OldGnuLongPath",
|
|
1085
|
+
"OldExtendedHeader"
|
|
1086
|
+
]);
|
|
1087
|
+
await mkdir2(packageDir, { recursive: true });
|
|
1088
|
+
const unpack = extract({
|
|
1089
|
+
cwd: packageDir,
|
|
1090
|
+
strip: 1,
|
|
1091
|
+
strict: true,
|
|
1092
|
+
preservePaths: false,
|
|
1093
|
+
preserveOwner: false,
|
|
1094
|
+
noMtime: true,
|
|
1095
|
+
chmod: false,
|
|
1096
|
+
maxDepth: ctx.limits.dependencyDepth * 4,
|
|
1097
|
+
filter: (entryPath, rawEntry) => {
|
|
1098
|
+
if (rejected)
|
|
1099
|
+
return false;
|
|
1100
|
+
const entry = rawEntry;
|
|
1101
|
+
if (metadataTypes.has(entry.type))
|
|
1102
|
+
return true;
|
|
1103
|
+
if (entry.type === "Link" || entry.type === "SymbolicLink") {
|
|
1104
|
+
rejected = new SecurityInstallError(`The npm tarball contains an unsafe link: ${entryPath}`);
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
if (entry.type !== "File" && entry.type !== "OldFile" && entry.type !== "Directory") {
|
|
1108
|
+
rejected = new SecurityInstallError(`The npm tarball contains unsupported entry type ${entry.type}: ${entryPath}`);
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
const { relative, key } = safeTarRelative(entryPath, packageName);
|
|
1113
|
+
if (!relative) {
|
|
1114
|
+
if (entry.type === "Directory")
|
|
1115
|
+
return true;
|
|
1116
|
+
throw new SecurityInstallError(`The npm tarball uses package/ as a file.`);
|
|
1117
|
+
}
|
|
1118
|
+
if (seen.has(key)) {
|
|
1119
|
+
throw new SecurityInstallError(`The npm tarball has a Windows-colliding path: ${entryPath}`);
|
|
1120
|
+
}
|
|
1121
|
+
seen.add(key);
|
|
1122
|
+
ctx.totalFiles++;
|
|
1123
|
+
if (ctx.totalFiles > ctx.limits.totalFiles) {
|
|
1124
|
+
throw new SecurityInstallError(`Plugin graph exceeds ${ctx.limits.totalFiles} files.`);
|
|
1125
|
+
}
|
|
1126
|
+
if (entry.type !== "Directory") {
|
|
1127
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > ctx.limits.packageFileBytes) {
|
|
1128
|
+
throw new SecurityInstallError(`Package file exceeds the ${mb(ctx.limits.packageFileBytes)} limit: ${entryPath}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return true;
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
rejected = error instanceof Error ? error : new SecurityInstallError(String(error));
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
const limiter = new Transform({
|
|
1139
|
+
transform(chunk, _encoding, callback) {
|
|
1140
|
+
ctx.totalUnpackedBytes += chunk.length;
|
|
1141
|
+
if (ctx.totalUnpackedBytes > ctx.limits.totalUnpackedBytes) {
|
|
1142
|
+
callback(new SecurityInstallError(`Plugin graph expands beyond the ${mb(ctx.limits.totalUnpackedBytes)} limit.`));
|
|
1143
|
+
} else
|
|
1144
|
+
callback(null, chunk);
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
try {
|
|
1148
|
+
await pipeline(createReadStream(archivePath), createGunzip(), limiter, unpack);
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
if (rejected)
|
|
1151
|
+
throw rejected;
|
|
1152
|
+
if (error instanceof SecurityInstallError)
|
|
1153
|
+
throw error;
|
|
1154
|
+
throw new SecurityInstallError(`The npm tarball could not be safely extracted (${error instanceof Error ? error.message : String(error)}).`, { cause: error });
|
|
1155
|
+
}
|
|
1156
|
+
if (rejected)
|
|
1157
|
+
throw rejected;
|
|
1158
|
+
}
|
|
1159
|
+
async function readInstalledManifest(packageDir, selected) {
|
|
1160
|
+
const packageJsonPath = path2.join(packageDir, "package.json");
|
|
1161
|
+
let manifest = null;
|
|
1162
|
+
try {
|
|
1163
|
+
const info = await lstat(packageJsonPath);
|
|
1164
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
1165
|
+
throw new Error("not a regular file");
|
|
1166
|
+
manifest = object(JSON.parse(await readFile3(packageJsonPath, "utf8")));
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
throw new SecurityInstallError(`Downloaded ${selected.name}@${selected.version} has no valid package.json (${error instanceof Error ? error.message : String(error)}).`);
|
|
1169
|
+
}
|
|
1170
|
+
if (manifest?.name !== selected.name || manifest.version !== selected.version) {
|
|
1171
|
+
throw new SecurityInstallError(`Downloaded ${String(manifest?.name)}@${String(manifest?.version)} instead of ${selected.name}@${selected.version}.`);
|
|
1172
|
+
}
|
|
1173
|
+
return manifest;
|
|
1174
|
+
}
|
|
1175
|
+
function platformAllowed(rule, current) {
|
|
1176
|
+
let values;
|
|
1177
|
+
if (typeof rule === "string") {
|
|
1178
|
+
values = [rule];
|
|
1179
|
+
} else if (Array.isArray(rule) && rule.every((item) => typeof item === "string")) {
|
|
1180
|
+
values = rule;
|
|
1181
|
+
} else {
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
1184
|
+
if (values.length === 0)
|
|
1185
|
+
return true;
|
|
1186
|
+
if (values.length === 1 && values[0] === "any")
|
|
1187
|
+
return true;
|
|
1188
|
+
if (values.includes(`!${current}`))
|
|
1189
|
+
return false;
|
|
1190
|
+
const positives = values.filter((value) => !value.startsWith("!"));
|
|
1191
|
+
return positives.length === 0 || positives.includes(current);
|
|
1192
|
+
}
|
|
1193
|
+
function relativePackagePath(parentPackagePath, name) {
|
|
1194
|
+
const segments = name.split("/");
|
|
1195
|
+
return parentPackagePath ? `${parentPackagePath}/node_modules/${segments.join("/")}` : `node_modules/${segments.join("/")}`;
|
|
1196
|
+
}
|
|
1197
|
+
async function installPackage(ctx, name, selector, parentPackagePath, ancestors, depth, optional) {
|
|
1198
|
+
const recordStart = ctx.records.length;
|
|
1199
|
+
const warningsBefore = new Set(ctx.warnings);
|
|
1200
|
+
const discardOptionalSubtree = () => {
|
|
1201
|
+
ctx.records.splice(recordStart);
|
|
1202
|
+
ctx.warnings = warningsBefore;
|
|
1203
|
+
};
|
|
1204
|
+
if (depth > ctx.limits.dependencyDepth) {
|
|
1205
|
+
throw new SecurityInstallError(`Dependency graph exceeds depth ${ctx.limits.dependencyDepth}.`);
|
|
1206
|
+
}
|
|
1207
|
+
let selected;
|
|
1208
|
+
try {
|
|
1209
|
+
selected = await selectPackage(ctx, name, selector);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
if (optional && isOptionalAvailabilityError(error)) {
|
|
1212
|
+
discardOptionalSubtree();
|
|
1213
|
+
return { skipped: error instanceof Error ? error.message : String(error) };
|
|
1214
|
+
}
|
|
1215
|
+
throw error;
|
|
1216
|
+
}
|
|
1217
|
+
const ancestor = ancestors.find((candidate) => candidate.name === name && (candidate.version === selected.version || (selector ? satisfies(candidate.version, selector) : false)));
|
|
1218
|
+
if (ancestor)
|
|
1219
|
+
return { path: ancestor.path };
|
|
1220
|
+
if (ctx.records.length >= ctx.limits.totalPackages) {
|
|
1221
|
+
throw new SecurityInstallError(`Dependency graph exceeds ${ctx.limits.totalPackages} packages.`);
|
|
1222
|
+
}
|
|
1223
|
+
const relativePath = relativePackagePath(parentPackagePath, name);
|
|
1224
|
+
const packageDir = path2.join(ctx.stageRoot, ...relativePath.split("/"));
|
|
1225
|
+
const archivePath = path2.join(ctx.downloadsDir, `${ctx.archiveCounter++}.tgz`);
|
|
1226
|
+
try {
|
|
1227
|
+
await mkdir2(path2.dirname(packageDir), { recursive: true });
|
|
1228
|
+
await downloadTarball(ctx, selected, archivePath);
|
|
1229
|
+
await extractTarball(ctx, archivePath, packageDir, selected.name);
|
|
1230
|
+
const manifest = await readInstalledManifest(packageDir, selected);
|
|
1231
|
+
if (!platformAllowed(manifest.os, process.platform) || !platformAllowed(manifest.cpu, process.arch)) {
|
|
1232
|
+
const reason = `${name}@${selected.version} does not support ${process.platform}/${process.arch}`;
|
|
1233
|
+
if (optional) {
|
|
1234
|
+
await rm2(packageDir, { recursive: true, force: true });
|
|
1235
|
+
return { skipped: reason };
|
|
1236
|
+
}
|
|
1237
|
+
throw new PackageUnavailableError(reason);
|
|
1238
|
+
}
|
|
1239
|
+
if (parentPackagePath === null) {
|
|
1240
|
+
try {
|
|
1241
|
+
await resolvePackageEntry(packageDir, manifest);
|
|
1242
|
+
} catch (cause) {
|
|
1243
|
+
throw new SecurityInstallError(`${name}@${selected.version} has no JavaScript plugin entry.`, { cause });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const record = {
|
|
1247
|
+
path: relativePath,
|
|
1248
|
+
name,
|
|
1249
|
+
version: selected.version,
|
|
1250
|
+
packageDir,
|
|
1251
|
+
manifest
|
|
1252
|
+
};
|
|
1253
|
+
ctx.records.push(record);
|
|
1254
|
+
if (selected.integrity.legacySha1) {
|
|
1255
|
+
ctx.warnings.add(`${name}@${selected.version} is an old npm release with only SHA-1 registry integrity. It was accepted for compatibility.`);
|
|
1256
|
+
}
|
|
1257
|
+
const dependencies = stringMap(manifest.dependencies);
|
|
1258
|
+
const optionalDependencies = stringMap(manifest.optionalDependencies);
|
|
1259
|
+
for (const optionalName of Object.keys(optionalDependencies))
|
|
1260
|
+
delete dependencies[optionalName];
|
|
1261
|
+
const peerDependencies = stringMap(manifest.peerDependencies);
|
|
1262
|
+
const peerMeta = object(manifest.peerDependenciesMeta) ?? {};
|
|
1263
|
+
for (const existing of [...Object.keys(dependencies), ...Object.keys(optionalDependencies)]) {
|
|
1264
|
+
delete peerDependencies[existing];
|
|
1265
|
+
}
|
|
1266
|
+
const nextAncestors = [...ancestors, record];
|
|
1267
|
+
const requiredEntries = [
|
|
1268
|
+
...Object.entries(dependencies).map(([dependency, range]) => ({ dependency, range, kind: "required" })),
|
|
1269
|
+
...Object.entries(peerDependencies).filter(([dependency]) => object(peerMeta[dependency])?.optional !== true).map(([dependency, range]) => ({ dependency, range, kind: "peer" }))
|
|
1270
|
+
].sort((a, b) => a.dependency.localeCompare(b.dependency));
|
|
1271
|
+
for (const { dependency, range } of requiredEntries) {
|
|
1272
|
+
const child = await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, false);
|
|
1273
|
+
if (!child.path)
|
|
1274
|
+
throw new Error(`Required dependency ${dependency} was unexpectedly skipped.`);
|
|
1275
|
+
}
|
|
1276
|
+
const optionalEntries = Object.entries(optionalDependencies).sort(([a], [b]) => a.localeCompare(b));
|
|
1277
|
+
for (const [dependency, range] of optionalEntries) {
|
|
1278
|
+
await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, true);
|
|
1279
|
+
}
|
|
1280
|
+
return { path: relativePath };
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
await rm2(packageDir, { recursive: true, force: true });
|
|
1283
|
+
if (optional && isOptionalAvailabilityError(error)) {
|
|
1284
|
+
discardOptionalSubtree();
|
|
1285
|
+
return { skipped: error instanceof Error ? error.message : String(error) };
|
|
1286
|
+
}
|
|
1287
|
+
throw error;
|
|
1288
|
+
} finally {
|
|
1289
|
+
await rm2(archivePath, { force: true });
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
async function ensureDirectory(pathname) {
|
|
1293
|
+
try {
|
|
1294
|
+
const info = await lstat(pathname);
|
|
1295
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
1296
|
+
throw new Error(`Plugin install path is not a normal directory: ${pathname}`);
|
|
1297
|
+
}
|
|
1298
|
+
return false;
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
if (error.code !== "ENOENT")
|
|
1301
|
+
throw error;
|
|
1302
|
+
await mkdir2(pathname);
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
async function removeEmptyDirs(dirs) {
|
|
1307
|
+
for (const dir of [...dirs].reverse()) {
|
|
1308
|
+
try {
|
|
1309
|
+
await rmdir(dir);
|
|
1310
|
+
} catch {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async function prepareVendorParent(projectDir, name) {
|
|
1316
|
+
const plugins = path2.join(projectDir, PLUGINS_DIR);
|
|
1317
|
+
const npm = path2.join(plugins, VENDORED_NPM_DIR);
|
|
1318
|
+
const packageParent = path2.dirname(vendoredNpmPluginRoot(projectDir, name, "0.0.0"));
|
|
1319
|
+
await mkdir2(projectDir, { recursive: true });
|
|
1320
|
+
const createdDirs = [];
|
|
1321
|
+
try {
|
|
1322
|
+
for (const dir of [plugins, npm, packageParent]) {
|
|
1323
|
+
if (await ensureDirectory(dir))
|
|
1324
|
+
createdDirs.push(dir);
|
|
1325
|
+
}
|
|
1326
|
+
const [realNpm, realPackageParent] = await Promise.all([
|
|
1327
|
+
realpath(npm),
|
|
1328
|
+
realpath(packageParent)
|
|
1329
|
+
]);
|
|
1330
|
+
if (!isContained(projectDir, realNpm) || !isContained(projectDir, realPackageParent)) {
|
|
1331
|
+
throw new Error("Plugin install path resolves outside the book folder.");
|
|
1332
|
+
}
|
|
1333
|
+
return { npmRoot: realNpm, createdDirs };
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
await removeEmptyDirs(createdDirs);
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
async function installNpmPlugin(projectDirInput, packageSpec, options = {}) {
|
|
1340
|
+
const requestedProjectDir = path2.resolve(projectDirInput);
|
|
1341
|
+
await mkdir2(requestedProjectDir, { recursive: true });
|
|
1342
|
+
const projectDir = await realpath(requestedProjectDir);
|
|
1343
|
+
const requested = parseNpmPluginSpec(packageSpec);
|
|
1344
|
+
const { npmRoot, createdDirs } = await prepareVendorParent(projectDir, requested.name);
|
|
1345
|
+
const container = await mkdtemp(path2.join(npmRoot, ".install-"));
|
|
1346
|
+
const stageRoot = path2.join(container, "root");
|
|
1347
|
+
const downloadsDir = path2.join(container, "downloads");
|
|
1348
|
+
await Promise.all([mkdir2(stageRoot), mkdir2(downloadsDir)]);
|
|
1349
|
+
const ctx = {
|
|
1350
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
1351
|
+
signal: options.signal,
|
|
1352
|
+
limits: mergeLimits(options.limits),
|
|
1353
|
+
stageRoot,
|
|
1354
|
+
downloadsDir,
|
|
1355
|
+
packuments: new Map,
|
|
1356
|
+
records: [],
|
|
1357
|
+
warnings: new Set,
|
|
1358
|
+
totalNetworkBytes: 0,
|
|
1359
|
+
totalUnpackedBytes: 0,
|
|
1360
|
+
totalFiles: 0,
|
|
1361
|
+
archiveCounter: 0
|
|
1362
|
+
};
|
|
1363
|
+
let finalRoot = "";
|
|
1364
|
+
let backupRoot = null;
|
|
1365
|
+
let published = false;
|
|
1366
|
+
try {
|
|
1367
|
+
const rootResult = await installPackage(ctx, requested.name, requested.selector, null, [], 0, false);
|
|
1368
|
+
if (!rootResult.path)
|
|
1369
|
+
throw new Error("Root plugin package was unexpectedly skipped.");
|
|
1370
|
+
const rootRecord = ctx.records.find((record) => record.path === rootResult.path);
|
|
1371
|
+
if (!rootRecord)
|
|
1372
|
+
throw new Error("Root plugin package is missing from the dependency graph.");
|
|
1373
|
+
finalRoot = vendoredNpmPluginRoot(projectDir, rootRecord.name, rootRecord.version);
|
|
1374
|
+
const realFinalParent = await realpath(path2.dirname(finalRoot));
|
|
1375
|
+
if (!isContained(projectDir, realFinalParent)) {
|
|
1376
|
+
throw new Error("Plugin install destination resolves outside the book folder.");
|
|
1377
|
+
}
|
|
1378
|
+
try {
|
|
1379
|
+
await lstat(finalRoot);
|
|
1380
|
+
backupRoot = `${finalRoot}.backup-${randomUUID2()}`;
|
|
1381
|
+
await rename2(finalRoot, backupRoot);
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
if (error.code !== "ENOENT")
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
try {
|
|
1387
|
+
await rename2(stageRoot, finalRoot);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
if (backupRoot) {
|
|
1390
|
+
try {
|
|
1391
|
+
await rename2(backupRoot, finalRoot);
|
|
1392
|
+
} catch (restoreError) {
|
|
1393
|
+
throw new Error(`Could not publish the new plugin tree or restore the previous tree: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, { cause: error });
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
throw error;
|
|
1397
|
+
}
|
|
1398
|
+
published = true;
|
|
1399
|
+
return {
|
|
1400
|
+
name: rootRecord.name,
|
|
1401
|
+
version: rootRecord.version,
|
|
1402
|
+
installRoot: finalRoot,
|
|
1403
|
+
backupRoot,
|
|
1404
|
+
createdDirs,
|
|
1405
|
+
warnings: [...ctx.warnings]
|
|
1406
|
+
};
|
|
1407
|
+
} finally {
|
|
1408
|
+
await rm2(container, { recursive: true, force: true }).catch(() => {});
|
|
1409
|
+
if (!published)
|
|
1410
|
+
await removeEmptyDirs(createdDirs);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
async function finalizeNpmPluginInstall(installed) {
|
|
1414
|
+
if (installed.backupRoot) {
|
|
1415
|
+
await rm2(installed.backupRoot, { recursive: true, force: true });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
async function rollbackNpmPluginInstall(installed) {
|
|
1419
|
+
await rm2(installed.installRoot, { recursive: true, force: true });
|
|
1420
|
+
if (installed.backupRoot)
|
|
1421
|
+
await rename2(installed.backupRoot, installed.installRoot);
|
|
1422
|
+
else
|
|
1423
|
+
await removeEmptyDirs(installed.createdDirs);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/lib/extension-manager.ts
|
|
1427
|
+
import { gt as semverGt, rcompare as semverRcompare } from "semver";
|
|
1428
|
+
|
|
1429
|
+
// src/lib/project-gitignore.ts
|
|
1430
|
+
import { readFile as readFile4, writeFile } from "node:fs/promises";
|
|
1431
|
+
import path3 from "node:path";
|
|
1432
|
+
import ignore from "ignore";
|
|
1433
|
+
function ignoreStatus(gitignore, entry) {
|
|
1434
|
+
const matcher = ignore().add(gitignore);
|
|
1435
|
+
const dir = matcher.test(entry);
|
|
1436
|
+
const file = matcher.test(`${entry}probe`);
|
|
1437
|
+
if (file.ignored)
|
|
1438
|
+
return "ignored";
|
|
1439
|
+
return dir.unignored || file.unignored ? "negated" : "open";
|
|
1440
|
+
}
|
|
1441
|
+
var PROJECT_GITIGNORE_ENTRIES = ["dist/", "plugins/npm/"];
|
|
1442
|
+
async function ensureProjectGitignore(projectDir) {
|
|
1443
|
+
const gitignorePath = path3.join(projectDir, ".gitignore");
|
|
1444
|
+
let existing = null;
|
|
1445
|
+
try {
|
|
1446
|
+
existing = await readFile4(gitignorePath, "utf8");
|
|
1447
|
+
} catch {}
|
|
1448
|
+
const missing = [];
|
|
1449
|
+
const negated = [];
|
|
1450
|
+
for (const entry of PROJECT_GITIGNORE_ENTRIES) {
|
|
1451
|
+
const status = ignoreStatus(existing ?? "", entry);
|
|
1452
|
+
if (status === "open")
|
|
1453
|
+
missing.push(entry);
|
|
1454
|
+
else if (status === "negated")
|
|
1455
|
+
negated.push(entry);
|
|
1456
|
+
}
|
|
1457
|
+
if (missing.length > 0) {
|
|
1458
|
+
const lead = existing !== null && existing.length > 0 && !existing.endsWith(`
|
|
1459
|
+
`) ? `
|
|
1460
|
+
` : "";
|
|
1461
|
+
await writeFile(gitignorePath, `${existing ?? ""}${lead}${missing.join(`
|
|
1462
|
+
`)}
|
|
1463
|
+
`, "utf8");
|
|
1464
|
+
}
|
|
1465
|
+
return { negated };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// src/lib/extension-manager.ts
|
|
1469
|
+
var EXTENSIONS_DIR = "extensions";
|
|
1470
|
+
var RECOMMENDED_EXTENSIONS = [
|
|
1471
|
+
{
|
|
1472
|
+
use: "markdown-it-mark",
|
|
1473
|
+
label: "Highlight",
|
|
1474
|
+
description: "Highlight text by wrapping it in `==double equals==`."
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
use: "markdown-it-sub",
|
|
1478
|
+
label: "Subscript",
|
|
1479
|
+
description: "Lower text below the line, like the 2 in `H~2~O`."
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
use: "markdown-it-sup",
|
|
1483
|
+
label: "Superscript",
|
|
1484
|
+
description: "Raise text above the line, like the th in `29^th^`."
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
use: "markdown-it-abbr",
|
|
1488
|
+
label: "Abbreviations",
|
|
1489
|
+
description: "Explain an abbreviation when readers hover over it. Define it once, like `*[GPS]: Global Positioning System`."
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
use: "gutterpress-gfm-alerts",
|
|
1493
|
+
label: "Callouts",
|
|
1494
|
+
description: "Turn a quote into a boxed note: start it with `> [!NOTE]` (or `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`)."
|
|
1495
|
+
}
|
|
1496
|
+
];
|
|
1497
|
+
var BUILT_IN_STYLE_SET_IDS = ["clean-book", "zine", "technical-doc"];
|
|
1498
|
+
var mutationQueues = new Map;
|
|
1499
|
+
function projectKey(projectDir) {
|
|
1500
|
+
const resolved = path4.resolve(projectDir);
|
|
1501
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
1502
|
+
}
|
|
1503
|
+
function withMutationLock(projectDir, mutation) {
|
|
1504
|
+
const key = projectKey(projectDir);
|
|
1505
|
+
const previous = mutationQueues.get(key) ?? Promise.resolve();
|
|
1506
|
+
const run = previous.then(mutation, mutation);
|
|
1507
|
+
const tail = run.then(() => {
|
|
1508
|
+
return;
|
|
1509
|
+
}, () => {
|
|
1510
|
+
return;
|
|
1511
|
+
});
|
|
1512
|
+
mutationQueues.set(key, tail);
|
|
1513
|
+
tail.then(() => {
|
|
1514
|
+
if (mutationQueues.get(key) === tail)
|
|
1515
|
+
mutationQueues.delete(key);
|
|
1516
|
+
});
|
|
1517
|
+
return run;
|
|
1518
|
+
}
|
|
1519
|
+
function entryUse(item) {
|
|
1520
|
+
if (isScalar(item))
|
|
1521
|
+
return typeof item.value === "string" ? item.value.trim() : null;
|
|
1522
|
+
if (isMap(item)) {
|
|
1523
|
+
const u = item.get("use");
|
|
1524
|
+
return typeof u === "string" ? u.trim() : null;
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function rawEntry(item) {
|
|
1529
|
+
const use = entryUse(item);
|
|
1530
|
+
if (!use)
|
|
1531
|
+
return null;
|
|
1532
|
+
if (!isMap(item))
|
|
1533
|
+
return { use, enabled: true };
|
|
1534
|
+
const x = item.get("export");
|
|
1535
|
+
return {
|
|
1536
|
+
use,
|
|
1537
|
+
...typeof x === "string" && x.trim() ? { export: x.trim() } : {},
|
|
1538
|
+
enabled: item.get("enabled") !== false
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
function sameExtension(a, b) {
|
|
1542
|
+
if (a.trim() === b.trim())
|
|
1543
|
+
return true;
|
|
1544
|
+
let pa;
|
|
1545
|
+
let pb;
|
|
1546
|
+
try {
|
|
1547
|
+
pa = parseExtensionSpecifier(a);
|
|
1548
|
+
pb = parseExtensionSpecifier(b);
|
|
1549
|
+
} catch {
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
return pa.kind === "npm" && pb.kind === "npm" && pa.name === pb.name;
|
|
1553
|
+
}
|
|
1554
|
+
function indexOfUse(seq, use) {
|
|
1555
|
+
const items = seq.items;
|
|
1556
|
+
const exact = items.findIndex((item) => entryUse(item) === use.trim());
|
|
1557
|
+
if (exact >= 0)
|
|
1558
|
+
return exact;
|
|
1559
|
+
return items.findIndex((item) => {
|
|
1560
|
+
const u = entryUse(item);
|
|
1561
|
+
return !!u && sameExtension(u, use);
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
function manifestPathFor(projectDir, abs) {
|
|
1565
|
+
const rel = path4.relative(path4.resolve(projectDir), abs).split(path4.sep).join("/");
|
|
1566
|
+
if (rel === "") {
|
|
1567
|
+
throw new Error("An extension cannot be the book folder itself.");
|
|
1568
|
+
}
|
|
1569
|
+
if (isPathSpecifier(rel))
|
|
1570
|
+
return rel;
|
|
1571
|
+
return `./${rel}`;
|
|
1572
|
+
}
|
|
1573
|
+
var NO_CARRIES = {
|
|
1574
|
+
markdown: false,
|
|
1575
|
+
styles: false,
|
|
1576
|
+
snippets: false,
|
|
1577
|
+
components: false,
|
|
1578
|
+
publish: false
|
|
1579
|
+
};
|
|
1580
|
+
async function describeFolder(dir, base, fallbackLabel, labelFrom = prettify) {
|
|
1581
|
+
const meta = await readExtensionMeta(dir);
|
|
1582
|
+
const styles = extensionStyleList(meta);
|
|
1583
|
+
return {
|
|
1584
|
+
...base,
|
|
1585
|
+
label: meta.name?.trim() ? labelFrom(meta.name.trim()) : fallbackLabel,
|
|
1586
|
+
...meta.description?.trim() ? { description: meta.description.trim() } : {},
|
|
1587
|
+
...meta.author?.trim() ? { author: meta.author.trim() } : {},
|
|
1588
|
+
...meta.preview !== undefined ? { preview: meta.preview } : {},
|
|
1589
|
+
...meta.tokensFile?.trim() ? { tokensFile: meta.tokensFile.trim() } : {},
|
|
1590
|
+
...styles.length > 0 ? { styles } : {},
|
|
1591
|
+
carries: extensionCarries(meta),
|
|
1592
|
+
dir
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
async function describeEntry(projectDir, raw) {
|
|
1596
|
+
const warnings = [];
|
|
1597
|
+
let parsed = null;
|
|
1598
|
+
try {
|
|
1599
|
+
parsed = parseExtensionSpecifier(raw.use);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
1602
|
+
}
|
|
1603
|
+
const common = {
|
|
1604
|
+
use: raw.use,
|
|
1605
|
+
enabled: raw.enabled,
|
|
1606
|
+
...raw.export ? { export: raw.export } : {}
|
|
1607
|
+
};
|
|
1608
|
+
const withWarnings = (entry) => warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
|
|
1609
|
+
if (!parsed) {
|
|
1610
|
+
return withWarnings({ ...common, kind: "npm", name: raw.use, label: raw.use, carries: NO_CARRIES });
|
|
1611
|
+
}
|
|
1612
|
+
if (parsed.kind === "bundled") {
|
|
1613
|
+
const bundledName = parsed.name;
|
|
1614
|
+
const rec = RECOMMENDED_EXTENSIONS.find((r) => r.use === bundledName);
|
|
1615
|
+
return withWarnings({
|
|
1616
|
+
...common,
|
|
1617
|
+
kind: "bundled",
|
|
1618
|
+
name: bundledName,
|
|
1619
|
+
label: rec?.label ?? bundledName,
|
|
1620
|
+
...rec ? { description: rec.description } : {},
|
|
1621
|
+
carries: { ...NO_CARRIES, markdown: true }
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
if (parsed.kind === "path") {
|
|
1625
|
+
const abs = path4.resolve(projectDir, parsed.path);
|
|
1626
|
+
const base2 = { ...common, kind: "path", name: parsed.path };
|
|
1627
|
+
let info;
|
|
1628
|
+
try {
|
|
1629
|
+
info = await stat(abs);
|
|
1630
|
+
} catch {
|
|
1631
|
+
warnings.push(`Not found: ${abs}`);
|
|
1632
|
+
return withWarnings({ ...base2, label: path4.basename(abs), carries: NO_CARRIES });
|
|
1633
|
+
}
|
|
1634
|
+
if (info.isDirectory()) {
|
|
1635
|
+
return withWarnings(await describeFolder(abs, base2, prettify(path4.basename(abs))));
|
|
1636
|
+
}
|
|
1637
|
+
return withWarnings({
|
|
1638
|
+
...base2,
|
|
1639
|
+
label: path4.basename(abs),
|
|
1640
|
+
carries: { ...NO_CARRIES, markdown: true }
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
const base = {
|
|
1644
|
+
...common,
|
|
1645
|
+
kind: "npm",
|
|
1646
|
+
name: parsed.name,
|
|
1647
|
+
...parsed.version ? { version: parsed.version } : {}
|
|
1648
|
+
};
|
|
1649
|
+
if (!parsed.version) {
|
|
1650
|
+
warnings.push(`Not pinned — run \`gutterpress ext add ${parsed.name}\` to install it and pin an exact version.`);
|
|
1651
|
+
return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
|
|
1652
|
+
}
|
|
1653
|
+
let installRoot = null;
|
|
1654
|
+
try {
|
|
1655
|
+
installRoot = await resolveVendoredPluginInstallRoot(projectDir, parsed.name, parsed.version);
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
1658
|
+
}
|
|
1659
|
+
if (!installRoot) {
|
|
1660
|
+
warnings.push(`Not installed — run \`gutterpress ext add ${raw.use}\`.`);
|
|
1661
|
+
return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
|
|
1662
|
+
}
|
|
1663
|
+
const pkgDir = vendoredNpmPluginPackageDir(installRoot, parsed.name);
|
|
1664
|
+
if (!existsSync3(path4.join(pkgDir, EXTENSION_MANIFEST_FILENAME))) {
|
|
1665
|
+
return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
|
|
1666
|
+
}
|
|
1667
|
+
const described = await describeFolder(pkgDir, base, parsed.name, (name) => name);
|
|
1668
|
+
return withWarnings({
|
|
1669
|
+
...described,
|
|
1670
|
+
carries: { ...described.carries, markdown: !described.carries.publish }
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
async function listProjectExtensions(projectDir) {
|
|
1674
|
+
const { doc } = await loadManifestDoc(projectDir);
|
|
1675
|
+
const seq = doc.get("extensions", true);
|
|
1676
|
+
if (!isSeq2(seq))
|
|
1677
|
+
return [];
|
|
1678
|
+
const out = [];
|
|
1679
|
+
for (const item of seq.items) {
|
|
1680
|
+
const raw = rawEntry(item);
|
|
1681
|
+
if (!raw)
|
|
1682
|
+
continue;
|
|
1683
|
+
out.push(await describeEntry(projectDir, raw));
|
|
1684
|
+
}
|
|
1685
|
+
return out;
|
|
1686
|
+
}
|
|
1687
|
+
async function describeExtension(projectDir, use) {
|
|
1688
|
+
return (await listProjectExtensions(projectDir)).find((e) => sameExtension(e.use, use)) ?? null;
|
|
1689
|
+
}
|
|
1690
|
+
function extensionConfigFor(entry) {
|
|
1691
|
+
return {
|
|
1692
|
+
use: entry.use,
|
|
1693
|
+
...entry.kind === "path" ? { path: entry.name } : { name: entry.name, ...entry.version ? { version: entry.version } : {} },
|
|
1694
|
+
...entry.export ? { export: entry.export } : {},
|
|
1695
|
+
options: {}
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
async function setExtensionEnabled(projectDir, use, enabled) {
|
|
1699
|
+
return withMutationLock(projectDir, async () => {
|
|
1700
|
+
const { doc, file } = await loadManifestDoc(projectDir);
|
|
1701
|
+
const seq = doc.get("extensions", true);
|
|
1702
|
+
const idx = isSeq2(seq) ? indexOfUse(seq, use) : -1;
|
|
1703
|
+
if (!isSeq2(seq) || idx < 0)
|
|
1704
|
+
throw new Error(`Extension "${use}" is not in the manifest.`);
|
|
1705
|
+
const item = seq.items[idx];
|
|
1706
|
+
if (enabled) {
|
|
1707
|
+
if (isMap(item)) {
|
|
1708
|
+
item.delete("enabled");
|
|
1709
|
+
if (item.items.length === 1 && typeof item.get("use") === "string") {
|
|
1710
|
+
seq.items[idx] = new Scalar(item.get("use"));
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
} else if (isMap(item)) {
|
|
1714
|
+
item.set("enabled", false);
|
|
1715
|
+
} else {
|
|
1716
|
+
const map = new YAMLMap(doc.schema);
|
|
1717
|
+
map.set("use", entryUse(item));
|
|
1718
|
+
map.set("enabled", false);
|
|
1719
|
+
seq.items[idx] = map;
|
|
1720
|
+
}
|
|
1721
|
+
await writeManifestDoc(file, doc);
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
async function reorderExtensions(projectDir, order) {
|
|
1725
|
+
return withMutationLock(projectDir, async () => {
|
|
1726
|
+
const { doc, file } = await loadManifestDoc(projectDir);
|
|
1727
|
+
const seq = doc.get("extensions", true);
|
|
1728
|
+
if (!isSeq2(seq))
|
|
1729
|
+
throw new Error(`No extensions are configured in ${path4.basename(file)}.`);
|
|
1730
|
+
const items = seq.items;
|
|
1731
|
+
const current = items.map((item) => entryUse(item));
|
|
1732
|
+
const wanted = order.map((u) => u.trim());
|
|
1733
|
+
const sameSet = current.length === wanted.length && [...current].sort().join(`
|
|
1734
|
+
`) === [...wanted].sort().join(`
|
|
1735
|
+
`);
|
|
1736
|
+
if (!sameSet) {
|
|
1737
|
+
throw new Error("The new order must list every configured extension exactly once — reload the list and try again.");
|
|
1738
|
+
}
|
|
1739
|
+
const remaining = [...items];
|
|
1740
|
+
seq.items = wanted.map((u) => {
|
|
1741
|
+
const i = remaining.findIndex((item) => entryUse(item) === u);
|
|
1742
|
+
return remaining.splice(i, 1)[0];
|
|
1743
|
+
});
|
|
1744
|
+
await writeManifestDoc(file, doc);
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
async function removeExtension(projectDir, use) {
|
|
1748
|
+
return withMutationLock(projectDir, async () => {
|
|
1749
|
+
const { doc, file } = await loadManifestDoc(projectDir);
|
|
1750
|
+
const seq = doc.get("extensions", true);
|
|
1751
|
+
const idx = isSeq2(seq) ? indexOfUse(seq, use) : -1;
|
|
1752
|
+
if (!isSeq2(seq) || idx < 0)
|
|
1753
|
+
throw new Error(`Extension "${use}" is not in the manifest.`);
|
|
1754
|
+
const written = entryUse(seq.items[idx]);
|
|
1755
|
+
seq.items.splice(idx, 1);
|
|
1756
|
+
if (seq.items.length === 0)
|
|
1757
|
+
doc.delete("extensions");
|
|
1758
|
+
await writeManifestDoc(file, doc);
|
|
1759
|
+
let parsed = null;
|
|
1760
|
+
try {
|
|
1761
|
+
parsed = parseExtensionSpecifier(written);
|
|
1762
|
+
} catch {
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
if (parsed.kind === "npm" && parsed.version) {
|
|
1766
|
+
const root = vendoredNpmPluginRoot(projectDir, parsed.name, parsed.version);
|
|
1767
|
+
await rm3(root, { recursive: true, force: true });
|
|
1768
|
+
await removeEmptyDirs([path4.dirname(root)]);
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
async function upsertEntry(projectDir, use, exportName) {
|
|
1773
|
+
const { doc, file } = await loadManifestDoc(projectDir);
|
|
1774
|
+
const seq = ensureSeq(doc, "extensions");
|
|
1775
|
+
const idx = indexOfUse(seq, use);
|
|
1776
|
+
if (idx >= 0) {
|
|
1777
|
+
const item = seq.items[idx];
|
|
1778
|
+
if (isMap(item)) {
|
|
1779
|
+
item.set("use", use);
|
|
1780
|
+
if (exportName)
|
|
1781
|
+
item.set("export", exportName);
|
|
1782
|
+
else
|
|
1783
|
+
item.delete("export");
|
|
1784
|
+
if (item.items.length === 1)
|
|
1785
|
+
seq.items[idx] = new Scalar(use);
|
|
1786
|
+
} else if (exportName) {
|
|
1787
|
+
const map = new YAMLMap(doc.schema);
|
|
1788
|
+
map.set("use", use);
|
|
1789
|
+
map.set("export", exportName);
|
|
1790
|
+
seq.items[idx] = map;
|
|
1791
|
+
} else {
|
|
1792
|
+
seq.items[idx] = new Scalar(use);
|
|
1793
|
+
}
|
|
1794
|
+
} else if (exportName) {
|
|
1795
|
+
const map = new YAMLMap(doc.schema);
|
|
1796
|
+
map.set("use", use);
|
|
1797
|
+
map.set("export", exportName);
|
|
1798
|
+
seq.add(map);
|
|
1799
|
+
} else {
|
|
1800
|
+
seq.add(new Scalar(use));
|
|
1801
|
+
}
|
|
1802
|
+
await writeManifestDoc(file, doc);
|
|
1803
|
+
}
|
|
1804
|
+
async function addPathExtension(projectDir, abs, exportName) {
|
|
1805
|
+
try {
|
|
1806
|
+
await stat(abs);
|
|
1807
|
+
} catch {
|
|
1808
|
+
throw new Error(`Extension not found: ${abs}`);
|
|
1809
|
+
}
|
|
1810
|
+
const use = manifestPathFor(projectDir, abs);
|
|
1811
|
+
await loadPlugin(extensionConfigFor({ use, kind: "path", name: use, export: exportName }), projectDir);
|
|
1812
|
+
await upsertEntry(projectDir, use, exportName);
|
|
1813
|
+
return await describeExtension(projectDir, use);
|
|
1814
|
+
}
|
|
1815
|
+
async function addExtension(projectDir, specifier, options = {}) {
|
|
1816
|
+
return withMutationLock(projectDir, () => addExtensionUnlocked(projectDir, specifier, options));
|
|
1817
|
+
}
|
|
1818
|
+
async function addExtensionUnlocked(projectDir, specifier, options) {
|
|
1819
|
+
const input = specifier.trim();
|
|
1820
|
+
const exportName = options.exportName?.trim() || undefined;
|
|
1821
|
+
if (path4.isAbsolute(input))
|
|
1822
|
+
return addPathExtension(projectDir, input, exportName);
|
|
1823
|
+
const parsed = parseExtensionSpecifier(input);
|
|
1824
|
+
if (parsed.kind === "path") {
|
|
1825
|
+
return addPathExtension(projectDir, path4.resolve(projectDir, parsed.path), exportName);
|
|
1826
|
+
}
|
|
1827
|
+
if (parsed.kind === "bundled") {
|
|
1828
|
+
if (exportName)
|
|
1829
|
+
throw new Error(`Bundled extension "${parsed.name}" does not take a named export.`);
|
|
1830
|
+
await upsertEntry(projectDir, parsed.name);
|
|
1831
|
+
return await describeExtension(projectDir, parsed.name);
|
|
1832
|
+
}
|
|
1833
|
+
const existing = await describeExtension(projectDir, parsed.name);
|
|
1834
|
+
const chosenExport = exportName ?? existing?.export;
|
|
1835
|
+
const { name, version, use, warnings } = await vendorNpmExtension(projectDir, input, chosenExport, options, (use2) => upsertEntry(projectDir, use2, chosenExport));
|
|
1836
|
+
warnings.push(...await pruneStaleVersions(projectDir, name, [version, ...await pinnedVersionsOf(projectDir, name)]));
|
|
1837
|
+
const entry = await describeExtension(projectDir, use);
|
|
1838
|
+
return warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
|
|
1839
|
+
}
|
|
1840
|
+
function restoreNpmExtension(projectDir, name, version, exportName, options = {}) {
|
|
1841
|
+
return withMutationLock(projectDir, async () => {
|
|
1842
|
+
if (!await vendoredCopyNeedsInstall(projectDir, name, version))
|
|
1843
|
+
return [];
|
|
1844
|
+
const restored = await vendorNpmExtension(projectDir, `${name}@${version}`, exportName, options, async () => {});
|
|
1845
|
+
return [...restored.warnings, ...await pruneStaleVersions(projectDir, name, [version, ...options.keepVersions ?? []])];
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
async function vendoredCopyNeedsInstall(projectDir, name, version) {
|
|
1849
|
+
try {
|
|
1850
|
+
const installRoot = await resolveVendoredPluginInstallRoot(projectDir, name, version);
|
|
1851
|
+
if (!installRoot)
|
|
1852
|
+
return true;
|
|
1853
|
+
await resolvePackageEntry(vendoredNpmPluginPackageDir(installRoot, name));
|
|
1854
|
+
return false;
|
|
1855
|
+
} catch {
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
async function vendorNpmExtension(projectDir, input, exportName, options, commit) {
|
|
1860
|
+
const installed = await installNpmPlugin(projectDir, input, options);
|
|
1861
|
+
const use = pinnedNpmSpecifier(installed.name, installed.version);
|
|
1862
|
+
try {
|
|
1863
|
+
await loadPlugin(extensionConfigFor({
|
|
1864
|
+
use,
|
|
1865
|
+
kind: "npm",
|
|
1866
|
+
name: installed.name,
|
|
1867
|
+
version: installed.version,
|
|
1868
|
+
export: exportName
|
|
1869
|
+
}), projectDir);
|
|
1870
|
+
await options.__testFailBeforeManifestCommit?.();
|
|
1871
|
+
await commit(use);
|
|
1872
|
+
} catch (cause) {
|
|
1873
|
+
try {
|
|
1874
|
+
await rollbackNpmPluginInstall(installed);
|
|
1875
|
+
} catch (rollbackError) {
|
|
1876
|
+
throw new Error(`Extension install failed and its previous vendor tree could not be restored: ` + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause });
|
|
1877
|
+
}
|
|
1878
|
+
throw new Error(`Downloaded ${use}, but it is not a loadable markdown-it plugin: ` + `${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
1879
|
+
}
|
|
1880
|
+
const warnings = [...installed.warnings];
|
|
1881
|
+
await finalizeNpmPluginInstall(installed).catch((error) => {
|
|
1882
|
+
warnings.push(`The extension was installed, but an old backup could not be removed: ` + `${error instanceof Error ? error.message : String(error)}`);
|
|
1883
|
+
});
|
|
1884
|
+
try {
|
|
1885
|
+
const { negated } = await ensureProjectGitignore(projectDir);
|
|
1886
|
+
if (negated.includes("plugins/npm/")) {
|
|
1887
|
+
warnings.push("Your .gitignore re-includes plugins/npm/, so downloaded extensions will be committed with " + "your book. They shouldn't be: the manifest pins each version and a build downloads a " + "missing copy again.");
|
|
1888
|
+
}
|
|
1889
|
+
} catch (error) {
|
|
1890
|
+
warnings.push(`The extension was installed, but .gitignore could not be updated to keep it out of version history: ` + `${error instanceof Error ? error.message : String(error)}`);
|
|
1891
|
+
}
|
|
1892
|
+
return { name: installed.name, version: installed.version, use, warnings };
|
|
1893
|
+
}
|
|
1894
|
+
async function pinnedVersionsOf(projectDir, name) {
|
|
1895
|
+
const { doc } = await loadManifestDoc(projectDir);
|
|
1896
|
+
const seq = doc.get("extensions", true);
|
|
1897
|
+
if (!isSeq2(seq))
|
|
1898
|
+
return [];
|
|
1899
|
+
const versions = [];
|
|
1900
|
+
for (const item of seq.items) {
|
|
1901
|
+
const raw = rawEntry(item);
|
|
1902
|
+
if (!raw?.enabled)
|
|
1903
|
+
continue;
|
|
1904
|
+
try {
|
|
1905
|
+
const parsed = parseExtensionSpecifier(raw.use);
|
|
1906
|
+
if (parsed.kind === "npm" && parsed.name === name && parsed.version)
|
|
1907
|
+
versions.push(parsed.version);
|
|
1908
|
+
} catch {}
|
|
1909
|
+
}
|
|
1910
|
+
return versions;
|
|
1911
|
+
}
|
|
1912
|
+
async function pruneStaleVersions(projectDir, name, keep) {
|
|
1913
|
+
const parent = path4.dirname(vendoredNpmPluginRoot(projectDir, name, "0.0.0"));
|
|
1914
|
+
try {
|
|
1915
|
+
for (const entry of await readdir(parent, { withFileTypes: true })) {
|
|
1916
|
+
if (!entry.isDirectory())
|
|
1917
|
+
continue;
|
|
1918
|
+
let version;
|
|
1919
|
+
try {
|
|
1920
|
+
version = decodeURIComponent(entry.name);
|
|
1921
|
+
} catch {
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
if (!isExactNpmVersion(version) || keep.includes(version))
|
|
1925
|
+
continue;
|
|
1926
|
+
await rm3(path4.join(parent, entry.name), { recursive: true, force: true });
|
|
1927
|
+
}
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
if (error.code === "ENOENT")
|
|
1930
|
+
return [];
|
|
1931
|
+
return [
|
|
1932
|
+
`The extension was installed, but a previous version's downloaded copy could not be removed: ` + `${error instanceof Error ? error.message : String(error)}`
|
|
1933
|
+
];
|
|
1934
|
+
}
|
|
1935
|
+
return [];
|
|
1936
|
+
}
|
|
1937
|
+
var LATEST_LOOKUP_TIMEOUT_MS = 30000;
|
|
1938
|
+
var MAX_PACKUMENT_BYTES = 15 * 1024 * 1024;
|
|
1939
|
+
async function fetchNpmVersions(name, options) {
|
|
1940
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1941
|
+
return withFetchTimeout({
|
|
1942
|
+
timeoutMs: LATEST_LOOKUP_TIMEOUT_MS,
|
|
1943
|
+
signal: options.signal,
|
|
1944
|
+
timeoutMessage: `Looking up ${name} on npm timed out. Check your connection and try again.`,
|
|
1945
|
+
offlineMessage: (cause) => `Looking up ${name} on npm failed (${cause instanceof Error ? cause.message : String(cause)}).`
|
|
1946
|
+
}, async (signal) => {
|
|
1947
|
+
const response = await fetchImpl(`${npmRegistryUrl()}/${encodeURIComponent(name)}`, {
|
|
1948
|
+
signal,
|
|
1949
|
+
redirect: "error",
|
|
1950
|
+
headers: { accept: "application/vnd.npm.install-v1+json" }
|
|
1951
|
+
});
|
|
1952
|
+
if (response.status === 404)
|
|
1953
|
+
throw new FriendlyHttpError(`npm package "${name}" was not found.`);
|
|
1954
|
+
if (!response.ok)
|
|
1955
|
+
throw new FriendlyHttpError(`Looking up ${name} on npm failed (HTTP ${response.status}).`);
|
|
1956
|
+
const bytes = await response.arrayBuffer();
|
|
1957
|
+
if (bytes.byteLength > MAX_PACKUMENT_BYTES) {
|
|
1958
|
+
throw new FriendlyHttpError(`npm metadata for "${name}" is too large to read.`);
|
|
1959
|
+
}
|
|
1960
|
+
try {
|
|
1961
|
+
const packument = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
1962
|
+
return {
|
|
1963
|
+
latest: packument["dist-tags"]?.latest,
|
|
1964
|
+
versions: Object.keys(packument.versions ?? {}).filter(isExactNpmVersion)
|
|
1965
|
+
};
|
|
1966
|
+
} catch {
|
|
1967
|
+
throw new FriendlyHttpError(`npm metadata for "${name}" is not valid JSON.`);
|
|
1968
|
+
}
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
async function latestNpmVersion(name, options) {
|
|
1972
|
+
const { latest, versions } = await fetchNpmVersions(name, options);
|
|
1973
|
+
if (options.includePrerelease) {
|
|
1974
|
+
const newest = versions.sort(semverRcompare)[0];
|
|
1975
|
+
if (newest)
|
|
1976
|
+
return newest;
|
|
1977
|
+
}
|
|
1978
|
+
if (typeof latest !== "string" || !isExactNpmVersion(latest)) {
|
|
1979
|
+
throw new FriendlyHttpError(`npm metadata for "${name}" has no latest version.`);
|
|
1980
|
+
}
|
|
1981
|
+
return latest;
|
|
1982
|
+
}
|
|
1983
|
+
async function listNpmVersions(name, options = {}) {
|
|
1984
|
+
return (await fetchNpmVersions(name, options)).versions.sort(semverRcompare);
|
|
1985
|
+
}
|
|
1986
|
+
async function checkExtensionUpdates(projectDir, options = {}) {
|
|
1987
|
+
const entries = await listProjectExtensions(projectDir);
|
|
1988
|
+
const pinned = entries.filter((e) => e.kind === "npm" && e.version);
|
|
1989
|
+
return Promise.all(pinned.map(async (e) => {
|
|
1990
|
+
const current = e.version;
|
|
1991
|
+
const latest = await latestNpmVersion(e.name, options);
|
|
1992
|
+
return { use: e.use, name: e.name, current, latest, outdated: semverGt(latest, current) };
|
|
1993
|
+
}));
|
|
1994
|
+
}
|
|
1995
|
+
async function updateExtensions(projectDir, options = {}) {
|
|
1996
|
+
let checks = await checkExtensionUpdates(projectDir, options);
|
|
1997
|
+
if (options.only) {
|
|
1998
|
+
checks = checks.filter((c) => c.name === options.only);
|
|
1999
|
+
if (checks.length === 0) {
|
|
2000
|
+
throw new Error(`"${options.only}" is not a pinned npm extension of this book.`);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const updated = [];
|
|
2004
|
+
for (const check of checks) {
|
|
2005
|
+
if (!check.outdated)
|
|
2006
|
+
continue;
|
|
2007
|
+
await addExtension(projectDir, pinnedNpmSpecifier(check.name, check.latest), {
|
|
2008
|
+
fetch: options.fetch,
|
|
2009
|
+
signal: options.signal,
|
|
2010
|
+
limits: options.limits
|
|
2011
|
+
});
|
|
2012
|
+
updated.push({ name: check.name, from: check.current, to: check.latest });
|
|
2013
|
+
}
|
|
2014
|
+
return updated;
|
|
2015
|
+
}
|
|
2016
|
+
async function listBuiltInStyleSets() {
|
|
2017
|
+
const assets = await getAssetsDir();
|
|
2018
|
+
const out = [];
|
|
2019
|
+
for (const id of BUILT_IN_STYLE_SET_IDS) {
|
|
2020
|
+
const meta = await readExtensionMeta(path4.join(assets, "themes", id));
|
|
2021
|
+
out.push({
|
|
2022
|
+
id,
|
|
2023
|
+
name: prettify(meta.name?.trim() || id),
|
|
2024
|
+
description: meta.description?.trim() || ""
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
return out;
|
|
2028
|
+
}
|
|
2029
|
+
async function uniqueExtensionId(projectDir, base) {
|
|
2030
|
+
const slug = slugify(base) || "extension";
|
|
2031
|
+
let candidate = slug;
|
|
2032
|
+
for (let n = 2;existsSync3(path4.join(projectDir, EXTENSIONS_DIR, candidate)); n++) {
|
|
2033
|
+
candidate = `${slug}-${n}`;
|
|
2034
|
+
}
|
|
2035
|
+
return candidate;
|
|
2036
|
+
}
|
|
2037
|
+
async function addBuiltInStyleSet(projectDir, id) {
|
|
2038
|
+
if (!BUILT_IN_STYLE_SET_IDS.includes(id)) {
|
|
2039
|
+
throw new Error(`Unknown built-in look "${id}" (one of: ${BUILT_IN_STYLE_SET_IDS.join(", ")}).`);
|
|
2040
|
+
}
|
|
2041
|
+
return withMutationLock(projectDir, async () => {
|
|
2042
|
+
const dest = path4.join(projectDir, EXTENSIONS_DIR, id);
|
|
2043
|
+
if (!existsSync3(dest)) {
|
|
2044
|
+
const src = path4.join(await getAssetsDir(), "themes", id);
|
|
2045
|
+
await mkdir3(path4.dirname(dest), { recursive: true });
|
|
2046
|
+
await cp(src, dest, { recursive: true });
|
|
2047
|
+
}
|
|
2048
|
+
return addExtensionUnlocked(projectDir, `./${EXTENSIONS_DIR}/${id}`, {});
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
async function validateProjectExtensions(projectDir) {
|
|
2052
|
+
const out = [];
|
|
2053
|
+
for (const entry of await listProjectExtensions(projectDir)) {
|
|
2054
|
+
if (!entry.enabled) {
|
|
2055
|
+
out.push({ use: entry.use, kind: entry.kind, enabled: false, ok: true });
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
try {
|
|
2059
|
+
await loadPlugin(extensionConfigFor(entry), projectDir);
|
|
2060
|
+
out.push({ use: entry.use, kind: entry.kind, enabled: true, ok: true });
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
out.push({
|
|
2063
|
+
use: entry.use,
|
|
2064
|
+
kind: entry.kind,
|
|
2065
|
+
enabled: true,
|
|
2066
|
+
ok: false,
|
|
2067
|
+
error: e instanceof Error ? e.message : String(e)
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return out;
|
|
2072
|
+
}
|
|
2073
|
+
async function readExtensionCss(projectDir, use) {
|
|
2074
|
+
const entry = await describeExtension(projectDir, use);
|
|
2075
|
+
if (!entry)
|
|
2076
|
+
throw new Error(`Extension "${use}" is not in the manifest.`);
|
|
2077
|
+
if (!entry.dir)
|
|
2078
|
+
throw new Error(`Extension "${use}" has no stylesheets to preview.`);
|
|
2079
|
+
const meta = await readExtensionMeta(entry.dir);
|
|
2080
|
+
const sheets = resolveExtension(entry.dir, meta, `Extension "${use}"`).styles ?? [];
|
|
2081
|
+
if (sheets.length === 0)
|
|
2082
|
+
throw new Error(`Extension "${use}" declares no stylesheets.`);
|
|
2083
|
+
const parts = [];
|
|
2084
|
+
for (const sheet of sheets)
|
|
2085
|
+
parts.push(await readFile5(sheet, "utf8"));
|
|
2086
|
+
return parts.join(`
|
|
2087
|
+
|
|
2088
|
+
`);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// src/lib/extension-restore.ts
|
|
2092
|
+
var OFFLINE_REASON = "you appear to be offline (npm can't be reached)";
|
|
2093
|
+
function pinnedEntries(manifest) {
|
|
2094
|
+
const found = new Map;
|
|
2095
|
+
for (const entry of Array.isArray(manifest.extensions) ? manifest.extensions : []) {
|
|
2096
|
+
const object2 = typeof entry === "object" && entry !== null ? entry : null;
|
|
2097
|
+
const use = typeof entry === "string" ? entry : object2?.use;
|
|
2098
|
+
if (typeof use !== "string" || object2?.enabled === false)
|
|
2099
|
+
continue;
|
|
2100
|
+
let parsed;
|
|
2101
|
+
try {
|
|
2102
|
+
parsed = parseExtensionSpecifier(use);
|
|
2103
|
+
} catch {
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
if (parsed.kind !== "npm" || !parsed.version || !isExactNpmVersion(parsed.version))
|
|
2107
|
+
continue;
|
|
2108
|
+
const spec = `${parsed.name}@${parsed.version}`;
|
|
2109
|
+
if (found.has(spec))
|
|
2110
|
+
continue;
|
|
2111
|
+
found.set(spec, {
|
|
2112
|
+
name: parsed.name,
|
|
2113
|
+
version: parsed.version,
|
|
2114
|
+
...typeof object2?.export === "string" && object2.export ? { export: object2.export } : {}
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
return [...found.values()];
|
|
2118
|
+
}
|
|
2119
|
+
async function restoreAll(projectDir, manifestFile, pins, options) {
|
|
2120
|
+
const result = { manifestFile, installed: [], failed: [], warnings: [], offline: false };
|
|
2121
|
+
const missing = [];
|
|
2122
|
+
for (const pin of pins)
|
|
2123
|
+
if (await vendoredCopyNeedsInstall(projectDir, pin.name, pin.version))
|
|
2124
|
+
missing.push(pin);
|
|
2125
|
+
if (missing.length === 0)
|
|
2126
|
+
return result;
|
|
2127
|
+
const report = options.onProgress ?? (() => {});
|
|
2128
|
+
const specs = missing.map((pin) => `${pin.name}@${pin.version}`);
|
|
2129
|
+
report({ type: "start", specs });
|
|
2130
|
+
for (const [index, pin] of missing.entries()) {
|
|
2131
|
+
const spec = specs[index];
|
|
2132
|
+
const step = { type: "package", spec, index, total: missing.length };
|
|
2133
|
+
if (result.offline) {
|
|
2134
|
+
result.failed.push({ use: spec, message: OFFLINE_REASON });
|
|
2135
|
+
report({ ...step, state: "failed", message: OFFLINE_REASON });
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
report({ ...step, state: "downloading" });
|
|
2139
|
+
try {
|
|
2140
|
+
result.warnings.push(...await restoreNpmExtension(projectDir, pin.name, pin.version, pin.export, {
|
|
2141
|
+
...options,
|
|
2142
|
+
keepVersions: pins.filter((other) => other.name === pin.name).map((other) => other.version)
|
|
2143
|
+
}));
|
|
2144
|
+
result.installed.push(spec);
|
|
2145
|
+
report({ ...step, state: "done" });
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
if (error instanceof FetchUnavailableError)
|
|
2148
|
+
result.offline = true;
|
|
2149
|
+
const message = result.offline ? OFFLINE_REASON : error instanceof Error ? error.message : String(error);
|
|
2150
|
+
result.failed.push({ use: spec, message });
|
|
2151
|
+
report({ ...step, state: "failed", message });
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
report({ type: "end", installed: result.installed, failed: result.failed });
|
|
2155
|
+
return result;
|
|
2156
|
+
}
|
|
2157
|
+
var inFlight = new Map;
|
|
2158
|
+
async function restorePinnedExtensions(projectDir, options = {}) {
|
|
2159
|
+
let loaded;
|
|
2160
|
+
try {
|
|
2161
|
+
loaded = await loadManifestWithPath(options.manifestPath ?? projectDir);
|
|
2162
|
+
} catch {
|
|
2163
|
+
return { manifestFile: null, installed: [], failed: [], warnings: [], offline: false };
|
|
2164
|
+
}
|
|
2165
|
+
const { manifest, manifestDir, manifestPath } = loaded;
|
|
2166
|
+
const pins = pinnedEntries(manifest);
|
|
2167
|
+
if (manifestPath === null || pins.length === 0) {
|
|
2168
|
+
return { manifestFile: manifestPath && path5.basename(manifestPath), installed: [], failed: [], warnings: [], offline: false };
|
|
2169
|
+
}
|
|
2170
|
+
const key = projectKey(manifestDir);
|
|
2171
|
+
const running = inFlight.get(key);
|
|
2172
|
+
if (running)
|
|
2173
|
+
return running;
|
|
2174
|
+
const run = restoreAll(manifestDir, path5.basename(manifestPath), pins, options).finally(() => {
|
|
2175
|
+
inFlight.delete(key);
|
|
2176
|
+
});
|
|
2177
|
+
inFlight.set(key, run);
|
|
2178
|
+
return run;
|
|
2179
|
+
}
|
|
2180
|
+
function restoreFailureMessage(failure, manifestFile) {
|
|
2181
|
+
return `Could not download ${failure.use} (pinned in ${manifestFile ?? "the manifest"}): ${failure.message.replace(/[.\s]+$/, "")}. ` + `Check your internet connection and try again, or install it yourself with \`gutterpress ext add ${failure.use}\`.`;
|
|
2182
|
+
}
|
|
2183
|
+
function restoreOfflineMessage(failed, manifestFile) {
|
|
2184
|
+
const list = failed.map((f) => f.use).join(", ");
|
|
2185
|
+
return `You appear to be offline, and this book's extensions are not installed yet: ${list} ` + `(pinned in ${manifestFile ?? "the manifest"}). Connect to the internet and try again to install them; ` + `once installed, they work offline.`;
|
|
2186
|
+
}
|
|
2187
|
+
async function restoreForCommand(dir, options) {
|
|
2188
|
+
const result = await restorePinnedExtensions(dir, { manifestPath: options.manifestPath });
|
|
2189
|
+
const file = result.manifestFile ?? "the manifest";
|
|
2190
|
+
for (const spec of result.installed)
|
|
2191
|
+
console.error(`Downloaded ${spec} (pinned in ${file})`);
|
|
2192
|
+
for (const warning of result.warnings)
|
|
2193
|
+
log.warn(warning);
|
|
2194
|
+
const offline = result.failed.filter((f) => result.offline && f.message === OFFLINE_REASON);
|
|
2195
|
+
const problems = [
|
|
2196
|
+
...result.failed.filter((f) => !offline.includes(f)).map((f) => restoreFailureMessage(f, result.manifestFile)),
|
|
2197
|
+
...offline.length > 0 ? [restoreOfflineMessage(offline, result.manifestFile)] : []
|
|
2198
|
+
];
|
|
2199
|
+
if (problems.length === 0)
|
|
2200
|
+
return;
|
|
2201
|
+
if (options.failFast)
|
|
2202
|
+
throw new BuildError(problems.join(`
|
|
2203
|
+
`), EXIT_CODES.PIPELINE);
|
|
2204
|
+
for (const problem of problems)
|
|
2205
|
+
log.warn(problem);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
export { BUNDLED_EXTENSIONS, isBundledExtension, isPathSpecifier, parseExtensionSpecifier, DEBOUNCE, UsageError, resolvePort, PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, resolveTargets, applyDefaultPdfStrictChecks, MANIFEST_FILENAMES, hasProjectManifest, loadManifest, loadManifestWithPath, resolveConfig, resolveConfigForTarget, FriendlyHttpError, withFetchTimeout, loadManifestDoc, writeManifestDoc, ensureSeq, scalarString, DEFAULT_NPM_REGISTRY, npmRegistryUrl, ensureProjectGitignore, EXTENSIONS_DIR, RECOMMENDED_EXTENSIONS, BUILT_IN_STYLE_SET_IDS, sameExtension, listProjectExtensions, describeExtension, extensionConfigFor, setExtensionEnabled, reorderExtensions, removeExtension, addExtension, listNpmVersions, checkExtensionUpdates, updateExtensions, listBuiltInStyleSets, uniqueExtensionId, addBuiltInStyleSet, validateProjectExtensions, readExtensionCss, restorePinnedExtensions, restoreFailureMessage, restoreOfflineMessage, restoreForCommand };
|