gutterpress 0.10.11 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/{README-7x5akcns.md → README-3nj919rm.md} +10 -8
  3. package/dist/{README-3zk4tgnj.md → README-8b33qbjw.md} +14 -21
  4. package/dist/api/index.js +2 -2
  5. package/dist/{audit-ebbbhpx7.js → audit-xcreaxvj.js} +4 -4
  6. package/dist/{build-zrajk3s2.js → build-j433exnc.js} +4 -4
  7. package/dist/{cli-gxq7yz5f.js → cli-9a60c9wn.js} +1 -1
  8. package/dist/{cli-36r50yr3.js → cli-r0ycvdr9.js} +1 -1
  9. package/dist/{cli-k8f9eb12.js → cli-sekhwex4.js} +5 -5
  10. package/dist/{cli-hz44te1m.js → cli-xa9y4np2.js} +1426 -1405
  11. package/dist/cli.js +14 -14
  12. package/dist/{doctor-78qft9g7.js → doctor-mmgg0p64.js} +2 -2
  13. package/dist/{engine-qtmvaqt7.js → engine-cbc17ne4.js} +1 -1
  14. package/dist/{engine-pt2acjb5.js → engine-w5ed5hmb.js} +2 -2
  15. package/dist/{ext-3g1mk8mb.js → ext-2vjqcmsh.js} +4 -4
  16. package/dist/{index-a0e2253t.js → index-12pvhbyz.js} +1 -1
  17. package/dist/{index-5dgxeevv.js → index-6n15e1ze.js} +5 -5
  18. package/dist/{index-03cqyyyk.js → index-g6t4qdzm.js} +1764 -1748
  19. package/dist/index.js +3 -3
  20. package/dist/lib/markdown/index.d.ts +8 -16
  21. package/dist/lib/markdown/plugins.d.ts +9 -4
  22. package/dist/lib/markdown/renderer.d.ts +18 -0
  23. package/dist/lib/project-scaffold.d.ts +1 -1
  24. package/dist/{lint-va95zh6j.js → lint-485wgyn0.js} +4 -4
  25. package/dist/{new-3ex11eyq.js → new-nayk48pr.js} +4 -4
  26. package/dist/plugin-spbcem4j.css +103 -0
  27. package/dist/{plugin.test.js-k2r87k04.tpl → plugin.test.js-nw7c84s6.tpl} +5 -6
  28. package/dist/{preflight-q8w6dnjh.js → preflight-wf4j4vs6.js} +4 -4
  29. package/dist/{preview-azatsrtp.js → preview-ke5fyhtt.js} +4 -4
  30. package/dist/{publish-5tqzepkb.js → publish-prnv6hf1.js} +4 -4
  31. package/dist/render.js +1 -1
  32. package/dist/{tokens-2sracx1w.css → tokens-7984461t.css} +8 -8
  33. package/dist/{validate-rp613ejg.js → validate-98fk1yqg.js} +4 -4
  34. package/package.json +1 -1
  35. package/dist/plugin-m2w7g47v.css +0 -114
@@ -13,12 +13,12 @@ import {
13
13
  stampCreator,
14
14
  stripAnnotations,
15
15
  warn
16
- } from "./cli-36r50yr3.js";
16
+ } from "./cli-r0ycvdr9.js";
17
17
  import {
18
18
  DEBOUNCE,
19
19
  UsageError,
20
20
  resolvePort
21
- } from "./cli-gxq7yz5f.js";
21
+ } from "./cli-9a60c9wn.js";
22
22
  import {
23
23
  MARGIN_BOX_IGNORED_PROPERTIES,
24
24
  RENDER_TIMEOUT_MS,
@@ -28,7 +28,7 @@ import {
28
28
  getAssetsDir,
29
29
  getBrowser,
30
30
  prewarmBrowser
31
- } from "./cli-k8f9eb12.js";
31
+ } from "./cli-sekhwex4.js";
32
32
  import {
33
33
  INSTALL_HINTS,
34
34
  execCapture,
@@ -5165,14 +5165,30 @@ function applyPlugins(md, plugins) {
5165
5165
  }
5166
5166
  }
5167
5167
  }
5168
- function collectPluginCss(plugins) {
5169
- return plugins.map((p) => p.css).filter((css) => typeof css === "string" && css.length > 0).join(`
5170
-
5171
- `);
5172
- }
5173
5168
  function collectPluginStylePaths(plugins) {
5174
5169
  return plugins.flatMap((p) => p.styles ?? []);
5175
5170
  }
5171
+ function collectPluginStyleGroups(plugins) {
5172
+ const taken = new Set;
5173
+ const groups = [];
5174
+ for (const p of plugins) {
5175
+ const paths = p.styles ?? [];
5176
+ const css = typeof p.css === "string" && p.css.length > 0 ? p.css : undefined;
5177
+ if (paths.length === 0 && !css)
5178
+ continue;
5179
+ const base = `ext.${layerSlug(p.name)}`;
5180
+ let layer = base;
5181
+ for (let n = 2;taken.has(layer); n++)
5182
+ layer = `${base}-${n}`;
5183
+ taken.add(layer);
5184
+ groups.push({ name: p.name, layer, paths, ...css ? { css } : {} });
5185
+ }
5186
+ return groups;
5187
+ }
5188
+ function layerSlug(name) {
5189
+ const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
5190
+ return /^[a-z]/.test(slug) ? slug : `x${slug}`;
5191
+ }
5176
5192
 
5177
5193
  // src/lib/markdown/plugins.ts
5178
5194
  import { createHash as createHash3 } from "node:crypto";
@@ -6085,12 +6101,12 @@ async function loadPlugins(configs, baseDir, onError) {
6085
6101
  }
6086
6102
  async function loadPluginsWithCss(configs, baseDir, onError) {
6087
6103
  if (!configs || configs.length === 0) {
6088
- return { plugins: undefined, pluginCss: "", pluginStylePaths: [] };
6104
+ return { plugins: undefined, pluginStyles: [], pluginStylePaths: [] };
6089
6105
  }
6090
6106
  const plugins = await loadPlugins(configs, baseDir, onError);
6091
6107
  return {
6092
6108
  plugins,
6093
- pluginCss: collectPluginCss(plugins),
6109
+ pluginStyles: collectPluginStyleGroups(plugins),
6094
6110
  pluginStylePaths: collectPluginStylePaths(plugins)
6095
6111
  };
6096
6112
  }
@@ -7635,7 +7651,7 @@ ${MARKER_CSS.trim()}
7635
7651
  @layer gp.vocab {
7636
7652
  ${GUTTERPRESS_CSS.trim()}
7637
7653
  }`,
7638
- pluginCss ? `/* user plugin css */
7654
+ pluginCss ? `/* extension css */
7639
7655
  ${pluginCss.trim()}` : null,
7640
7656
  projectCss ? `/* project css */
7641
7657
  ${projectCss.trim()}` : null
@@ -7669,16 +7685,34 @@ async function resolveActiveMarkdownFiles(inputDir, configuredFiles) {
7669
7685
  async function renderChapters(inputDir, opts = {}) {
7670
7686
  const styles = await resolveActiveStyles(inputDir, opts.styles);
7671
7687
  const inlined = await inlineStyles(inputDir, styles);
7672
- const pluginInlined = await inlineStyles(inputDir, opts.pluginStylePaths ?? []);
7673
- const styleWarnings = [...pluginInlined.warnings, ...inlined.warnings];
7688
+ const blocks = [];
7689
+ const layers = [];
7690
+ const styleWarnings = [];
7691
+ const cssAssetCopies = [];
7692
+ for (const group of opts.pluginStyles ?? []) {
7693
+ const groupInlined = await inlineStyles(inputDir, group.paths);
7694
+ styleWarnings.push(...groupInlined.warnings);
7695
+ cssAssetCopies.push(...groupInlined.copies);
7696
+ const css = [groupInlined.css, group.css].filter((s) => !!s && s.trim().length > 0).join(`
7697
+
7698
+ `);
7699
+ if (!css)
7700
+ continue;
7701
+ layers.push(group.layer);
7702
+ blocks.push(`/* ${group.name} */
7703
+ @layer ${group.layer} {
7704
+ ${css.trim()}
7705
+ }`);
7706
+ }
7707
+ styleWarnings.push(...inlined.warnings);
7674
7708
  if (styleWarnings.length > 0)
7675
7709
  opts.onStyleWarnings?.(styleWarnings);
7676
- const cssAssetCopies = [...pluginInlined.copies, ...inlined.copies];
7710
+ cssAssetCopies.push(...inlined.copies);
7677
7711
  if (cssAssetCopies.length > 0)
7678
7712
  opts.onCssAssets?.(cssAssetCopies);
7679
- const pluginCss = [pluginInlined.css, opts.pluginCss].filter((s) => !!s && s.trim().length > 0).join(`
7713
+ const pluginCss = blocks.length > 0 ? [`@layer ${layers.join(", ")};`, ...blocks].join(`
7680
7714
 
7681
- `);
7715
+ `) : "";
7682
7716
  const files = await resolveActiveMarkdownFiles(inputDir, opts.files);
7683
7717
  if (files.length === 0) {
7684
7718
  throw new Error(`No markdown files found in ${inputDir}`);
@@ -7687,7 +7721,7 @@ async function renderChapters(inputDir, opts = {}) {
7687
7721
  files,
7688
7722
  readText: (relPath) => readFile19(join2(inputDir, canonicalChapterId(relPath)), "utf-8"),
7689
7723
  projectCss: inlined.css,
7690
- preloadImages: [...pluginInlined.copies.map((c) => c.to), ...inlined.copies.map((c) => c.to)],
7724
+ preloadImages: cssAssetCopies.map((c) => c.to),
7691
7725
  title: opts.title,
7692
7726
  plugins: opts.plugins,
7693
7727
  pluginCss,
@@ -7704,8 +7738,7 @@ async function renderChaptersToFile(inputDir, outDir, opts = {}) {
7704
7738
  styles: opts.styles,
7705
7739
  files: opts.files,
7706
7740
  plugins: opts.plugins,
7707
- pluginCss: opts.pluginCss,
7708
- pluginStylePaths: opts.pluginStylePaths,
7741
+ pluginStyles: opts.pluginStyles,
7709
7742
  onChapterWarnings: opts.onChapterWarnings,
7710
7743
  onImageRefs: opts.onImageRefs,
7711
7744
  onCssAssets: opts.onCssAssets,
@@ -8581,7 +8614,7 @@ async function renderBook(ctx) {
8581
8614
  } else {
8582
8615
  log.info("Using all .md files in alphabetical order");
8583
8616
  }
8584
- const { plugins, pluginCss, pluginStylePaths } = await loadBuildPlugins(ctx);
8617
+ const { plugins, pluginStyles } = await loadBuildPlugins(ctx);
8585
8618
  const imageRefs = [];
8586
8619
  const cssAssets = [];
8587
8620
  const htmlFile = await renderChaptersToFile(renderDir, workDir, {
@@ -8589,8 +8622,7 @@ async function renderBook(ctx) {
8589
8622
  styles: config.styles,
8590
8623
  files: config.source.files,
8591
8624
  plugins,
8592
- pluginCss,
8593
- pluginStylePaths,
8625
+ pluginStyles,
8594
8626
  onChapterWarnings: (file, warnings) => {
8595
8627
  for (const w of warnings) {
8596
8628
  const key = layoutWarningKey(path9.resolve(renderDir, file), w.line, w.message);
@@ -8736,7 +8768,7 @@ class PdfOutput {
8736
8768
  const rawPdf = pdfxMode ? path9.join(stage, "raw.pdf") : path9.resolve(pdfFile);
8737
8769
  await fsp2.mkdir(path9.dirname(path9.resolve(pdfFile)), { recursive: true });
8738
8770
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
8739
- const { buildNativePdf } = await import("./engine-qtmvaqt7.js");
8771
+ const { buildNativePdf } = await import("./engine-cbc17ne4.js");
8740
8772
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
8741
8773
  title: config.title,
8742
8774
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
@@ -8902,14 +8934,13 @@ function incrementalPreviewEnabled() {
8902
8934
  return process.env.GUTTERPRESS_PREVIEW_INCREMENTAL !== "0";
8903
8935
  }
8904
8936
  async function renderPreviewBook(inputPath, config, opts) {
8905
- const { plugins, pluginCss, pluginStylePaths } = await loadPluginsWithCss(config.extensions, inputPath, (ref, err) => warn(`Skipping plugin "${ref}" in preview — ${err.message}`));
8937
+ const { plugins, pluginStyles } = await loadPluginsWithCss(config.extensions, inputPath, (ref, err) => warn(`Skipping plugin "${ref}" in preview — ${err.message}`));
8906
8938
  return renderChapters(inputPath, {
8907
8939
  title: config.title ?? "Document",
8908
8940
  styles: config.styles,
8909
8941
  files: opts.files,
8910
8942
  plugins,
8911
- pluginCss,
8912
- pluginStylePaths,
8943
+ pluginStyles,
8913
8944
  wrapChapters: opts.wrapChapters,
8914
8945
  annotateSourceChapters: true,
8915
8946
  ...opts.onCssAssets ? { onCssAssets: opts.onCssAssets } : {},
@@ -10153,11 +10184,10 @@ async function startPreviewServer(options) {
10153
10184
  };
10154
10185
  }
10155
10186
 
10156
- // src/lib/extension-manager.ts
10157
- import { cp as cp2, mkdir as mkdir7, readFile as readFile26, rm as rm4, stat as stat5 } from "node:fs/promises";
10158
- import { existsSync as existsSync11 } from "node:fs";
10159
- import path16 from "node:path";
10160
- import { isSeq as isSeq2, isMap, isScalar, YAMLMap, Scalar } from "yaml";
10187
+ // src/lib/project-scaffold.ts
10188
+ import { access, copyFile, cp as cp2, mkdir as mkdir6, readdir as readdir5, readFile as readFile25, stat as stat5, writeFile as writeFile6 } from "node:fs/promises";
10189
+ import { constants as FS, existsSync as existsSync11 } from "node:fs";
10190
+ import path15 from "node:path";
10161
10191
 
10162
10192
  // src/lib/manifest-doc.ts
10163
10193
  import { existsSync as existsSync10 } from "node:fs";
@@ -10212,1518 +10242,1509 @@ function ensureSeq(doc, key) {
10212
10242
  return seq;
10213
10243
  }
10214
10244
 
10215
- // src/lib/npm-plugin-installer.ts
10216
- import { createHash as createHash7, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
10217
- import { createReadStream as createReadStream2 } from "node:fs";
10218
- import {
10219
- lstat as lstat2,
10220
- mkdir as mkdir6,
10221
- mkdtemp as mkdtemp2,
10222
- open as open2,
10223
- readFile as readFile25,
10224
- realpath as realpath2,
10225
- rename as rename2,
10226
- rm as rm3
10227
- } from "node:fs/promises";
10228
- import path15 from "node:path";
10229
- import { Transform } from "node:stream";
10230
- import { pipeline } from "node:stream/promises";
10231
- import { createGunzip } from "node:zlib";
10232
- import { maxSatisfying, satisfies as satisfies2, validRange as validRange2 } from "semver";
10233
-
10234
- // src/lib/fetch-timeout.ts
10235
- class FetchPassthroughError extends Error {
10236
- }
10237
-
10238
- class FriendlyHttpError extends FetchPassthroughError {
10245
+ // src/lib/project-scaffold.ts
10246
+ function starterOverridesCss() {
10247
+ return `/**
10248
+ * Your book's own styles.
10249
+ *
10250
+ * This file loads AFTER every extension, so any rule you add here overrides
10251
+ * a look's defaults. A new book has no look until you add one:
10252
+ * gutterpress ext add clean-book <book> --look (a built-in look)
10253
+ * gutterpress ext add <package> <book> (a look from npm)
10254
+ * A book has one look at a time; adding another turns the current one off.
10255
+ */
10256
+ `;
10239
10257
  }
10240
10258
 
10241
- class FetchUnavailableError extends Error {
10242
- }
10243
- function composeSignal(signal, timeoutMs) {
10244
- const timeout = AbortSignal.timeout(timeoutMs);
10245
- return {
10246
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
10247
- timeout
10248
- };
10249
- }
10250
- async function withFetchTimeout(options, run) {
10251
- const composed = composeSignal(options.signal, options.timeoutMs);
10252
- try {
10253
- return await run(composed.signal);
10254
- } catch (cause) {
10255
- if (cause instanceof FetchPassthroughError)
10256
- throw cause;
10257
- if (options.signal?.aborted)
10258
- throw cause;
10259
- const offline = typeof options.offlineMessage === "function" ? options.offlineMessage(cause) : options.offlineMessage;
10260
- const timedOut = composed.timeout.aborted || cause instanceof Error && cause.name === "TimeoutError";
10261
- if (cause instanceof Error && cause.name === "AbortError" && !timedOut)
10262
- throw cause;
10263
- throw new FetchUnavailableError(timedOut ? options.timeoutMessage ?? offline : offline, { cause });
10259
+ class CreateProjectErrorImpl extends Error {
10260
+ code;
10261
+ constructor(code, message) {
10262
+ super(message);
10263
+ this.name = "CreateProjectError";
10264
+ this.code = code;
10264
10265
  }
10265
10266
  }
10266
-
10267
- // src/lib/npm-plugin-installer.ts
10268
- var DEFAULT_LIMITS = {
10269
- metadataBytes: 15 * 1024 * 1024,
10270
- packageTarballBytes: 50 * 1024 * 1024,
10271
- totalNetworkBytes: 250 * 1024 * 1024,
10272
- packageFileBytes: 50 * 1024 * 1024,
10273
- totalUnpackedBytes: 512 * 1024 * 1024,
10274
- totalFiles: 50000,
10275
- totalPackages: 512,
10276
- dependencyDepth: 64
10277
- };
10278
- var METADATA_TIMEOUT_MS = 30000;
10279
- var TARBALL_TIMEOUT_MS = 120000;
10280
-
10281
- class SecurityInstallError extends FetchPassthroughError {
10282
- }
10283
-
10284
- class PackageUnavailableError extends Error {
10267
+ function slugifyProjectName(name) {
10268
+ return slugify(name);
10285
10269
  }
10286
- function isOptionalAvailabilityError(error2) {
10287
- return error2 instanceof PackageUnavailableError || error2 instanceof FriendlyHttpError || error2 instanceof FetchUnavailableError;
10270
+ function escapeYamlScalar(value) {
10271
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
10288
10272
  }
10289
- function object3(value) {
10290
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
10273
+ var DEFAULT_AUTHOR = "Anonymous";
10274
+ function isPositivePoints(value) {
10275
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
10291
10276
  }
10292
- function stringMap2(value) {
10293
- const map = object3(value);
10294
- if (!map)
10295
- return {};
10296
- const result = {};
10297
- for (const [name, selector] of Object.entries(map)) {
10298
- if (typeof selector === "string" && selector.trim())
10299
- result[name] = selector.trim();
10277
+ function requirePreset(preset, customPage) {
10278
+ if (!preset || !PRESET_IDS.includes(preset)) {
10279
+ const got = preset ? ` (got "${preset}")` : "";
10280
+ throw new CreateProjectErrorImpl("preset-required", `A preset is required to create a book${got}. Choose one of: ${PRESET_IDS.join(", ")}.`);
10300
10281
  }
10301
- return result;
10302
- }
10303
- function mb(bytes) {
10304
- return `${Math.ceil(bytes / 1024 / 1024)}MB`;
10305
- }
10306
- function isContained2(root, candidate) {
10307
- const relative2 = path15.relative(root, candidate);
10308
- return relative2 === "" || !relative2.startsWith("..") && !path15.isAbsolute(relative2);
10309
- }
10310
- function mergeLimits(overrides) {
10311
- const limits = { ...DEFAULT_LIMITS, ...overrides };
10312
- for (const [name, value] of Object.entries(limits)) {
10313
- if (!Number.isSafeInteger(value) || value <= 0) {
10314
- throw new Error(`Invalid npm plugin install limit ${name}: ${value}`);
10282
+ if (preset === "custom") {
10283
+ const missing = [];
10284
+ if (!customPage || !isPositivePoints(customPage.width))
10285
+ missing.push("page width");
10286
+ if (!customPage || !isPositivePoints(customPage.height))
10287
+ missing.push("page height");
10288
+ if (missing.length > 0) {
10289
+ throw new CreateProjectErrorImpl("custom-page-required", `The custom preset needs a trim size: ${missing.join(" and ")} in points ` + `(72pt = 1in — e.g. US Letter is 612 x 792).`);
10315
10290
  }
10291
+ } else if (customPage && (!isPositivePoints(customPage.width) || !isPositivePoints(customPage.height))) {
10292
+ throw new CreateProjectErrorImpl("custom-page-required", "Page overrides need a positive width and height in points (72pt = 1in).");
10316
10293
  }
10317
- return limits;
10294
+ if (customPage?.tolerance !== undefined && !isPositivePoints(customPage.tolerance)) {
10295
+ throw new CreateProjectErrorImpl("custom-page-required", "Page tolerance must be a positive number of points.");
10296
+ }
10297
+ return preset;
10318
10298
  }
10319
- async function streamResponse(ctx, response, maxBytes, totalLimitMessage, onChunk) {
10320
- if (!response.body)
10321
- throw new Error("Registry response had no body.");
10322
- const declared = Number(response.headers.get("content-length"));
10323
- if (Number.isFinite(declared) && declared > maxBytes) {
10324
- throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
10299
+ function resolveScaffoldTargets(requested, preset) {
10300
+ const ids = requested ?? [...PRESETS[preset].defaultTargets];
10301
+ const unknown = ids.filter((id) => !TARGETS[id]);
10302
+ if (unknown.length > 0) {
10303
+ throw new CreateProjectErrorImpl("invalid-targets", `Unknown publish target${unknown.length > 1 ? "s" : ""} "${unknown.join('", "')}". ` + `Known targets: ${TARGET_IDS.join(", ")}.`);
10325
10304
  }
10326
- if (Number.isFinite(declared) && declared > ctx.limits.totalNetworkBytes - ctx.totalNetworkBytes) {
10327
- throw new SecurityInstallError(totalLimitMessage);
10305
+ return [...new Set(ids)];
10306
+ }
10307
+ async function scaffoldProject(options) {
10308
+ const name = (options.name ?? "").trim();
10309
+ if (!name) {
10310
+ throw new CreateProjectErrorImpl("invalid-name", "A project name is required.");
10311
+ }
10312
+ const slug = slugifyProjectName(name);
10313
+ const folderName = (options.folderName ?? slug).trim();
10314
+ if (!folderName || folderName !== slugifyProjectName(folderName)) {
10315
+ throw new CreateProjectErrorImpl("invalid-name", `Could not derive a valid folder name from "${name}".`);
10316
+ }
10317
+ const parentDir = options.parentDir;
10318
+ if (!parentDir || !path15.isAbsolute(parentDir)) {
10319
+ throw new CreateProjectErrorImpl("parent-not-writable", "The save location must be an absolute path.");
10328
10320
  }
10329
- const reader = response.body.getReader();
10330
- let total = 0;
10331
10321
  try {
10332
- while (true) {
10333
- const { done, value } = await reader.read();
10334
- if (done)
10335
- break;
10336
- total += value.length;
10337
- if (total > maxBytes) {
10338
- await reader.cancel("size limit exceeded").catch(() => {});
10339
- throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
10322
+ await access(parentDir, FS.W_OK);
10323
+ } catch {
10324
+ throw new CreateProjectErrorImpl("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
10325
+ }
10326
+ const projectDir = path15.join(parentDir, folderName);
10327
+ let targetExists = true;
10328
+ try {
10329
+ await access(projectDir, FS.F_OK);
10330
+ } catch {
10331
+ targetExists = false;
10332
+ }
10333
+ if (targetExists) {
10334
+ throw new CreateProjectErrorImpl("target-exists", `A folder named "${folderName}" already exists here. Choose a different name or location.`);
10335
+ }
10336
+ const template = options.template ?? "book";
10337
+ const customTemplateDir = options.templateDir;
10338
+ const preset = customTemplateDir && options.preset === undefined ? undefined : requirePreset(options.preset, options.customPage);
10339
+ const targets = preset ? resolveScaffoldTargets(options.targets, preset) : undefined;
10340
+ try {
10341
+ await mkdir6(projectDir, { recursive: true });
10342
+ if (customTemplateDir) {
10343
+ const entries = await readdir5(customTemplateDir, { withFileTypes: true });
10344
+ for (const entry of entries) {
10345
+ if (entry.name === ".gutterpress-template.json")
10346
+ continue;
10347
+ await cp2(path15.join(customTemplateDir, entry.name), path15.join(projectDir, entry.name), { recursive: true });
10340
10348
  }
10341
- if (ctx.totalNetworkBytes + value.length > ctx.limits.totalNetworkBytes) {
10342
- await reader.cancel("total size limit exceeded").catch(() => {});
10343
- throw new SecurityInstallError(totalLimitMessage);
10349
+ await mkdir6(path15.join(projectDir, "assets"), { recursive: true });
10350
+ } else {
10351
+ const tplManifest = await getAssetPath(`templates/${template}/manifest.yaml`);
10352
+ const tplChapter = await getAssetPath(`templates/${template}/chapter-01.md`);
10353
+ await mkdir6(path15.join(projectDir, "assets"), { recursive: true });
10354
+ await copyFile(tplManifest, path15.join(projectDir, MANIFEST_FILENAMES[0]));
10355
+ await copyFile(tplChapter, path15.join(projectDir, "chapter-01.md"));
10356
+ await mkdir6(path15.join(projectDir, "styles"), { recursive: true });
10357
+ await writeFile6(path15.join(projectDir, "styles", "book.css"), starterOverridesCss(), "utf8");
10358
+ }
10359
+ } catch (e) {
10360
+ throw new CreateProjectErrorImpl("scaffold-io", `Could not create the project files: ${e instanceof Error ? e.message : String(e)}`);
10361
+ }
10362
+ try {
10363
+ await ensureGitignoreHasDist(projectDir);
10364
+ } catch (e) {
10365
+ throw new CreateProjectErrorImpl("scaffold-io", `Could not write .gitignore: ${e instanceof Error ? e.message : String(e)}`);
10366
+ }
10367
+ const manifestPath = MANIFEST_FILENAMES.map((name2) => path15.join(projectDir, name2)).find(existsSync11) ?? path15.join(projectDir, MANIFEST_FILENAMES[0]);
10368
+ const author = (options.author ?? "").trim() || DEFAULT_AUTHOR;
10369
+ const substitutions = {
10370
+ "{{TITLE}}": escapeYamlScalar(name),
10371
+ "{{AUTHOR}}": escapeYamlScalar(author)
10372
+ };
10373
+ let openFile = path15.join(projectDir, "chapter-01.md");
10374
+ try {
10375
+ await fillTemplateFile(manifestPath, substitutions);
10376
+ if (preset) {
10377
+ const { doc, file } = await loadManifestDoc(projectDir);
10378
+ doc.set("preset", preset);
10379
+ doc.set("targets", doc.createNode(targets ?? []));
10380
+ if (options.customPage) {
10381
+ const page = {
10382
+ width: options.customPage.width,
10383
+ height: options.customPage.height
10384
+ };
10385
+ if (options.customPage.tolerance !== undefined) {
10386
+ page.tolerance = options.customPage.tolerance;
10387
+ }
10388
+ doc.set("page", doc.createNode(page));
10344
10389
  }
10345
- ctx.totalNetworkBytes += value.length;
10346
- await onChunk(value);
10390
+ await writeManifestDoc(file, doc);
10347
10391
  }
10348
- } finally {
10349
- reader.releaseLock();
10392
+ const firstSource = await firstSourceFile(manifestPath);
10393
+ if (firstSource)
10394
+ openFile = path15.join(projectDir, firstSource);
10395
+ await fillTemplateFile(openFile, { "{{TITLE}}": name }).catch(() => {});
10396
+ } catch (e) {
10397
+ throw new CreateProjectErrorImpl("scaffold-io", `Could not finalise the project files: ${e instanceof Error ? e.message : String(e)}`);
10350
10398
  }
10351
- return total;
10352
- }
10353
- async function fetchPackument(ctx, name) {
10354
- let pending = ctx.packuments.get(name);
10355
- if (pending)
10356
- return pending;
10357
- pending = withFetchTimeout({
10358
- timeoutMs: METADATA_TIMEOUT_MS,
10359
- signal: ctx.signal,
10360
- timeoutMessage: `Looking up ${name} on npm timed out. Check your connection and try again.`,
10361
- offlineMessage: (cause) => `Looking up ${name} on npm failed (${cause instanceof Error ? cause.message : String(cause)}).`
10362
- }, async (signal) => {
10363
- const response = await ctx.fetch(`${npmRegistryUrl()}/${encodeURIComponent(name)}`, {
10364
- signal,
10365
- redirect: "error",
10366
- headers: { accept: "application/vnd.npm.install-v1+json" }
10367
- });
10368
- if (response.status === 404) {
10369
- throw new FriendlyHttpError(`npm package "${name}" was not found.`);
10370
- }
10371
- if (!response.ok) {
10372
- throw new FriendlyHttpError(`Looking up ${name} on npm failed (HTTP ${response.status}).`);
10373
- }
10374
- const chunks = [];
10375
- const size = await streamResponse(ctx, response, ctx.limits.metadataBytes, `Plugin dependency metadata exceeds the ${mb(ctx.limits.totalNetworkBytes)} total download limit.`, (chunk2) => {
10376
- chunks.push(chunk2);
10377
- });
10378
- const bytes = new Uint8Array(size);
10379
- let offset = 0;
10380
- for (const chunk2 of chunks) {
10381
- bytes.set(chunk2, offset);
10382
- offset += chunk2.length;
10383
- }
10384
- let metadata = null;
10399
+ const requested = options.versionHistory ?? "local-git";
10400
+ let versionHistory = "none";
10401
+ let versionHistoryError;
10402
+ if (requested === "local-git") {
10385
10403
  try {
10386
- metadata = object3(JSON.parse(new TextDecoder().decode(bytes)));
10387
- } catch {}
10388
- if (!metadata || metadata.name !== name) {
10389
- throw new FriendlyHttpError(`npm returned invalid metadata for "${name}".`);
10404
+ const { providerFor } = await import("./source-provider-51g6mt6y.js");
10405
+ const provider = providerFor({ type: "local-folder", path: projectDir });
10406
+ await provider.initVersionHistory({
10407
+ projectDir,
10408
+ authorName: options.author?.trim() || undefined,
10409
+ initialMessage: "Created project"
10410
+ });
10411
+ versionHistory = "local-git";
10412
+ } catch (e) {
10413
+ versionHistory = "none";
10414
+ const { findEnclosingRepoDir } = await import("./project-source-ekcyp63q.js");
10415
+ const enclosingRepoDir = await findEnclosingRepoDir(projectDir);
10416
+ versionHistoryError = enclosingRepoDir ? `This book was added to ${path15.basename(enclosingRepoDir)}'s shared version history; save a snapshot to record it.` : e instanceof Error ? e.message : String(e);
10390
10417
  }
10391
- return metadata;
10392
- }).catch((error2) => {
10393
- ctx.packuments.delete(name);
10394
- throw error2;
10395
- });
10396
- ctx.packuments.set(name, pending);
10397
- return pending;
10418
+ }
10419
+ const result = {
10420
+ projectDir,
10421
+ manifestPath,
10422
+ openFile,
10423
+ versionHistory
10424
+ };
10425
+ if (versionHistoryError !== undefined) {
10426
+ result.versionHistoryError = versionHistoryError;
10427
+ }
10428
+ return result;
10398
10429
  }
10399
- function parseIntegrity(dist) {
10400
- const integrity = typeof dist.integrity === "string" ? dist.integrity.trim() : "";
10401
- 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]));
10402
- for (const algorithm of ["sha512", "sha384", "sha256"]) {
10403
- const match = parsed.find((candidate) => candidate[1] === algorithm);
10404
- if (!match)
10405
- continue;
10406
- const digest = Buffer.from(match[2], "base64");
10407
- if (digest.length > 0) {
10408
- return { algorithm, digest, label: `${algorithm}-${match[2]}`, legacySha1: false };
10409
- }
10430
+ async function ensureGitignoreHasDist(projectDir) {
10431
+ const gitignorePath = path15.join(projectDir, ".gitignore");
10432
+ let existing = null;
10433
+ try {
10434
+ existing = await readFile25(gitignorePath, "utf8");
10435
+ } catch {}
10436
+ if (existing === null) {
10437
+ await writeFile6(gitignorePath, `dist/
10438
+ `, "utf8");
10439
+ return;
10410
10440
  }
10411
- const sha1 = parsed.find((candidate) => candidate[1] === "sha1");
10412
- if (sha1) {
10413
- return {
10414
- algorithm: "sha1",
10415
- digest: Buffer.from(sha1[2], "base64"),
10416
- label: `sha1-${sha1[2]}`,
10417
- legacySha1: true
10418
- };
10441
+ const alreadyIgnoresDist = existing.split(`
10442
+ `).some((line) => /^\/?dist\/?$/.test(line.trim()));
10443
+ if (alreadyIgnoresDist)
10444
+ return;
10445
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith(`
10446
+ `);
10447
+ await writeFile6(gitignorePath, existing + (needsLeadingNewline ? `
10448
+ ` : "") + `dist/
10449
+ `, "utf8");
10450
+ }
10451
+ async function firstSourceFile(manifestPath) {
10452
+ let text;
10453
+ try {
10454
+ text = await readFile25(manifestPath, "utf8");
10455
+ } catch {
10456
+ return;
10419
10457
  }
10420
- if (integrity) {
10421
- throw new SecurityInstallError("The npm registry supplied integrity data, but no supported SHA-256-or-stronger hash.");
10458
+ const filesIdx = text.search(/^\s*files:\s*$/m);
10459
+ if (filesIdx === -1)
10460
+ return;
10461
+ const rest = text.slice(filesIdx);
10462
+ const m = rest.match(/^\s*-\s*"?([^"\n]+?)"?\s*$/m);
10463
+ return m ? m[1].trim() : undefined;
10464
+ }
10465
+ async function fillTemplateFile(filePath, substitutions) {
10466
+ let text = await readFile25(filePath, "utf8");
10467
+ for (const [token, value] of Object.entries(substitutions)) {
10468
+ text = text.split(token).join(value);
10422
10469
  }
10423
- const shasum = typeof dist.shasum === "string" ? dist.shasum.trim().toLowerCase() : "";
10424
- if (/^[a-f0-9]{40}$/.test(shasum)) {
10425
- return {
10426
- algorithm: "sha1",
10427
- digest: Buffer.from(shasum, "hex"),
10428
- label: `sha1-hex-${shasum}`,
10429
- legacySha1: true
10430
- };
10470
+ await writeFile6(filePath, text, "utf8");
10471
+ }
10472
+
10473
+ // src/lib/extension-scaffold.ts
10474
+ import { access as access2, mkdir as mkdir7, readFile as readFile26, writeFile as writeFile7 } from "node:fs/promises";
10475
+ import { constants as FS2 } from "node:fs";
10476
+ import path16 from "node:path";
10477
+ var EXTENSION_KINDS = ["plugin", "theme"];
10478
+ var RESERVED_PREFIX = "gp-";
10479
+ var TEMPLATE_FILES = {
10480
+ plugin: [
10481
+ ["package.json", "package.json"],
10482
+ ["README.md", "README.md"],
10483
+ ["plugin.js.tpl", "plugin.js"],
10484
+ ["styles/plugin.css", "styles/plugin.css"],
10485
+ ["snippets/term-box.md", "snippets/term-box.md"],
10486
+ ["test/fixture.md", "test/fixture.md"],
10487
+ ["test/expected.html", "test/expected.html"],
10488
+ ["test/plugin.test.js.tpl", "test/plugin.test.js"]
10489
+ ],
10490
+ theme: [
10491
+ ["package.json", "package.json"],
10492
+ ["README.md", "README.md"],
10493
+ ["components.yaml", "components.yaml"],
10494
+ ["snippets/callout.md", "snippets/callout.md"],
10495
+ ["styles/tokens.css", "styles/tokens.css"],
10496
+ ["styles/base.css", "styles/base.css"],
10497
+ ["styles/components.css", "styles/components.css"],
10498
+ ["styles/page-templates.css", "styles/page-templates.css"],
10499
+ ["styles/page-rules.css", "styles/page-rules.css"],
10500
+ ["styles/book.css", "styles/book.css"]
10501
+ ]
10502
+ };
10503
+ var OPEN_FILE = {
10504
+ plugin: "plugin.js",
10505
+ theme: "styles/tokens.css"
10506
+ };
10507
+ var DEFAULT_DESCRIPTION = {
10508
+ plugin: "A Gutterpress plugin.",
10509
+ theme: "A Gutterpress theme."
10510
+ };
10511
+ var DEFAULT_AUTHOR2 = "Anonymous";
10512
+
10513
+ class ExtensionScaffoldError extends Error {
10514
+ code;
10515
+ constructor(code, message) {
10516
+ super(message);
10517
+ this.name = "CreateProjectError";
10518
+ this.code = code;
10431
10519
  }
10432
- throw new SecurityInstallError("The npm registry provided no usable integrity hash, so the package was not installed.");
10433
10520
  }
10434
- function unsupportedSelector(selector) {
10435
- return /^(?:file:|git\+|https?:|ssh:|github:|workspace:|link:|npm:)/i.test(selector);
10521
+ function resolveExtensionPrefix(slug, requested) {
10522
+ const raw = (requested ?? `${slug}-`).trim();
10523
+ const prefix = raw.endsWith("-") ? raw : `${raw}-`;
10524
+ if (!/^[a-z][a-z0-9-]*-$/.test(prefix)) {
10525
+ throw new ExtensionScaffoldError("invalid-name", `"${raw}" is not a usable prefix. Use lower-case letters, digits and hyphens, ` + `starting with a letter — for example "${slug}-" or "fn-".`);
10526
+ }
10527
+ if (prefix === RESERVED_PREFIX) {
10528
+ throw new ExtensionScaffoldError("invalid-name", `"${RESERVED_PREFIX}" is reserved for Gutterpress core's own classes and custom ` + `properties. Taking it would silently override them — choose your own prefix.`);
10529
+ }
10530
+ return prefix;
10436
10531
  }
10437
- async function selectPackage(ctx, name, selector) {
10438
- if (selector && unsupportedSelector(selector)) {
10439
- throw new PackageUnavailableError(`Dependency selector "${selector}" for ${name} is not a registry semver range or dist-tag.`);
10532
+ function identifierFor(slug) {
10533
+ const camel = slug.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase());
10534
+ return /^[0-9]/.test(camel) ? `_${camel}` : camel;
10535
+ }
10536
+ function escapeJsonScalar(value) {
10537
+ return JSON.stringify(value).slice(1, -1);
10538
+ }
10539
+ async function scaffoldExtension(options) {
10540
+ const kind = options.kind;
10541
+ if (kind !== "plugin" && kind !== "theme") {
10542
+ throw new ExtensionScaffoldError("invalid-name", `Unknown extension kind "${String(kind)}". Choose one of: ${EXTENSION_KINDS.join(", ")}.`);
10440
10543
  }
10441
- const metadata = await fetchPackument(ctx, name);
10442
- const versions = object3(metadata.versions);
10443
- const tags = object3(metadata["dist-tags"]);
10444
- if (!versions || !tags)
10445
- throw new PackageUnavailableError(`npm metadata for "${name}" has no versions.`);
10446
- let version;
10447
- if (!selector) {
10448
- if (typeof tags.latest === "string")
10449
- version = tags.latest;
10450
- } else if (object3(versions[selector])) {
10451
- version = selector;
10452
- } else if (typeof tags[selector] === "string") {
10453
- version = tags[selector];
10454
- } else if (validRange2(selector)) {
10455
- version = maxSatisfying(Object.keys(versions), selector) ?? undefined;
10544
+ const name = (options.name ?? "").trim();
10545
+ if (!name) {
10546
+ throw new ExtensionScaffoldError("invalid-name", "A name is required.");
10456
10547
  }
10457
- if (!version || !isExactNpmVersion(version)) {
10458
- throw new PackageUnavailableError(`No exact npm version of "${name}" satisfies "${selector ?? "latest"}".`);
10548
+ const slug = (options.folderName ?? slugify(name)).trim();
10549
+ if (!slug || slug !== slugify(slug)) {
10550
+ throw new ExtensionScaffoldError("invalid-name", `Could not derive a valid folder name from "${name}".`);
10459
10551
  }
10460
- const selected = object3(versions[version]);
10461
- if (!selected || selected.name !== name || selected.version !== version) {
10462
- throw new SecurityInstallError(`npm metadata identity failed for "${name}@${version}".`);
10552
+ const prefix = resolveExtensionPrefix(slug, options.prefix);
10553
+ const parentDir = options.parentDir;
10554
+ if (!parentDir || !path16.isAbsolute(parentDir)) {
10555
+ throw new ExtensionScaffoldError("parent-not-writable", "The save location must be an absolute path.");
10463
10556
  }
10464
- const dist = object3(selected.dist);
10465
- if (!dist || typeof dist.tarball !== "string") {
10466
- throw new PackageUnavailableError(`npm metadata for "${name}@${version}" has no tarball.`);
10557
+ try {
10558
+ await access2(parentDir, FS2.W_OK);
10559
+ } catch {
10560
+ throw new ExtensionScaffoldError("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
10467
10561
  }
10468
- let tarball;
10562
+ const extensionDir = path16.join(parentDir, slug);
10563
+ let targetExists = true;
10469
10564
  try {
10470
- tarball = new URL(dist.tarball);
10565
+ await access2(extensionDir, FS2.F_OK);
10471
10566
  } catch {
10472
- throw new SecurityInstallError(`npm returned an invalid tarball URL for "${name}@${version}".`);
10567
+ targetExists = false;
10473
10568
  }
10474
- if (tarball.origin !== new URL(npmRegistryUrl()).origin || tarball.username || tarball.password) {
10475
- throw new SecurityInstallError(`npm returned an unexpected tarball host for "${name}@${version}".`);
10569
+ if (targetExists) {
10570
+ throw new ExtensionScaffoldError("target-exists", `A folder named "${slug}" already exists here. Choose a different name or location.`);
10476
10571
  }
10477
- return { name, version, tarball: tarball.href, integrity: parseIntegrity(dist) };
10478
- }
10479
- async function downloadTarball(ctx, pkg, archivePath) {
10480
- const file = await open2(archivePath, "wx");
10481
- const hash = createHash7(pkg.integrity.algorithm);
10482
- try {
10483
- await withFetchTimeout({
10484
- timeoutMs: TARBALL_TIMEOUT_MS,
10485
- signal: ctx.signal,
10486
- timeoutMessage: `Downloading ${pkg.name}@${pkg.version} timed out.`,
10487
- offlineMessage: (cause) => `Downloading ${pkg.name}@${pkg.version} failed (${cause instanceof Error ? cause.message : String(cause)}).`
10488
- }, async (signal) => {
10489
- const response = await ctx.fetch(pkg.tarball, { signal, redirect: "error" });
10490
- if (!response.ok) {
10491
- throw new FriendlyHttpError(`Downloading ${pkg.name}@${pkg.version} failed (HTTP ${response.status}).`);
10492
- }
10493
- await streamResponse(ctx, response, ctx.limits.packageTarballBytes, `Plugin dependency downloads exceed the ${mb(ctx.limits.totalNetworkBytes)} total limit.`, async (chunk2) => {
10494
- hash.update(chunk2);
10495
- try {
10496
- await file.write(chunk2);
10497
- } catch (cause) {
10498
- throw new SecurityInstallError(`Could not store ${pkg.name}@${pkg.version} while downloading.`, { cause });
10499
- }
10500
- });
10501
- });
10502
- } finally {
10503
- await file.close();
10504
- }
10505
- const actual = hash.digest();
10506
- if (actual.length !== pkg.integrity.digest.length || !timingSafeEqual(actual, pkg.integrity.digest)) {
10507
- throw new SecurityInstallError(`The tarball for ${pkg.name}@${pkg.version} failed its registry integrity check.`);
10508
- }
10509
- }
10510
- function safeTarRelative(entryPath, packageName) {
10511
- if (!entryPath || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
10512
- throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath || "(empty)"}`);
10513
- }
10514
- const parts = entryPath.split("/");
10515
- if (parts.at(-1) === "")
10516
- parts.pop();
10517
- const legacyTypesRoot = packageName.startsWith("@types/") ? packageName.slice("@types/".length) : null;
10518
- if (parts.some((part) => !part || part === "." || part === "..") || parts[0] !== "package" && parts[0] !== legacyTypesRoot) {
10519
- throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath}`);
10520
- }
10521
- const relative2 = parts.slice(1).join("/");
10522
- if (!relative2)
10523
- return { relative: relative2, key: "" };
10524
- assertWindowsSafeRelativePath(relative2);
10525
- if (relative2.normalize("NFKC").split("/").some((part) => part.toLowerCase() === "node_modules")) {
10526
- throw new SecurityInstallError(`The npm tarball contains bundled node_modules (${entryPath}); bundled dependencies are not accepted.`);
10527
- }
10528
- return { relative: relative2, key: windowsPathKey(relative2) };
10529
- }
10530
- async function extractTarball(ctx, archivePath, packageDir, packageName) {
10531
- const { extract } = await import("tar");
10532
- let rejected = null;
10533
- const seen = new Set;
10534
- const metadataTypes = new Set([
10535
- "GlobalExtendedHeader",
10536
- "ExtendedHeader",
10537
- "NextFileHasLongLinkpath",
10538
- "NextFileHasLongPath",
10539
- "OldGnuLongPath",
10540
- "OldExtendedHeader"
10541
- ]);
10542
- await mkdir6(packageDir, { recursive: true });
10543
- const unpack = extract({
10544
- cwd: packageDir,
10545
- strip: 1,
10546
- strict: true,
10547
- preservePaths: false,
10548
- preserveOwner: false,
10549
- noMtime: true,
10550
- chmod: false,
10551
- maxDepth: ctx.limits.dependencyDepth * 4,
10552
- filter: (entryPath, rawEntry) => {
10553
- if (rejected)
10554
- return false;
10555
- const entry = rawEntry;
10556
- if (metadataTypes.has(entry.type))
10557
- return true;
10558
- if (entry.type === "Link" || entry.type === "SymbolicLink") {
10559
- rejected = new SecurityInstallError(`The npm tarball contains an unsafe link: ${entryPath}`);
10560
- return false;
10561
- }
10562
- if (entry.type !== "File" && entry.type !== "OldFile" && entry.type !== "Directory") {
10563
- rejected = new SecurityInstallError(`The npm tarball contains unsupported entry type ${entry.type}: ${entryPath}`);
10564
- return false;
10565
- }
10566
- try {
10567
- const { relative: relative2, key } = safeTarRelative(entryPath, packageName);
10568
- if (!relative2) {
10569
- if (entry.type === "Directory")
10570
- return true;
10571
- throw new SecurityInstallError(`The npm tarball uses package/ as a file.`);
10572
- }
10573
- if (seen.has(key)) {
10574
- throw new SecurityInstallError(`The npm tarball has a Windows-colliding path: ${entryPath}`);
10575
- }
10576
- seen.add(key);
10577
- ctx.totalFiles++;
10578
- if (ctx.totalFiles > ctx.limits.totalFiles) {
10579
- throw new SecurityInstallError(`Plugin graph exceeds ${ctx.limits.totalFiles} files.`);
10580
- }
10581
- if (entry.type !== "Directory") {
10582
- if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > ctx.limits.packageFileBytes) {
10583
- throw new SecurityInstallError(`Package file exceeds the ${mb(ctx.limits.packageFileBytes)} limit: ${entryPath}`);
10584
- }
10585
- }
10586
- return true;
10587
- } catch (error2) {
10588
- rejected = error2 instanceof Error ? error2 : new SecurityInstallError(String(error2));
10589
- return false;
10590
- }
10591
- }
10592
- });
10593
- const limiter = new Transform({
10594
- transform(chunk2, _encoding, callback) {
10595
- ctx.totalUnpackedBytes += chunk2.length;
10596
- if (ctx.totalUnpackedBytes > ctx.limits.totalUnpackedBytes) {
10597
- callback(new SecurityInstallError(`Plugin graph expands beyond the ${mb(ctx.limits.totalUnpackedBytes)} limit.`));
10598
- } else
10599
- callback(null, chunk2);
10600
- }
10601
- });
10602
- try {
10603
- await pipeline(createReadStream2(archivePath), createGunzip(), limiter, unpack);
10604
- } catch (error2) {
10605
- if (rejected)
10606
- throw rejected;
10607
- if (error2 instanceof SecurityInstallError)
10608
- throw error2;
10609
- throw new SecurityInstallError(`The npm tarball could not be safely extracted (${error2 instanceof Error ? error2.message : String(error2)}).`, { cause: error2 });
10610
- }
10611
- if (rejected)
10612
- throw rejected;
10613
- }
10614
- async function readInstalledManifest(packageDir, selected) {
10615
- const packageJsonPath = path15.join(packageDir, "package.json");
10616
- let manifest = null;
10617
- try {
10618
- const info2 = await lstat2(packageJsonPath);
10619
- if (!info2.isFile() || info2.isSymbolicLink())
10620
- throw new Error("not a regular file");
10621
- manifest = object3(JSON.parse(await readFile25(packageJsonPath, "utf8")));
10622
- } catch (error2) {
10623
- throw new SecurityInstallError(`Downloaded ${selected.name}@${selected.version} has no valid package.json (${error2 instanceof Error ? error2.message : String(error2)}).`);
10624
- }
10625
- if (manifest?.name !== selected.name || manifest.version !== selected.version) {
10626
- throw new SecurityInstallError(`Downloaded ${String(manifest?.name)}@${String(manifest?.version)} instead of ${selected.name}@${selected.version}.`);
10627
- }
10628
- return manifest;
10629
- }
10630
- async function optionalPackageEntry(packageDir, manifest, condition) {
10631
- try {
10632
- return await resolvePackageEntry(packageDir, manifest, condition);
10633
- } catch (error2) {
10634
- if (error2 instanceof Error && error2.message.includes("no contained JavaScript entry point")) {
10635
- return;
10636
- }
10637
- throw error2;
10638
- }
10639
- }
10640
- function platformAllowed(rule, current) {
10641
- let values;
10642
- if (typeof rule === "string") {
10643
- values = [rule];
10644
- } else if (Array.isArray(rule) && rule.every((item) => typeof item === "string")) {
10645
- values = rule;
10646
- } else {
10647
- return true;
10648
- }
10649
- if (values.length === 0)
10650
- return true;
10651
- if (values.length === 1 && values[0] === "any")
10652
- return true;
10653
- if (values.includes(`!${current}`))
10654
- return false;
10655
- const positives = values.filter((value) => !value.startsWith("!"));
10656
- return positives.length === 0 || positives.includes(current);
10657
- }
10658
- function relativePackagePath(parentPackagePath, name) {
10659
- const segments = name.split("/");
10660
- return parentPackagePath ? `${parentPackagePath}/node_modules/${segments.join("/")}` : `node_modules/${segments.join("/")}`;
10661
- }
10662
- async function installPackage(ctx, name, selector, parentPackagePath, ancestors, depth, optional) {
10663
- const recordStart = ctx.records.length;
10664
- const skippedStart = ctx.skipped.length;
10665
- const warningsBefore = new Set(ctx.warnings);
10666
- const discardOptionalSubtree = () => {
10667
- ctx.records.splice(recordStart);
10668
- ctx.skipped.splice(skippedStart);
10669
- ctx.warnings = warningsBefore;
10670
- };
10671
- if (depth > ctx.limits.dependencyDepth) {
10672
- throw new SecurityInstallError(`Dependency graph exceeds depth ${ctx.limits.dependencyDepth}.`);
10673
- }
10674
- let selected;
10675
- try {
10676
- selected = await selectPackage(ctx, name, selector);
10677
- } catch (error2) {
10678
- if (optional && isOptionalAvailabilityError(error2)) {
10679
- discardOptionalSubtree();
10680
- return { skipped: error2 instanceof Error ? error2.message : String(error2) };
10681
- }
10682
- throw error2;
10683
- }
10684
- const ancestor = ancestors.find((candidate) => candidate.name === name && (candidate.version === selected.version || (selector ? satisfies2(candidate.version, selector) : false)));
10685
- if (ancestor)
10686
- return { path: ancestor.path };
10687
- if (ctx.records.length >= ctx.limits.totalPackages) {
10688
- throw new SecurityInstallError(`Dependency graph exceeds ${ctx.limits.totalPackages} packages.`);
10689
- }
10690
- const relativePath = relativePackagePath(parentPackagePath, name);
10691
- const packageDir = path15.join(ctx.stageRoot, ...relativePath.split("/"));
10692
- const archivePath = path15.join(ctx.downloadsDir, `${ctx.archiveCounter++}.tgz`);
10693
- try {
10694
- await mkdir6(path15.dirname(packageDir), { recursive: true });
10695
- await downloadTarball(ctx, selected, archivePath);
10696
- await extractTarball(ctx, archivePath, packageDir, selected.name);
10697
- const manifest = await readInstalledManifest(packageDir, selected);
10698
- if (!platformAllowed(manifest.os, process.platform) || !platformAllowed(manifest.cpu, process.arch)) {
10699
- const reason = `${name}@${selected.version} does not support ${process.platform}/${process.arch}`;
10700
- if (optional) {
10701
- await rm3(packageDir, { recursive: true, force: true });
10702
- return { skipped: reason };
10703
- }
10704
- throw new PackageUnavailableError(reason);
10705
- }
10706
- const importEntry = await optionalPackageEntry(packageDir, manifest, "import");
10707
- const requireEntry = await optionalPackageEntry(packageDir, manifest, "require");
10708
- if (parentPackagePath === null && !importEntry && !requireEntry) {
10709
- throw new SecurityInstallError(`${name}@${selected.version} has no JavaScript plugin entry.`);
10710
- }
10711
- const record = {
10712
- path: relativePath,
10713
- name,
10714
- version: selected.version,
10715
- tarball: selected.tarball,
10716
- integrity: selected.integrity.label,
10717
- legacySha1: selected.integrity.legacySha1,
10718
- ...importEntry ? { entry: `${relativePath}/${importEntry}` } : {},
10719
- ...requireEntry ? { requireEntry: `${relativePath}/${requireEntry}` } : {},
10720
- dependencies: {},
10721
- packageDir,
10722
- manifest
10723
- };
10724
- ctx.records.push(record);
10725
- if (selected.integrity.legacySha1) {
10726
- ctx.warnings.add(`${name}@${selected.version} is an old npm release with only SHA-1 registry integrity. It was accepted for compatibility and recorded as legacy SHA-1 in the vendor receipt.`);
10727
- }
10728
- const dependencies = stringMap2(manifest.dependencies);
10729
- const optionalDependencies = stringMap2(manifest.optionalDependencies);
10730
- for (const optionalName of Object.keys(optionalDependencies))
10731
- delete dependencies[optionalName];
10732
- const peerDependencies = stringMap2(manifest.peerDependencies);
10733
- const peerMeta = object3(manifest.peerDependenciesMeta) ?? {};
10734
- for (const existing of [...Object.keys(dependencies), ...Object.keys(optionalDependencies)]) {
10735
- delete peerDependencies[existing];
10736
- }
10737
- const nextAncestors = [...ancestors, record];
10738
- const requiredEntries = [
10739
- ...Object.entries(dependencies).map(([dependency, range]) => ({ dependency, range, kind: "required" })),
10740
- ...Object.entries(peerDependencies).filter(([dependency]) => object3(peerMeta[dependency])?.optional !== true).map(([dependency, range]) => ({ dependency, range, kind: "peer" }))
10741
- ].sort((a, b) => a.dependency.localeCompare(b.dependency));
10742
- for (const { dependency, range } of requiredEntries) {
10743
- const child = await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, false);
10744
- if (!child.path)
10745
- throw new Error(`Required dependency ${dependency} was unexpectedly skipped.`);
10746
- record.dependencies[dependency] = child.path;
10747
- }
10748
- const optionalEntries = Object.entries(optionalDependencies).sort(([a], [b]) => a.localeCompare(b));
10749
- for (const [dependency, range] of optionalEntries) {
10750
- const child = await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, true);
10751
- if (child.path)
10752
- record.dependencies[dependency] = child.path;
10753
- else if (child.skipped) {
10754
- ctx.skipped.push({ from: relativePath, name: dependency, selector: range, kind: "optional", reason: child.skipped });
10755
- }
10756
- }
10757
- for (const [dependency, range] of Object.entries(peerDependencies)) {
10758
- if (object3(peerMeta[dependency])?.optional === true) {
10759
- ctx.skipped.push({
10760
- from: relativePath,
10761
- name: dependency,
10762
- selector: range,
10763
- kind: "optional-peer",
10764
- reason: "Optional peer dependencies are not auto-installed."
10765
- });
10766
- }
10767
- }
10768
- return { path: relativePath };
10769
- } catch (error2) {
10770
- await rm3(packageDir, { recursive: true, force: true });
10771
- if (optional && isOptionalAvailabilityError(error2)) {
10772
- discardOptionalSubtree();
10773
- return { skipped: error2 instanceof Error ? error2.message : String(error2) };
10774
- }
10775
- throw error2;
10776
- } finally {
10777
- await rm3(archivePath, { force: true });
10778
- }
10779
- }
10780
- async function ensureDirectory(pathname) {
10572
+ const author = (options.author ?? "").trim() || DEFAULT_AUTHOR2;
10573
+ const description = (options.description ?? "").trim() || DEFAULT_DESCRIPTION[kind];
10574
+ const plain = {
10575
+ "{{NAME}}": name,
10576
+ "{{SLUG}}": slug,
10577
+ "{{PREFIX}}": prefix,
10578
+ "{{AUTHOR}}": author,
10579
+ "{{DESCRIPTION}}": description,
10580
+ "{{IDENT}}": identifierFor(slug)
10581
+ };
10582
+ const jsonSafe = Object.fromEntries(Object.entries(plain).map(([token, value]) => [token, escapeJsonScalar(value)]));
10583
+ const entries = TEMPLATE_FILES[kind];
10584
+ const written = [];
10781
10585
  try {
10782
- const info2 = await lstat2(pathname);
10783
- if (info2.isSymbolicLink() || !info2.isDirectory()) {
10784
- throw new Error(`Plugin install path is not a normal directory: ${pathname}`);
10586
+ await mkdir7(extensionDir, { recursive: true });
10587
+ for (const [assetName, relDest] of entries) {
10588
+ const src = await getAssetPath(`extension-templates/${kind}/${assetName}`);
10589
+ const dest = path16.join(extensionDir, relDest);
10590
+ await mkdir7(path16.dirname(dest), { recursive: true });
10591
+ const text = await readFile26(src, "utf8");
10592
+ const table = relDest.endsWith(".json") ? jsonSafe : plain;
10593
+ await writeFile7(dest, substitute(text, table), "utf8");
10594
+ written.push(relDest);
10785
10595
  }
10786
- } catch (error2) {
10787
- if (error2.code !== "ENOENT")
10788
- throw error2;
10789
- await mkdir6(pathname);
10596
+ } catch (e) {
10597
+ throw new ExtensionScaffoldError("scaffold-io", `Could not create the ${kind} files: ${e instanceof Error ? e.message : String(e)}`);
10790
10598
  }
10599
+ return {
10600
+ extensionDir,
10601
+ manifestPath: path16.join(extensionDir, "package.json"),
10602
+ kind,
10603
+ slug,
10604
+ prefix,
10605
+ openFile: path16.join(extensionDir, OPEN_FILE[kind]),
10606
+ files: written
10607
+ };
10791
10608
  }
10792
- async function prepareVendorParent(projectDir, name) {
10793
- const plugins = path15.join(projectDir, PLUGINS_DIR);
10794
- const npm = path15.join(plugins, VENDORED_NPM_DIR);
10795
- const packageParent = path15.dirname(vendoredNpmPluginRoot(projectDir, name, "0.0.0"));
10796
- await mkdir6(projectDir, { recursive: true });
10797
- await ensureDirectory(plugins);
10798
- await ensureDirectory(npm);
10799
- await ensureDirectory(packageParent);
10800
- const [realNpm, realPackageParent] = await Promise.all([
10801
- realpath2(npm),
10802
- realpath2(packageParent)
10803
- ]);
10804
- if (!isContained2(projectDir, realNpm) || !isContained2(projectDir, realPackageParent)) {
10805
- throw new Error("Plugin install path resolves outside the project.");
10609
+ function substitute(text, table) {
10610
+ let out = text;
10611
+ for (const [token, value] of Object.entries(table)) {
10612
+ out = out.split(token).join(value);
10806
10613
  }
10807
- return realNpm;
10614
+ return out;
10808
10615
  }
10809
- function publicReceipt(record) {
10616
+
10617
+ // src/lib/project-templates.ts
10618
+ import { isMap, isScalar, isSeq as isSeq2, parseDocument as parseDocument2 } from "yaml";
10619
+ var BUILT_IN_TEMPLATE_IDS = [
10620
+ "book",
10621
+ "zine",
10622
+ "technical"
10623
+ ];
10624
+ var SKIP_ENTRIES = new Set([
10625
+ ".git",
10626
+ "node_modules",
10627
+ "out",
10628
+ "dist",
10629
+ "build",
10630
+ ".gutterpress",
10631
+ ".DS_Store"
10632
+ ]);
10633
+
10634
+ // src/lib/extension-manager.ts
10635
+ import { cp as cp3, mkdir as mkdir9, readFile as readFile28, rm as rm4, stat as stat6 } from "node:fs/promises";
10636
+ import { existsSync as existsSync12 } from "node:fs";
10637
+ import path18 from "node:path";
10638
+ import { isSeq as isSeq3, isMap as isMap2, isScalar as isScalar2, YAMLMap, Scalar } from "yaml";
10639
+
10640
+ // src/lib/npm-plugin-installer.ts
10641
+ import { createHash as createHash7, randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
10642
+ import { createReadStream as createReadStream2 } from "node:fs";
10643
+ import {
10644
+ lstat as lstat2,
10645
+ mkdir as mkdir8,
10646
+ mkdtemp as mkdtemp2,
10647
+ open as open2,
10648
+ readFile as readFile27,
10649
+ realpath as realpath2,
10650
+ rename as rename2,
10651
+ rm as rm3
10652
+ } from "node:fs/promises";
10653
+ import path17 from "node:path";
10654
+ import { Transform } from "node:stream";
10655
+ import { pipeline } from "node:stream/promises";
10656
+ import { createGunzip } from "node:zlib";
10657
+ import { maxSatisfying, satisfies as satisfies2, validRange as validRange2 } from "semver";
10658
+
10659
+ // src/lib/fetch-timeout.ts
10660
+ class FetchPassthroughError extends Error {
10661
+ }
10662
+
10663
+ class FriendlyHttpError extends FetchPassthroughError {
10664
+ }
10665
+
10666
+ class FetchUnavailableError extends Error {
10667
+ }
10668
+ function composeSignal(signal, timeoutMs) {
10669
+ const timeout = AbortSignal.timeout(timeoutMs);
10810
10670
  return {
10811
- path: record.path,
10812
- name: record.name,
10813
- version: record.version,
10814
- tarball: record.tarball,
10815
- integrity: record.integrity,
10816
- legacySha1: record.legacySha1,
10817
- ...record.entry ? { entry: record.entry } : {},
10818
- ...record.requireEntry ? { requireEntry: record.requireEntry } : {},
10819
- dependencies: record.dependencies
10671
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
10672
+ timeout
10820
10673
  };
10821
10674
  }
10822
- async function installNpmPlugin(projectDirInput, packageSpec, options = {}) {
10823
- const requestedProjectDir = path15.resolve(projectDirInput);
10824
- await mkdir6(requestedProjectDir, { recursive: true });
10825
- const projectDir = await realpath2(requestedProjectDir);
10826
- const requested = parseNpmPluginSpec(packageSpec);
10827
- const npmRoot = await prepareVendorParent(projectDir, requested.name);
10828
- const container = await mkdtemp2(path15.join(npmRoot, ".install-"));
10829
- const stageRoot = path15.join(container, "root");
10830
- const downloadsDir = path15.join(container, "downloads");
10831
- await Promise.all([mkdir6(stageRoot), mkdir6(downloadsDir)]);
10832
- const ctx = {
10833
- fetch: options.fetch ?? globalThis.fetch,
10834
- signal: options.signal,
10835
- limits: mergeLimits(options.limits),
10836
- stageRoot,
10837
- downloadsDir,
10838
- packuments: new Map,
10839
- records: [],
10840
- skipped: [],
10841
- warnings: new Set,
10842
- totalNetworkBytes: 0,
10843
- totalUnpackedBytes: 0,
10844
- totalFiles: 0,
10845
- archiveCounter: 0
10846
- };
10847
- let finalRoot = "";
10848
- let backupRoot = null;
10675
+ async function withFetchTimeout(options, run) {
10676
+ const composed = composeSignal(options.signal, options.timeoutMs);
10849
10677
  try {
10850
- const rootResult = await installPackage(ctx, requested.name, requested.selector, null, [], 0, false);
10851
- if (!rootResult.path)
10852
- throw new Error("Root plugin package was unexpectedly skipped.");
10853
- const rootRecord = ctx.records.find((record) => record.path === rootResult.path);
10854
- if (!rootRecord)
10855
- throw new Error("Root plugin package is missing from the dependency graph.");
10856
- const importEntry = rootRecord.entry ?? rootRecord.requireEntry;
10857
- if (!importEntry)
10858
- throw new Error("Root plugin package has no recorded JavaScript entry.");
10859
- const importFormat = packageEntryFormat(rootRecord.manifest, importEntry);
10860
- const entry = importFormat === "commonjs" ? rootRecord.requireEntry ?? importEntry : importEntry;
10861
- const format = packageEntryFormat(rootRecord.manifest, entry);
10862
- const tree = await computeVendorTreeDigest(stageRoot);
10863
- const receipt = {
10864
- schemaVersion: VENDOR_RECEIPT_VERSION,
10865
- root: {
10866
- name: rootRecord.name,
10867
- version: rootRecord.version,
10868
- packagePath: rootRecord.path,
10869
- entry,
10870
- format
10871
- },
10872
- packages: ctx.records.map(publicReceipt).sort((a, b) => a.path.localeCompare(b.path)),
10873
- skipped: ctx.skipped,
10874
- tree: { algorithm: "sha256", ...tree }
10875
- };
10876
- const receiptFile = await open2(path15.join(stageRoot, VENDOR_RECEIPT_FILE), "wx");
10877
- try {
10878
- await receiptFile.writeFile(`${JSON.stringify(receipt, null, 2)}
10879
- `, "utf8");
10880
- await receiptFile.sync();
10881
- } finally {
10882
- await receiptFile.close();
10678
+ return await run(composed.signal);
10679
+ } catch (cause) {
10680
+ if (cause instanceof FetchPassthroughError)
10681
+ throw cause;
10682
+ if (options.signal?.aborted)
10683
+ throw cause;
10684
+ const offline = typeof options.offlineMessage === "function" ? options.offlineMessage(cause) : options.offlineMessage;
10685
+ const timedOut = composed.timeout.aborted || cause instanceof Error && cause.name === "TimeoutError";
10686
+ if (cause instanceof Error && cause.name === "AbortError" && !timedOut)
10687
+ throw cause;
10688
+ throw new FetchUnavailableError(timedOut ? options.timeoutMessage ?? offline : offline, { cause });
10689
+ }
10690
+ }
10691
+
10692
+ // src/lib/npm-plugin-installer.ts
10693
+ var DEFAULT_LIMITS = {
10694
+ metadataBytes: 15 * 1024 * 1024,
10695
+ packageTarballBytes: 50 * 1024 * 1024,
10696
+ totalNetworkBytes: 250 * 1024 * 1024,
10697
+ packageFileBytes: 50 * 1024 * 1024,
10698
+ totalUnpackedBytes: 512 * 1024 * 1024,
10699
+ totalFiles: 50000,
10700
+ totalPackages: 512,
10701
+ dependencyDepth: 64
10702
+ };
10703
+ var METADATA_TIMEOUT_MS = 30000;
10704
+ var TARBALL_TIMEOUT_MS = 120000;
10705
+
10706
+ class SecurityInstallError extends FetchPassthroughError {
10707
+ }
10708
+
10709
+ class PackageUnavailableError extends Error {
10710
+ }
10711
+ function isOptionalAvailabilityError(error2) {
10712
+ return error2 instanceof PackageUnavailableError || error2 instanceof FriendlyHttpError || error2 instanceof FetchUnavailableError;
10713
+ }
10714
+ function object3(value) {
10715
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
10716
+ }
10717
+ function stringMap2(value) {
10718
+ const map = object3(value);
10719
+ if (!map)
10720
+ return {};
10721
+ const result = {};
10722
+ for (const [name, selector] of Object.entries(map)) {
10723
+ if (typeof selector === "string" && selector.trim())
10724
+ result[name] = selector.trim();
10725
+ }
10726
+ return result;
10727
+ }
10728
+ function mb(bytes) {
10729
+ return `${Math.ceil(bytes / 1024 / 1024)}MB`;
10730
+ }
10731
+ function isContained2(root, candidate) {
10732
+ const relative2 = path17.relative(root, candidate);
10733
+ return relative2 === "" || !relative2.startsWith("..") && !path17.isAbsolute(relative2);
10734
+ }
10735
+ function mergeLimits(overrides) {
10736
+ const limits = { ...DEFAULT_LIMITS, ...overrides };
10737
+ for (const [name, value] of Object.entries(limits)) {
10738
+ if (!Number.isSafeInteger(value) || value <= 0) {
10739
+ throw new Error(`Invalid npm plugin install limit ${name}: ${value}`);
10740
+ }
10741
+ }
10742
+ return limits;
10743
+ }
10744
+ async function streamResponse(ctx, response, maxBytes, totalLimitMessage, onChunk) {
10745
+ if (!response.body)
10746
+ throw new Error("Registry response had no body.");
10747
+ const declared = Number(response.headers.get("content-length"));
10748
+ if (Number.isFinite(declared) && declared > maxBytes) {
10749
+ throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
10750
+ }
10751
+ if (Number.isFinite(declared) && declared > ctx.limits.totalNetworkBytes - ctx.totalNetworkBytes) {
10752
+ throw new SecurityInstallError(totalLimitMessage);
10753
+ }
10754
+ const reader = response.body.getReader();
10755
+ let total = 0;
10756
+ try {
10757
+ while (true) {
10758
+ const { done, value } = await reader.read();
10759
+ if (done)
10760
+ break;
10761
+ total += value.length;
10762
+ if (total > maxBytes) {
10763
+ await reader.cancel("size limit exceeded").catch(() => {});
10764
+ throw new SecurityInstallError(`Registry response is too large (max ${mb(maxBytes)}).`);
10765
+ }
10766
+ if (ctx.totalNetworkBytes + value.length > ctx.limits.totalNetworkBytes) {
10767
+ await reader.cancel("total size limit exceeded").catch(() => {});
10768
+ throw new SecurityInstallError(totalLimitMessage);
10769
+ }
10770
+ ctx.totalNetworkBytes += value.length;
10771
+ await onChunk(value);
10883
10772
  }
10884
- finalRoot = vendoredNpmPluginRoot(projectDir, rootRecord.name, rootRecord.version);
10885
- const realFinalParent = await realpath2(path15.dirname(finalRoot));
10886
- if (!isContained2(projectDir, realFinalParent)) {
10887
- throw new Error("Plugin install destination resolves outside the project.");
10773
+ } finally {
10774
+ reader.releaseLock();
10775
+ }
10776
+ return total;
10777
+ }
10778
+ async function fetchPackument(ctx, name) {
10779
+ let pending = ctx.packuments.get(name);
10780
+ if (pending)
10781
+ return pending;
10782
+ pending = withFetchTimeout({
10783
+ timeoutMs: METADATA_TIMEOUT_MS,
10784
+ signal: ctx.signal,
10785
+ timeoutMessage: `Looking up ${name} on npm timed out. Check your connection and try again.`,
10786
+ offlineMessage: (cause) => `Looking up ${name} on npm failed (${cause instanceof Error ? cause.message : String(cause)}).`
10787
+ }, async (signal) => {
10788
+ const response = await ctx.fetch(`${npmRegistryUrl()}/${encodeURIComponent(name)}`, {
10789
+ signal,
10790
+ redirect: "error",
10791
+ headers: { accept: "application/vnd.npm.install-v1+json" }
10792
+ });
10793
+ if (response.status === 404) {
10794
+ throw new FriendlyHttpError(`npm package "${name}" was not found.`);
10888
10795
  }
10889
- try {
10890
- await lstat2(finalRoot);
10891
- backupRoot = `${finalRoot}.backup-${randomUUID3()}`;
10892
- await rename2(finalRoot, backupRoot);
10893
- } catch (error2) {
10894
- if (error2.code !== "ENOENT")
10895
- throw error2;
10796
+ if (!response.ok) {
10797
+ throw new FriendlyHttpError(`Looking up ${name} on npm failed (HTTP ${response.status}).`);
10798
+ }
10799
+ const chunks = [];
10800
+ const size = await streamResponse(ctx, response, ctx.limits.metadataBytes, `Plugin dependency metadata exceeds the ${mb(ctx.limits.totalNetworkBytes)} total download limit.`, (chunk2) => {
10801
+ chunks.push(chunk2);
10802
+ });
10803
+ const bytes = new Uint8Array(size);
10804
+ let offset = 0;
10805
+ for (const chunk2 of chunks) {
10806
+ bytes.set(chunk2, offset);
10807
+ offset += chunk2.length;
10896
10808
  }
10809
+ let metadata = null;
10897
10810
  try {
10898
- await rename2(stageRoot, finalRoot);
10899
- } catch (error2) {
10900
- if (backupRoot) {
10901
- try {
10902
- await rename2(backupRoot, finalRoot);
10903
- } catch (restoreError) {
10904
- throw new Error(`Could not publish the new plugin tree or restore the previous tree: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, { cause: error2 });
10905
- }
10906
- }
10907
- throw error2;
10811
+ metadata = object3(JSON.parse(new TextDecoder().decode(bytes)));
10812
+ } catch {}
10813
+ if (!metadata || metadata.name !== name) {
10814
+ throw new FriendlyHttpError(`npm returned invalid metadata for "${name}".`);
10815
+ }
10816
+ return metadata;
10817
+ }).catch((error2) => {
10818
+ ctx.packuments.delete(name);
10819
+ throw error2;
10820
+ });
10821
+ ctx.packuments.set(name, pending);
10822
+ return pending;
10823
+ }
10824
+ function parseIntegrity(dist) {
10825
+ const integrity = typeof dist.integrity === "string" ? dist.integrity.trim() : "";
10826
+ 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]));
10827
+ for (const algorithm of ["sha512", "sha384", "sha256"]) {
10828
+ const match = parsed.find((candidate) => candidate[1] === algorithm);
10829
+ if (!match)
10830
+ continue;
10831
+ const digest = Buffer.from(match[2], "base64");
10832
+ if (digest.length > 0) {
10833
+ return { algorithm, digest, label: `${algorithm}-${match[2]}`, legacySha1: false };
10908
10834
  }
10835
+ }
10836
+ const sha1 = parsed.find((candidate) => candidate[1] === "sha1");
10837
+ if (sha1) {
10909
10838
  return {
10910
- name: rootRecord.name,
10911
- version: rootRecord.version,
10912
- installRoot: finalRoot,
10913
- backupRoot,
10914
- warnings: [...ctx.warnings]
10839
+ algorithm: "sha1",
10840
+ digest: Buffer.from(sha1[2], "base64"),
10841
+ label: `sha1-${sha1[2]}`,
10842
+ legacySha1: true
10915
10843
  };
10916
- } finally {
10917
- await rm3(container, { recursive: true, force: true }).catch(() => {});
10918
10844
  }
10919
- }
10920
- async function finalizeNpmPluginInstall(installed) {
10921
- if (installed.backupRoot) {
10922
- await rm3(installed.backupRoot, { recursive: true, force: true });
10845
+ if (integrity) {
10846
+ throw new SecurityInstallError("The npm registry supplied integrity data, but no supported SHA-256-or-stronger hash.");
10847
+ }
10848
+ const shasum = typeof dist.shasum === "string" ? dist.shasum.trim().toLowerCase() : "";
10849
+ if (/^[a-f0-9]{40}$/.test(shasum)) {
10850
+ return {
10851
+ algorithm: "sha1",
10852
+ digest: Buffer.from(shasum, "hex"),
10853
+ label: `sha1-hex-${shasum}`,
10854
+ legacySha1: true
10855
+ };
10923
10856
  }
10857
+ throw new SecurityInstallError("The npm registry provided no usable integrity hash, so the package was not installed.");
10924
10858
  }
10925
- async function rollbackNpmPluginInstall(installed) {
10926
- await rm3(installed.installRoot, { recursive: true, force: true });
10927
- if (installed.backupRoot)
10928
- await rename2(installed.backupRoot, installed.installRoot);
10859
+ function unsupportedSelector(selector) {
10860
+ return /^(?:file:|git\+|https?:|ssh:|github:|workspace:|link:|npm:)/i.test(selector);
10929
10861
  }
10930
-
10931
- // src/lib/extension-manager.ts
10932
- var EXTENSIONS_DIR = "extensions";
10933
- var RECOMMENDED_EXTENSIONS = [
10934
- {
10935
- use: "markdown-it-mark",
10936
- label: "Highlight",
10937
- description: "Highlighted text with `==marked==` -> `<mark>`."
10938
- },
10939
- {
10940
- use: "markdown-it-sub",
10941
- label: "Subscript",
10942
- description: "Subscript text with `H~2~O`."
10943
- },
10944
- {
10945
- use: "markdown-it-sup",
10946
- label: "Superscript",
10947
- description: "Superscript text with `29^th^`."
10948
- },
10949
- {
10950
- use: "markdown-it-abbr",
10951
- label: "Abbreviations",
10952
- description: "Define `*[HTML]: Hyper Text...` and get `<abbr>` tooltips."
10953
- },
10954
- {
10955
- use: "gutterpress-gfm-alerts",
10956
- label: "Callouts",
10957
- description: "GitHub-style `> [!NOTE]` alert boxes (Note/Tip/Important/Warning/Caution)."
10862
+ async function selectPackage(ctx, name, selector) {
10863
+ if (selector && unsupportedSelector(selector)) {
10864
+ throw new PackageUnavailableError(`Dependency selector "${selector}" for ${name} is not a registry semver range or dist-tag.`);
10958
10865
  }
10959
- ];
10960
- var BUILT_IN_STYLE_SET_IDS = ["clean-book", "zine", "technical-doc"];
10961
- var mutationQueues = new Map;
10962
- function withMutationLock(projectDir, mutation) {
10963
- const resolved = path16.resolve(projectDir);
10964
- const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
10965
- const previous = mutationQueues.get(key) ?? Promise.resolve();
10966
- const run = previous.then(mutation, mutation);
10967
- const tail = run.then(() => {
10968
- return;
10969
- }, () => {
10970
- return;
10971
- });
10972
- mutationQueues.set(key, tail);
10973
- tail.then(() => {
10974
- if (mutationQueues.get(key) === tail)
10975
- mutationQueues.delete(key);
10976
- });
10977
- return run;
10978
- }
10979
- function entryUse(item) {
10980
- if (isScalar(item))
10981
- return typeof item.value === "string" ? item.value.trim() : null;
10982
- if (isMap(item)) {
10983
- const u = item.get("use");
10984
- return typeof u === "string" ? u.trim() : null;
10866
+ const metadata = await fetchPackument(ctx, name);
10867
+ const versions = object3(metadata.versions);
10868
+ const tags = object3(metadata["dist-tags"]);
10869
+ if (!versions || !tags)
10870
+ throw new PackageUnavailableError(`npm metadata for "${name}" has no versions.`);
10871
+ let version;
10872
+ if (!selector) {
10873
+ if (typeof tags.latest === "string")
10874
+ version = tags.latest;
10875
+ } else if (object3(versions[selector])) {
10876
+ version = selector;
10877
+ } else if (typeof tags[selector] === "string") {
10878
+ version = tags[selector];
10879
+ } else if (validRange2(selector)) {
10880
+ version = maxSatisfying(Object.keys(versions), selector) ?? undefined;
10985
10881
  }
10986
- return null;
10987
- }
10988
- function rawEntry(item) {
10989
- const use = entryUse(item);
10990
- if (!use)
10991
- return null;
10992
- if (!isMap(item))
10993
- return { use, enabled: true };
10994
- const x = item.get("export");
10995
- return {
10996
- use,
10997
- ...typeof x === "string" && x.trim() ? { export: x.trim() } : {},
10998
- enabled: item.get("enabled") !== false
10999
- };
11000
- }
11001
- function sameExtension(a, b) {
11002
- if (a.trim() === b.trim())
11003
- return true;
11004
- let pa;
11005
- let pb;
10882
+ if (!version || !isExactNpmVersion(version)) {
10883
+ throw new PackageUnavailableError(`No exact npm version of "${name}" satisfies "${selector ?? "latest"}".`);
10884
+ }
10885
+ const selected = object3(versions[version]);
10886
+ if (!selected || selected.name !== name || selected.version !== version) {
10887
+ throw new SecurityInstallError(`npm metadata identity failed for "${name}@${version}".`);
10888
+ }
10889
+ const dist = object3(selected.dist);
10890
+ if (!dist || typeof dist.tarball !== "string") {
10891
+ throw new PackageUnavailableError(`npm metadata for "${name}@${version}" has no tarball.`);
10892
+ }
10893
+ let tarball;
11006
10894
  try {
11007
- pa = parseExtensionSpecifier(a);
11008
- pb = parseExtensionSpecifier(b);
10895
+ tarball = new URL(dist.tarball);
11009
10896
  } catch {
11010
- return false;
10897
+ throw new SecurityInstallError(`npm returned an invalid tarball URL for "${name}@${version}".`);
11011
10898
  }
11012
- return pa.kind === "npm" && pb.kind === "npm" && pa.name === pb.name;
11013
- }
11014
- function indexOfUse(seq, use) {
11015
- const items = seq.items;
11016
- const exact = items.findIndex((item) => entryUse(item) === use.trim());
11017
- if (exact >= 0)
11018
- return exact;
11019
- return items.findIndex((item) => {
11020
- const u = entryUse(item);
11021
- return !!u && sameExtension(u, use);
11022
- });
11023
- }
11024
- function manifestPathFor(projectDir, abs) {
11025
- const rel = path16.relative(path16.resolve(projectDir), abs).split(path16.sep).join("/");
11026
- if (rel === "") {
11027
- throw new Error("An extension cannot be the project folder itself.");
10899
+ if (tarball.origin !== new URL(npmRegistryUrl()).origin || tarball.username || tarball.password) {
10900
+ throw new SecurityInstallError(`npm returned an unexpected tarball host for "${name}@${version}".`);
11028
10901
  }
11029
- if (isPathSpecifier(rel))
11030
- return rel;
11031
- return `./${rel}`;
11032
- }
11033
- var NO_CARRIES = {
11034
- markdown: false,
11035
- styles: false,
11036
- snippets: false,
11037
- components: false
11038
- };
11039
- async function describeFolder(dir, base, fallbackLabel, labelFrom = prettify) {
11040
- const meta = await readExtensionMeta(dir);
11041
- const styles = extensionStyleList(meta);
11042
- return {
11043
- ...base,
11044
- label: meta.name?.trim() ? labelFrom(meta.name.trim()) : fallbackLabel,
11045
- ...meta.description?.trim() ? { description: meta.description.trim() } : {},
11046
- ...meta.author?.trim() ? { author: meta.author.trim() } : {},
11047
- ...meta.preview !== undefined ? { preview: meta.preview } : {},
11048
- ...meta.tokensFile?.trim() ? { tokensFile: meta.tokensFile.trim() } : {},
11049
- ...styles.length > 0 ? { styles } : {},
11050
- carries: extensionCarries(meta),
11051
- dir
11052
- };
10902
+ return { name, version, tarball: tarball.href, integrity: parseIntegrity(dist) };
11053
10903
  }
11054
- async function describeEntry(projectDir, raw) {
11055
- const warnings = [];
11056
- let parsed = null;
10904
+ async function downloadTarball(ctx, pkg, archivePath) {
10905
+ const file = await open2(archivePath, "wx");
10906
+ const hash = createHash7(pkg.integrity.algorithm);
11057
10907
  try {
11058
- parsed = parseExtensionSpecifier(raw.use);
11059
- } catch (error2) {
11060
- warnings.push(error2 instanceof Error ? error2.message : String(error2));
10908
+ await withFetchTimeout({
10909
+ timeoutMs: TARBALL_TIMEOUT_MS,
10910
+ signal: ctx.signal,
10911
+ timeoutMessage: `Downloading ${pkg.name}@${pkg.version} timed out.`,
10912
+ offlineMessage: (cause) => `Downloading ${pkg.name}@${pkg.version} failed (${cause instanceof Error ? cause.message : String(cause)}).`
10913
+ }, async (signal) => {
10914
+ const response = await ctx.fetch(pkg.tarball, { signal, redirect: "error" });
10915
+ if (!response.ok) {
10916
+ throw new FriendlyHttpError(`Downloading ${pkg.name}@${pkg.version} failed (HTTP ${response.status}).`);
10917
+ }
10918
+ await streamResponse(ctx, response, ctx.limits.packageTarballBytes, `Plugin dependency downloads exceed the ${mb(ctx.limits.totalNetworkBytes)} total limit.`, async (chunk2) => {
10919
+ hash.update(chunk2);
10920
+ try {
10921
+ await file.write(chunk2);
10922
+ } catch (cause) {
10923
+ throw new SecurityInstallError(`Could not store ${pkg.name}@${pkg.version} while downloading.`, { cause });
10924
+ }
10925
+ });
10926
+ });
10927
+ } finally {
10928
+ await file.close();
11061
10929
  }
11062
- const common = {
11063
- use: raw.use,
11064
- enabled: raw.enabled,
11065
- ...raw.export ? { export: raw.export } : {}
11066
- };
11067
- const withWarnings = (entry) => warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
11068
- if (!parsed) {
11069
- return withWarnings({ ...common, kind: "npm", name: raw.use, label: raw.use, carries: NO_CARRIES });
10930
+ const actual = hash.digest();
10931
+ if (actual.length !== pkg.integrity.digest.length || !timingSafeEqual(actual, pkg.integrity.digest)) {
10932
+ throw new SecurityInstallError(`The tarball for ${pkg.name}@${pkg.version} failed its registry integrity check.`);
11070
10933
  }
11071
- if (parsed.kind === "bundled") {
11072
- const bundledName = parsed.name;
11073
- const rec = RECOMMENDED_EXTENSIONS.find((r) => r.use === bundledName);
11074
- return withWarnings({
11075
- ...common,
11076
- kind: "bundled",
11077
- name: bundledName,
11078
- label: rec?.label ?? bundledName,
11079
- ...rec ? { description: rec.description } : {},
11080
- carries: { ...NO_CARRIES, markdown: true }
11081
- });
10934
+ }
10935
+ function safeTarRelative(entryPath, packageName) {
10936
+ if (!entryPath || entryPath.includes("\\") || entryPath.startsWith("/") || /^[a-zA-Z]:/.test(entryPath)) {
10937
+ throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath || "(empty)"}`);
11082
10938
  }
11083
- if (parsed.kind === "path") {
11084
- const abs = path16.resolve(projectDir, parsed.path);
11085
- const base2 = { ...common, kind: "path", name: parsed.path };
11086
- let info2;
11087
- try {
11088
- info2 = await stat5(abs);
11089
- } catch {
11090
- warnings.push(`Not found: ${abs}`);
11091
- return withWarnings({ ...base2, label: path16.basename(abs), carries: NO_CARRIES });
10939
+ const parts = entryPath.split("/");
10940
+ if (parts.at(-1) === "")
10941
+ parts.pop();
10942
+ const legacyTypesRoot = packageName.startsWith("@types/") ? packageName.slice("@types/".length) : null;
10943
+ if (parts.some((part) => !part || part === "." || part === "..") || parts[0] !== "package" && parts[0] !== legacyTypesRoot) {
10944
+ throw new SecurityInstallError(`The npm tarball contains an unsafe path: ${entryPath}`);
10945
+ }
10946
+ const relative2 = parts.slice(1).join("/");
10947
+ if (!relative2)
10948
+ return { relative: relative2, key: "" };
10949
+ assertWindowsSafeRelativePath(relative2);
10950
+ if (relative2.normalize("NFKC").split("/").some((part) => part.toLowerCase() === "node_modules")) {
10951
+ throw new SecurityInstallError(`The npm tarball contains bundled node_modules (${entryPath}); bundled dependencies are not accepted.`);
10952
+ }
10953
+ return { relative: relative2, key: windowsPathKey(relative2) };
10954
+ }
10955
+ async function extractTarball(ctx, archivePath, packageDir, packageName) {
10956
+ const { extract } = await import("tar");
10957
+ let rejected = null;
10958
+ const seen = new Set;
10959
+ const metadataTypes = new Set([
10960
+ "GlobalExtendedHeader",
10961
+ "ExtendedHeader",
10962
+ "NextFileHasLongLinkpath",
10963
+ "NextFileHasLongPath",
10964
+ "OldGnuLongPath",
10965
+ "OldExtendedHeader"
10966
+ ]);
10967
+ await mkdir8(packageDir, { recursive: true });
10968
+ const unpack = extract({
10969
+ cwd: packageDir,
10970
+ strip: 1,
10971
+ strict: true,
10972
+ preservePaths: false,
10973
+ preserveOwner: false,
10974
+ noMtime: true,
10975
+ chmod: false,
10976
+ maxDepth: ctx.limits.dependencyDepth * 4,
10977
+ filter: (entryPath, rawEntry) => {
10978
+ if (rejected)
10979
+ return false;
10980
+ const entry = rawEntry;
10981
+ if (metadataTypes.has(entry.type))
10982
+ return true;
10983
+ if (entry.type === "Link" || entry.type === "SymbolicLink") {
10984
+ rejected = new SecurityInstallError(`The npm tarball contains an unsafe link: ${entryPath}`);
10985
+ return false;
10986
+ }
10987
+ if (entry.type !== "File" && entry.type !== "OldFile" && entry.type !== "Directory") {
10988
+ rejected = new SecurityInstallError(`The npm tarball contains unsupported entry type ${entry.type}: ${entryPath}`);
10989
+ return false;
10990
+ }
10991
+ try {
10992
+ const { relative: relative2, key } = safeTarRelative(entryPath, packageName);
10993
+ if (!relative2) {
10994
+ if (entry.type === "Directory")
10995
+ return true;
10996
+ throw new SecurityInstallError(`The npm tarball uses package/ as a file.`);
10997
+ }
10998
+ if (seen.has(key)) {
10999
+ throw new SecurityInstallError(`The npm tarball has a Windows-colliding path: ${entryPath}`);
11000
+ }
11001
+ seen.add(key);
11002
+ ctx.totalFiles++;
11003
+ if (ctx.totalFiles > ctx.limits.totalFiles) {
11004
+ throw new SecurityInstallError(`Plugin graph exceeds ${ctx.limits.totalFiles} files.`);
11005
+ }
11006
+ if (entry.type !== "Directory") {
11007
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > ctx.limits.packageFileBytes) {
11008
+ throw new SecurityInstallError(`Package file exceeds the ${mb(ctx.limits.packageFileBytes)} limit: ${entryPath}`);
11009
+ }
11010
+ }
11011
+ return true;
11012
+ } catch (error2) {
11013
+ rejected = error2 instanceof Error ? error2 : new SecurityInstallError(String(error2));
11014
+ return false;
11015
+ }
11092
11016
  }
11093
- if (info2.isDirectory()) {
11094
- return withWarnings(await describeFolder(abs, base2, prettify(path16.basename(abs))));
11017
+ });
11018
+ const limiter = new Transform({
11019
+ transform(chunk2, _encoding, callback) {
11020
+ ctx.totalUnpackedBytes += chunk2.length;
11021
+ if (ctx.totalUnpackedBytes > ctx.limits.totalUnpackedBytes) {
11022
+ callback(new SecurityInstallError(`Plugin graph expands beyond the ${mb(ctx.limits.totalUnpackedBytes)} limit.`));
11023
+ } else
11024
+ callback(null, chunk2);
11095
11025
  }
11096
- return withWarnings({
11097
- ...base2,
11098
- label: path16.basename(abs),
11099
- carries: { ...NO_CARRIES, markdown: true }
11100
- });
11101
- }
11102
- const base = {
11103
- ...common,
11104
- kind: "npm",
11105
- name: parsed.name,
11106
- ...parsed.version ? { version: parsed.version } : {}
11107
- };
11108
- if (!parsed.version) {
11109
- warnings.push(`Not pinned — run \`gutterpress ext add ${parsed.name}\` to install it and pin an exact version.`);
11110
- return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11026
+ });
11027
+ try {
11028
+ await pipeline(createReadStream2(archivePath), createGunzip(), limiter, unpack);
11029
+ } catch (error2) {
11030
+ if (rejected)
11031
+ throw rejected;
11032
+ if (error2 instanceof SecurityInstallError)
11033
+ throw error2;
11034
+ throw new SecurityInstallError(`The npm tarball could not be safely extracted (${error2 instanceof Error ? error2.message : String(error2)}).`, { cause: error2 });
11111
11035
  }
11112
- let installRoot = null;
11036
+ if (rejected)
11037
+ throw rejected;
11038
+ }
11039
+ async function readInstalledManifest(packageDir, selected) {
11040
+ const packageJsonPath = path17.join(packageDir, "package.json");
11041
+ let manifest = null;
11113
11042
  try {
11114
- installRoot = await resolveVendoredPluginInstallRoot(projectDir, parsed.name, parsed.version);
11043
+ const info2 = await lstat2(packageJsonPath);
11044
+ if (!info2.isFile() || info2.isSymbolicLink())
11045
+ throw new Error("not a regular file");
11046
+ manifest = object3(JSON.parse(await readFile27(packageJsonPath, "utf8")));
11115
11047
  } catch (error2) {
11116
- warnings.push(error2 instanceof Error ? error2.message : String(error2));
11048
+ throw new SecurityInstallError(`Downloaded ${selected.name}@${selected.version} has no valid package.json (${error2 instanceof Error ? error2.message : String(error2)}).`);
11117
11049
  }
11118
- if (!installRoot) {
11119
- warnings.push(`Not installed — run \`gutterpress ext add ${raw.use}\`.`);
11120
- return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11050
+ if (manifest?.name !== selected.name || manifest.version !== selected.version) {
11051
+ throw new SecurityInstallError(`Downloaded ${String(manifest?.name)}@${String(manifest?.version)} instead of ${selected.name}@${selected.version}.`);
11121
11052
  }
11122
- const pkgDir = vendoredNpmPluginPackageDir(installRoot, parsed.name);
11123
- if (!existsSync11(path16.join(pkgDir, EXTENSION_MANIFEST_FILENAME))) {
11124
- return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11053
+ return manifest;
11054
+ }
11055
+ async function optionalPackageEntry(packageDir, manifest, condition) {
11056
+ try {
11057
+ return await resolvePackageEntry(packageDir, manifest, condition);
11058
+ } catch (error2) {
11059
+ if (error2 instanceof Error && error2.message.includes("no contained JavaScript entry point")) {
11060
+ return;
11061
+ }
11062
+ throw error2;
11125
11063
  }
11126
- const described = await describeFolder(pkgDir, base, parsed.name, (name) => name);
11127
- return withWarnings({
11128
- ...described,
11129
- carries: { ...described.carries, markdown: true }
11130
- });
11131
11064
  }
11132
- async function listProjectExtensions(projectDir) {
11133
- const { doc } = await loadManifestDoc(projectDir);
11134
- const seq = doc.get("extensions", true);
11135
- if (!isSeq2(seq))
11136
- return [];
11137
- const out = [];
11138
- for (const item of seq.items) {
11139
- const raw = rawEntry(item);
11140
- if (!raw)
11141
- continue;
11142
- out.push(await describeEntry(projectDir, raw));
11065
+ function platformAllowed(rule, current) {
11066
+ let values;
11067
+ if (typeof rule === "string") {
11068
+ values = [rule];
11069
+ } else if (Array.isArray(rule) && rule.every((item) => typeof item === "string")) {
11070
+ values = rule;
11071
+ } else {
11072
+ return true;
11143
11073
  }
11144
- return out;
11074
+ if (values.length === 0)
11075
+ return true;
11076
+ if (values.length === 1 && values[0] === "any")
11077
+ return true;
11078
+ if (values.includes(`!${current}`))
11079
+ return false;
11080
+ const positives = values.filter((value) => !value.startsWith("!"));
11081
+ return positives.length === 0 || positives.includes(current);
11145
11082
  }
11146
- async function describeExtension(projectDir, use) {
11147
- return (await listProjectExtensions(projectDir)).find((e) => sameExtension(e.use, use)) ?? null;
11083
+ function relativePackagePath(parentPackagePath, name) {
11084
+ const segments = name.split("/");
11085
+ return parentPackagePath ? `${parentPackagePath}/node_modules/${segments.join("/")}` : `node_modules/${segments.join("/")}`;
11148
11086
  }
11149
- function extensionConfigFor(entry) {
11150
- return {
11151
- use: entry.use,
11152
- ...entry.kind === "path" ? { path: entry.name } : { name: entry.name, ...entry.version ? { version: entry.version } : {} },
11153
- ...entry.export ? { export: entry.export } : {},
11154
- options: {}
11087
+ async function installPackage(ctx, name, selector, parentPackagePath, ancestors, depth, optional) {
11088
+ const recordStart = ctx.records.length;
11089
+ const skippedStart = ctx.skipped.length;
11090
+ const warningsBefore = new Set(ctx.warnings);
11091
+ const discardOptionalSubtree = () => {
11092
+ ctx.records.splice(recordStart);
11093
+ ctx.skipped.splice(skippedStart);
11094
+ ctx.warnings = warningsBefore;
11155
11095
  };
11156
- }
11157
- async function setExtensionEnabled(projectDir, use, enabled) {
11158
- return withMutationLock(projectDir, async () => {
11159
- const { doc, file } = await loadManifestDoc(projectDir);
11160
- const seq = doc.get("extensions", true);
11161
- const idx = isSeq2(seq) ? indexOfUse(seq, use) : -1;
11162
- if (!isSeq2(seq) || idx < 0)
11163
- throw new Error(`Extension "${use}" is not in the manifest.`);
11164
- const item = seq.items[idx];
11165
- if (enabled) {
11166
- if (isMap(item)) {
11167
- item.delete("enabled");
11168
- if (item.items.length === 1 && typeof item.get("use") === "string") {
11169
- seq.items[idx] = new Scalar(item.get("use"));
11170
- }
11096
+ if (depth > ctx.limits.dependencyDepth) {
11097
+ throw new SecurityInstallError(`Dependency graph exceeds depth ${ctx.limits.dependencyDepth}.`);
11098
+ }
11099
+ let selected;
11100
+ try {
11101
+ selected = await selectPackage(ctx, name, selector);
11102
+ } catch (error2) {
11103
+ if (optional && isOptionalAvailabilityError(error2)) {
11104
+ discardOptionalSubtree();
11105
+ return { skipped: error2 instanceof Error ? error2.message : String(error2) };
11106
+ }
11107
+ throw error2;
11108
+ }
11109
+ const ancestor = ancestors.find((candidate) => candidate.name === name && (candidate.version === selected.version || (selector ? satisfies2(candidate.version, selector) : false)));
11110
+ if (ancestor)
11111
+ return { path: ancestor.path };
11112
+ if (ctx.records.length >= ctx.limits.totalPackages) {
11113
+ throw new SecurityInstallError(`Dependency graph exceeds ${ctx.limits.totalPackages} packages.`);
11114
+ }
11115
+ const relativePath = relativePackagePath(parentPackagePath, name);
11116
+ const packageDir = path17.join(ctx.stageRoot, ...relativePath.split("/"));
11117
+ const archivePath = path17.join(ctx.downloadsDir, `${ctx.archiveCounter++}.tgz`);
11118
+ try {
11119
+ await mkdir8(path17.dirname(packageDir), { recursive: true });
11120
+ await downloadTarball(ctx, selected, archivePath);
11121
+ await extractTarball(ctx, archivePath, packageDir, selected.name);
11122
+ const manifest = await readInstalledManifest(packageDir, selected);
11123
+ if (!platformAllowed(manifest.os, process.platform) || !platformAllowed(manifest.cpu, process.arch)) {
11124
+ const reason = `${name}@${selected.version} does not support ${process.platform}/${process.arch}`;
11125
+ if (optional) {
11126
+ await rm3(packageDir, { recursive: true, force: true });
11127
+ return { skipped: reason };
11171
11128
  }
11172
- } else if (isMap(item)) {
11173
- item.set("enabled", false);
11174
- } else {
11175
- const map = new YAMLMap(doc.schema);
11176
- map.set("use", entryUse(item));
11177
- map.set("enabled", false);
11178
- seq.items[idx] = map;
11129
+ throw new PackageUnavailableError(reason);
11179
11130
  }
11180
- await writeManifestDoc(file, doc);
11181
- });
11182
- }
11183
- async function removeExtension(projectDir, use) {
11184
- return withMutationLock(projectDir, async () => {
11185
- const { doc, file } = await loadManifestDoc(projectDir);
11186
- const seq = doc.get("extensions", true);
11187
- const idx = isSeq2(seq) ? indexOfUse(seq, use) : -1;
11188
- if (!isSeq2(seq) || idx < 0)
11189
- throw new Error(`Extension "${use}" is not in the manifest.`);
11190
- const written = entryUse(seq.items[idx]);
11191
- seq.items.splice(idx, 1);
11192
- if (seq.items.length === 0)
11193
- doc.delete("extensions");
11194
- await writeManifestDoc(file, doc);
11195
- let parsed = null;
11196
- try {
11197
- parsed = parseExtensionSpecifier(written);
11198
- } catch {
11199
- return;
11131
+ const importEntry = await optionalPackageEntry(packageDir, manifest, "import");
11132
+ const requireEntry = await optionalPackageEntry(packageDir, manifest, "require");
11133
+ if (parentPackagePath === null && !importEntry && !requireEntry) {
11134
+ throw new SecurityInstallError(`${name}@${selected.version} has no JavaScript plugin entry.`);
11200
11135
  }
11201
- if (parsed.kind === "npm" && parsed.version) {
11202
- await rm4(vendoredNpmPluginRoot(projectDir, parsed.name, parsed.version), {
11203
- recursive: true,
11204
- force: true
11205
- });
11206
- clearVendoredPluginResolver(projectDir, parsed.name, parsed.version);
11136
+ const record = {
11137
+ path: relativePath,
11138
+ name,
11139
+ version: selected.version,
11140
+ tarball: selected.tarball,
11141
+ integrity: selected.integrity.label,
11142
+ legacySha1: selected.integrity.legacySha1,
11143
+ ...importEntry ? { entry: `${relativePath}/${importEntry}` } : {},
11144
+ ...requireEntry ? { requireEntry: `${relativePath}/${requireEntry}` } : {},
11145
+ dependencies: {},
11146
+ packageDir,
11147
+ manifest
11148
+ };
11149
+ ctx.records.push(record);
11150
+ if (selected.integrity.legacySha1) {
11151
+ ctx.warnings.add(`${name}@${selected.version} is an old npm release with only SHA-1 registry integrity. It was accepted for compatibility and recorded as legacy SHA-1 in the vendor receipt.`);
11207
11152
  }
11208
- });
11209
- }
11210
- async function upsertEntry(projectDir, use, exportName) {
11211
- const { doc, file } = await loadManifestDoc(projectDir);
11212
- const seq = ensureSeq(doc, "extensions");
11213
- const idx = indexOfUse(seq, use);
11214
- if (idx >= 0) {
11215
- const item = seq.items[idx];
11216
- if (isMap(item)) {
11217
- item.set("use", use);
11218
- if (exportName)
11219
- item.set("export", exportName);
11220
- else
11221
- item.delete("export");
11222
- if (item.items.length === 1)
11223
- seq.items[idx] = new Scalar(use);
11224
- } else if (exportName) {
11225
- const map = new YAMLMap(doc.schema);
11226
- map.set("use", use);
11227
- map.set("export", exportName);
11228
- seq.items[idx] = map;
11229
- } else {
11230
- seq.items[idx] = new Scalar(use);
11153
+ const dependencies = stringMap2(manifest.dependencies);
11154
+ const optionalDependencies = stringMap2(manifest.optionalDependencies);
11155
+ for (const optionalName of Object.keys(optionalDependencies))
11156
+ delete dependencies[optionalName];
11157
+ const peerDependencies = stringMap2(manifest.peerDependencies);
11158
+ const peerMeta = object3(manifest.peerDependenciesMeta) ?? {};
11159
+ for (const existing of [...Object.keys(dependencies), ...Object.keys(optionalDependencies)]) {
11160
+ delete peerDependencies[existing];
11231
11161
  }
11232
- } else if (exportName) {
11233
- const map = new YAMLMap(doc.schema);
11234
- map.set("use", use);
11235
- map.set("export", exportName);
11236
- seq.add(map);
11237
- } else {
11238
- seq.add(new Scalar(use));
11162
+ const nextAncestors = [...ancestors, record];
11163
+ const requiredEntries = [
11164
+ ...Object.entries(dependencies).map(([dependency, range]) => ({ dependency, range, kind: "required" })),
11165
+ ...Object.entries(peerDependencies).filter(([dependency]) => object3(peerMeta[dependency])?.optional !== true).map(([dependency, range]) => ({ dependency, range, kind: "peer" }))
11166
+ ].sort((a, b) => a.dependency.localeCompare(b.dependency));
11167
+ for (const { dependency, range } of requiredEntries) {
11168
+ const child = await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, false);
11169
+ if (!child.path)
11170
+ throw new Error(`Required dependency ${dependency} was unexpectedly skipped.`);
11171
+ record.dependencies[dependency] = child.path;
11172
+ }
11173
+ const optionalEntries = Object.entries(optionalDependencies).sort(([a], [b]) => a.localeCompare(b));
11174
+ for (const [dependency, range] of optionalEntries) {
11175
+ const child = await installPackage(ctx, dependency, range, relativePath, nextAncestors, depth + 1, true);
11176
+ if (child.path)
11177
+ record.dependencies[dependency] = child.path;
11178
+ else if (child.skipped) {
11179
+ ctx.skipped.push({ from: relativePath, name: dependency, selector: range, kind: "optional", reason: child.skipped });
11180
+ }
11181
+ }
11182
+ for (const [dependency, range] of Object.entries(peerDependencies)) {
11183
+ if (object3(peerMeta[dependency])?.optional === true) {
11184
+ ctx.skipped.push({
11185
+ from: relativePath,
11186
+ name: dependency,
11187
+ selector: range,
11188
+ kind: "optional-peer",
11189
+ reason: "Optional peer dependencies are not auto-installed."
11190
+ });
11191
+ }
11192
+ }
11193
+ return { path: relativePath };
11194
+ } catch (error2) {
11195
+ await rm3(packageDir, { recursive: true, force: true });
11196
+ if (optional && isOptionalAvailabilityError(error2)) {
11197
+ discardOptionalSubtree();
11198
+ return { skipped: error2 instanceof Error ? error2.message : String(error2) };
11199
+ }
11200
+ throw error2;
11201
+ } finally {
11202
+ await rm3(archivePath, { force: true });
11239
11203
  }
11240
- await writeManifestDoc(file, doc);
11241
11204
  }
11242
- async function addPathExtension(projectDir, abs, exportName) {
11205
+ async function ensureDirectory(pathname) {
11243
11206
  try {
11244
- await stat5(abs);
11245
- } catch {
11246
- throw new Error(`Extension not found: ${abs}`);
11207
+ const info2 = await lstat2(pathname);
11208
+ if (info2.isSymbolicLink() || !info2.isDirectory()) {
11209
+ throw new Error(`Plugin install path is not a normal directory: ${pathname}`);
11210
+ }
11211
+ } catch (error2) {
11212
+ if (error2.code !== "ENOENT")
11213
+ throw error2;
11214
+ await mkdir8(pathname);
11247
11215
  }
11248
- const use = manifestPathFor(projectDir, abs);
11249
- await loadPlugin(extensionConfigFor({ use, kind: "path", name: use, export: exportName }), projectDir);
11250
- await upsertEntry(projectDir, use, exportName);
11251
- return await describeExtension(projectDir, use);
11252
- }
11253
- async function addExtension(projectDir, specifier, options = {}) {
11254
- return withMutationLock(projectDir, () => addExtensionUnlocked(projectDir, specifier, options));
11255
11216
  }
11256
- async function addExtensionUnlocked(projectDir, specifier, options) {
11257
- const input = specifier.trim();
11258
- const exportName = options.exportName?.trim() || undefined;
11259
- if (path16.isAbsolute(input))
11260
- return addPathExtension(projectDir, input, exportName);
11261
- const parsed = parseExtensionSpecifier(input);
11262
- if (parsed.kind === "path") {
11263
- return addPathExtension(projectDir, path16.resolve(projectDir, parsed.path), exportName);
11264
- }
11265
- if (parsed.kind === "bundled") {
11266
- if (exportName)
11267
- throw new Error(`Bundled extension "${parsed.name}" does not take a named export.`);
11268
- await upsertEntry(projectDir, parsed.name);
11269
- return await describeExtension(projectDir, parsed.name);
11217
+ async function prepareVendorParent(projectDir, name) {
11218
+ const plugins = path17.join(projectDir, PLUGINS_DIR);
11219
+ const npm = path17.join(plugins, VENDORED_NPM_DIR);
11220
+ const packageParent = path17.dirname(vendoredNpmPluginRoot(projectDir, name, "0.0.0"));
11221
+ await mkdir8(projectDir, { recursive: true });
11222
+ await ensureDirectory(plugins);
11223
+ await ensureDirectory(npm);
11224
+ await ensureDirectory(packageParent);
11225
+ const [realNpm, realPackageParent] = await Promise.all([
11226
+ realpath2(npm),
11227
+ realpath2(packageParent)
11228
+ ]);
11229
+ if (!isContained2(projectDir, realNpm) || !isContained2(projectDir, realPackageParent)) {
11230
+ throw new Error("Plugin install path resolves outside the project.");
11270
11231
  }
11271
- const existing = await describeExtension(projectDir, parsed.name);
11272
- const chosenExport = exportName ?? existing?.export;
11273
- const installed = await installNpmPlugin(projectDir, input, options);
11274
- const use = pinnedNpmSpecifier(installed.name, installed.version);
11232
+ return realNpm;
11233
+ }
11234
+ function publicReceipt(record) {
11235
+ return {
11236
+ path: record.path,
11237
+ name: record.name,
11238
+ version: record.version,
11239
+ tarball: record.tarball,
11240
+ integrity: record.integrity,
11241
+ legacySha1: record.legacySha1,
11242
+ ...record.entry ? { entry: record.entry } : {},
11243
+ ...record.requireEntry ? { requireEntry: record.requireEntry } : {},
11244
+ dependencies: record.dependencies
11245
+ };
11246
+ }
11247
+ async function installNpmPlugin(projectDirInput, packageSpec, options = {}) {
11248
+ const requestedProjectDir = path17.resolve(projectDirInput);
11249
+ await mkdir8(requestedProjectDir, { recursive: true });
11250
+ const projectDir = await realpath2(requestedProjectDir);
11251
+ const requested = parseNpmPluginSpec(packageSpec);
11252
+ const npmRoot = await prepareVendorParent(projectDir, requested.name);
11253
+ const container = await mkdtemp2(path17.join(npmRoot, ".install-"));
11254
+ const stageRoot = path17.join(container, "root");
11255
+ const downloadsDir = path17.join(container, "downloads");
11256
+ await Promise.all([mkdir8(stageRoot), mkdir8(downloadsDir)]);
11257
+ const ctx = {
11258
+ fetch: options.fetch ?? globalThis.fetch,
11259
+ signal: options.signal,
11260
+ limits: mergeLimits(options.limits),
11261
+ stageRoot,
11262
+ downloadsDir,
11263
+ packuments: new Map,
11264
+ records: [],
11265
+ skipped: [],
11266
+ warnings: new Set,
11267
+ totalNetworkBytes: 0,
11268
+ totalUnpackedBytes: 0,
11269
+ totalFiles: 0,
11270
+ archiveCounter: 0
11271
+ };
11272
+ let finalRoot = "";
11273
+ let backupRoot = null;
11275
11274
  try {
11276
- await loadPlugin(extensionConfigFor({
11277
- use,
11278
- kind: "npm",
11279
- name: installed.name,
11280
- version: installed.version,
11281
- export: chosenExport
11282
- }), projectDir);
11283
- await options.__testFailBeforeManifestCommit?.();
11284
- await upsertEntry(projectDir, use, chosenExport);
11285
- } catch (cause) {
11275
+ const rootResult = await installPackage(ctx, requested.name, requested.selector, null, [], 0, false);
11276
+ if (!rootResult.path)
11277
+ throw new Error("Root plugin package was unexpectedly skipped.");
11278
+ const rootRecord = ctx.records.find((record) => record.path === rootResult.path);
11279
+ if (!rootRecord)
11280
+ throw new Error("Root plugin package is missing from the dependency graph.");
11281
+ const importEntry = rootRecord.entry ?? rootRecord.requireEntry;
11282
+ if (!importEntry)
11283
+ throw new Error("Root plugin package has no recorded JavaScript entry.");
11284
+ const importFormat = packageEntryFormat(rootRecord.manifest, importEntry);
11285
+ const entry = importFormat === "commonjs" ? rootRecord.requireEntry ?? importEntry : importEntry;
11286
+ const format = packageEntryFormat(rootRecord.manifest, entry);
11287
+ const tree = await computeVendorTreeDigest(stageRoot);
11288
+ const receipt = {
11289
+ schemaVersion: VENDOR_RECEIPT_VERSION,
11290
+ root: {
11291
+ name: rootRecord.name,
11292
+ version: rootRecord.version,
11293
+ packagePath: rootRecord.path,
11294
+ entry,
11295
+ format
11296
+ },
11297
+ packages: ctx.records.map(publicReceipt).sort((a, b) => a.path.localeCompare(b.path)),
11298
+ skipped: ctx.skipped,
11299
+ tree: { algorithm: "sha256", ...tree }
11300
+ };
11301
+ const receiptFile = await open2(path17.join(stageRoot, VENDOR_RECEIPT_FILE), "wx");
11286
11302
  try {
11287
- await rollbackNpmPluginInstall(installed);
11288
- clearVendoredPluginResolver(projectDir, installed.name, installed.version);
11289
- } catch (rollbackError) {
11290
- throw new Error(`Extension install failed and its previous vendor tree could not be restored: ` + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause });
11303
+ await receiptFile.writeFile(`${JSON.stringify(receipt, null, 2)}
11304
+ `, "utf8");
11305
+ await receiptFile.sync();
11306
+ } finally {
11307
+ await receiptFile.close();
11291
11308
  }
11292
- throw new Error(`Downloaded ${use}, but it is not a loadable markdown-it plugin: ` + `${cause instanceof Error ? cause.message : String(cause)}`, { cause });
11293
- }
11294
- const warnings = [...installed.warnings];
11295
- await finalizeNpmPluginInstall(installed).catch((error2) => {
11296
- warnings.push(`The extension was installed, but an old backup could not be removed: ` + `${error2 instanceof Error ? error2.message : String(error2)}`);
11297
- });
11298
- const entry = await describeExtension(projectDir, use);
11299
- return warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
11300
- }
11301
- async function uniqueExtensionId(projectDir, base) {
11302
- const slug = slugify(base) || "extension";
11303
- let candidate = slug;
11304
- for (let n = 2;existsSync11(path16.join(projectDir, EXTENSIONS_DIR, candidate)); n++) {
11305
- candidate = `${slug}-${n}`;
11309
+ finalRoot = vendoredNpmPluginRoot(projectDir, rootRecord.name, rootRecord.version);
11310
+ const realFinalParent = await realpath2(path17.dirname(finalRoot));
11311
+ if (!isContained2(projectDir, realFinalParent)) {
11312
+ throw new Error("Plugin install destination resolves outside the project.");
11313
+ }
11314
+ try {
11315
+ await lstat2(finalRoot);
11316
+ backupRoot = `${finalRoot}.backup-${randomUUID3()}`;
11317
+ await rename2(finalRoot, backupRoot);
11318
+ } catch (error2) {
11319
+ if (error2.code !== "ENOENT")
11320
+ throw error2;
11321
+ }
11322
+ try {
11323
+ await rename2(stageRoot, finalRoot);
11324
+ } catch (error2) {
11325
+ if (backupRoot) {
11326
+ try {
11327
+ await rename2(backupRoot, finalRoot);
11328
+ } catch (restoreError) {
11329
+ throw new Error(`Could not publish the new plugin tree or restore the previous tree: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, { cause: error2 });
11330
+ }
11331
+ }
11332
+ throw error2;
11333
+ }
11334
+ return {
11335
+ name: rootRecord.name,
11336
+ version: rootRecord.version,
11337
+ installRoot: finalRoot,
11338
+ backupRoot,
11339
+ warnings: [...ctx.warnings]
11340
+ };
11341
+ } finally {
11342
+ await rm3(container, { recursive: true, force: true }).catch(() => {});
11306
11343
  }
11307
- return candidate;
11308
11344
  }
11309
- async function addBuiltInStyleSet(projectDir, id) {
11310
- if (!BUILT_IN_STYLE_SET_IDS.includes(id)) {
11311
- throw new Error(`Unknown built-in look "${id}" (one of: ${BUILT_IN_STYLE_SET_IDS.join(", ")}).`);
11345
+ async function finalizeNpmPluginInstall(installed) {
11346
+ if (installed.backupRoot) {
11347
+ await rm3(installed.backupRoot, { recursive: true, force: true });
11312
11348
  }
11313
- return withMutationLock(projectDir, async () => {
11314
- const dest = path16.join(projectDir, EXTENSIONS_DIR, id);
11315
- if (!existsSync11(dest)) {
11316
- const src = path16.join(await getAssetsDir(), "themes", id);
11317
- await mkdir7(path16.dirname(dest), { recursive: true });
11318
- await cp2(src, dest, { recursive: true });
11319
- }
11320
- return addExtensionUnlocked(projectDir, `./${EXTENSIONS_DIR}/${id}`, {});
11321
- });
11322
11349
  }
11323
-
11324
- // src/lib/project-scaffold.ts
11325
- import { access, copyFile, cp as cp3, mkdir as mkdir8, readdir as readdir5, readFile as readFile27, stat as stat6, writeFile as writeFile6 } from "node:fs/promises";
11326
- import { constants as FS, existsSync as existsSync12 } from "node:fs";
11327
- import path17 from "node:path";
11328
- var STARTER_THEME_FOR_TEMPLATE = {
11329
- book: "clean-book",
11330
- zine: "zine",
11331
- technical: "technical-doc"
11332
- };
11333
- function starterOverridesCss(themeId) {
11334
- return `/**
11335
- * Your book's own styles.
11336
- *
11337
- * Your book's look lives in ${EXTENSIONS_DIR}/${themeId}/theme.css — an
11338
- * extension listed under \`extensions:\` in manifest.yaml. Add or swap looks
11339
- * with \`gutterpress ext add\` (or the desktop's Look view) without losing
11340
- * anything you write here. This file loads AFTER every extension, so any
11341
- * rule you add below overrides the look's defaults.
11342
- *
11343
- * Most everyday adjustments (colors, fonts, margins, spacing) are already
11344
- * exposed as CSS custom properties on the theme's :root — try those first.
11345
- */
11346
- `;
11350
+ async function rollbackNpmPluginInstall(installed) {
11351
+ await rm3(installed.installRoot, { recursive: true, force: true });
11352
+ if (installed.backupRoot)
11353
+ await rename2(installed.backupRoot, installed.installRoot);
11347
11354
  }
11348
11355
 
11349
- class CreateProjectErrorImpl extends Error {
11350
- code;
11351
- constructor(code, message) {
11352
- super(message);
11353
- this.name = "CreateProjectError";
11354
- this.code = code;
11356
+ // src/lib/extension-manager.ts
11357
+ var EXTENSIONS_DIR = "extensions";
11358
+ var RECOMMENDED_EXTENSIONS = [
11359
+ {
11360
+ use: "markdown-it-mark",
11361
+ label: "Highlight",
11362
+ description: "Highlighted text with `==marked==` -> `<mark>`."
11363
+ },
11364
+ {
11365
+ use: "markdown-it-sub",
11366
+ label: "Subscript",
11367
+ description: "Subscript text with `H~2~O`."
11368
+ },
11369
+ {
11370
+ use: "markdown-it-sup",
11371
+ label: "Superscript",
11372
+ description: "Superscript text with `29^th^`."
11373
+ },
11374
+ {
11375
+ use: "markdown-it-abbr",
11376
+ label: "Abbreviations",
11377
+ description: "Define `*[HTML]: Hyper Text...` and get `<abbr>` tooltips."
11378
+ },
11379
+ {
11380
+ use: "gutterpress-gfm-alerts",
11381
+ label: "Callouts",
11382
+ description: "GitHub-style `> [!NOTE]` alert boxes (Note/Tip/Important/Warning/Caution)."
11355
11383
  }
11384
+ ];
11385
+ var BUILT_IN_STYLE_SET_IDS = ["clean-book", "zine", "technical-doc"];
11386
+ var mutationQueues = new Map;
11387
+ function withMutationLock(projectDir, mutation) {
11388
+ const resolved = path18.resolve(projectDir);
11389
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
11390
+ const previous = mutationQueues.get(key) ?? Promise.resolve();
11391
+ const run = previous.then(mutation, mutation);
11392
+ const tail = run.then(() => {
11393
+ return;
11394
+ }, () => {
11395
+ return;
11396
+ });
11397
+ mutationQueues.set(key, tail);
11398
+ tail.then(() => {
11399
+ if (mutationQueues.get(key) === tail)
11400
+ mutationQueues.delete(key);
11401
+ });
11402
+ return run;
11356
11403
  }
11357
- function slugifyProjectName(name) {
11358
- return slugify(name);
11359
- }
11360
- function escapeYamlScalar(value) {
11361
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
11362
- }
11363
- var DEFAULT_AUTHOR = "Anonymous";
11364
- function isPositivePoints(value) {
11365
- return typeof value === "number" && Number.isFinite(value) && value > 0;
11366
- }
11367
- function requirePreset(preset, customPage) {
11368
- if (!preset || !PRESET_IDS.includes(preset)) {
11369
- const got = preset ? ` (got "${preset}")` : "";
11370
- throw new CreateProjectErrorImpl("preset-required", `A preset is required to create a book${got}. Choose one of: ${PRESET_IDS.join(", ")}.`);
11371
- }
11372
- if (preset === "custom") {
11373
- const missing = [];
11374
- if (!customPage || !isPositivePoints(customPage.width))
11375
- missing.push("page width");
11376
- if (!customPage || !isPositivePoints(customPage.height))
11377
- missing.push("page height");
11378
- if (missing.length > 0) {
11379
- throw new CreateProjectErrorImpl("custom-page-required", `The custom preset needs a trim size: ${missing.join(" and ")} in points ` + `(72pt = 1in — e.g. US Letter is 612 x 792).`);
11380
- }
11381
- } else if (customPage && (!isPositivePoints(customPage.width) || !isPositivePoints(customPage.height))) {
11382
- throw new CreateProjectErrorImpl("custom-page-required", "Page overrides need a positive width and height in points (72pt = 1in).");
11383
- }
11384
- if (customPage?.tolerance !== undefined && !isPositivePoints(customPage.tolerance)) {
11385
- throw new CreateProjectErrorImpl("custom-page-required", "Page tolerance must be a positive number of points.");
11404
+ function entryUse(item) {
11405
+ if (isScalar2(item))
11406
+ return typeof item.value === "string" ? item.value.trim() : null;
11407
+ if (isMap2(item)) {
11408
+ const u = item.get("use");
11409
+ return typeof u === "string" ? u.trim() : null;
11386
11410
  }
11387
- return preset;
11411
+ return null;
11388
11412
  }
11389
- function resolveScaffoldTargets(requested, preset) {
11390
- const ids = requested ?? [...PRESETS[preset].defaultTargets];
11391
- const unknown = ids.filter((id) => !TARGETS[id]);
11392
- if (unknown.length > 0) {
11393
- throw new CreateProjectErrorImpl("invalid-targets", `Unknown publish target${unknown.length > 1 ? "s" : ""} "${unknown.join('", "')}". ` + `Known targets: ${TARGET_IDS.join(", ")}.`);
11394
- }
11395
- return [...new Set(ids)];
11413
+ function rawEntry(item) {
11414
+ const use = entryUse(item);
11415
+ if (!use)
11416
+ return null;
11417
+ if (!isMap2(item))
11418
+ return { use, enabled: true };
11419
+ const x = item.get("export");
11420
+ return {
11421
+ use,
11422
+ ...typeof x === "string" && x.trim() ? { export: x.trim() } : {},
11423
+ enabled: item.get("enabled") !== false
11424
+ };
11396
11425
  }
11397
- async function scaffoldProject(options) {
11398
- const name = (options.name ?? "").trim();
11399
- if (!name) {
11400
- throw new CreateProjectErrorImpl("invalid-name", "A project name is required.");
11401
- }
11402
- const slug = slugifyProjectName(name);
11403
- const folderName = (options.folderName ?? slug).trim();
11404
- if (!folderName || folderName !== slugifyProjectName(folderName)) {
11405
- throw new CreateProjectErrorImpl("invalid-name", `Could not derive a valid folder name from "${name}".`);
11406
- }
11407
- const parentDir = options.parentDir;
11408
- if (!parentDir || !path17.isAbsolute(parentDir)) {
11409
- throw new CreateProjectErrorImpl("parent-not-writable", "The save location must be an absolute path.");
11410
- }
11411
- try {
11412
- await access(parentDir, FS.W_OK);
11413
- } catch {
11414
- throw new CreateProjectErrorImpl("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
11415
- }
11416
- const projectDir = path17.join(parentDir, folderName);
11417
- let targetExists = true;
11426
+ function sameExtension(a, b) {
11427
+ if (a.trim() === b.trim())
11428
+ return true;
11429
+ let pa;
11430
+ let pb;
11418
11431
  try {
11419
- await access(projectDir, FS.F_OK);
11432
+ pa = parseExtensionSpecifier(a);
11433
+ pb = parseExtensionSpecifier(b);
11420
11434
  } catch {
11421
- targetExists = false;
11422
- }
11423
- if (targetExists) {
11424
- throw new CreateProjectErrorImpl("target-exists", `A folder named "${folderName}" already exists here. Choose a different name or location.`);
11435
+ return false;
11425
11436
  }
11426
- const template = options.template ?? "book";
11427
- const customTemplateDir = options.templateDir;
11428
- const starterThemeId = STARTER_THEME_FOR_TEMPLATE[template] ?? "clean-book";
11429
- const preset = customTemplateDir && options.preset === undefined ? undefined : requirePreset(options.preset, options.customPage);
11430
- const targets = preset ? resolveScaffoldTargets(options.targets, preset) : undefined;
11431
- try {
11432
- await mkdir8(projectDir, { recursive: true });
11433
- if (customTemplateDir) {
11434
- const entries = await readdir5(customTemplateDir, { withFileTypes: true });
11435
- for (const entry of entries) {
11436
- if (entry.name === ".gutterpress-template.json")
11437
- continue;
11438
- await cp3(path17.join(customTemplateDir, entry.name), path17.join(projectDir, entry.name), { recursive: true });
11439
- }
11440
- await mkdir8(path17.join(projectDir, "assets"), { recursive: true });
11441
- } else {
11442
- const tplManifest = await getAssetPath(`templates/${template}/manifest.yaml`);
11443
- const tplChapter = await getAssetPath(`templates/${template}/chapter-01.md`);
11444
- await mkdir8(path17.join(projectDir, "assets"), { recursive: true });
11445
- await copyFile(tplManifest, path17.join(projectDir, MANIFEST_FILENAMES[0]));
11446
- await copyFile(tplChapter, path17.join(projectDir, "chapter-01.md"));
11447
- await mkdir8(path17.join(projectDir, "styles"), { recursive: true });
11448
- await writeFile6(path17.join(projectDir, "styles", "book.css"), starterOverridesCss(starterThemeId), "utf8");
11449
- }
11450
- } catch (e) {
11451
- throw new CreateProjectErrorImpl("scaffold-io", `Could not create the project files: ${e instanceof Error ? e.message : String(e)}`);
11437
+ return pa.kind === "npm" && pb.kind === "npm" && pa.name === pb.name;
11438
+ }
11439
+ function indexOfUse(seq, use) {
11440
+ const items = seq.items;
11441
+ const exact = items.findIndex((item) => entryUse(item) === use.trim());
11442
+ if (exact >= 0)
11443
+ return exact;
11444
+ return items.findIndex((item) => {
11445
+ const u = entryUse(item);
11446
+ return !!u && sameExtension(u, use);
11447
+ });
11448
+ }
11449
+ function manifestPathFor(projectDir, abs) {
11450
+ const rel = path18.relative(path18.resolve(projectDir), abs).split(path18.sep).join("/");
11451
+ if (rel === "") {
11452
+ throw new Error("An extension cannot be the project folder itself.");
11452
11453
  }
11454
+ if (isPathSpecifier(rel))
11455
+ return rel;
11456
+ return `./${rel}`;
11457
+ }
11458
+ var NO_CARRIES = {
11459
+ markdown: false,
11460
+ styles: false,
11461
+ snippets: false,
11462
+ components: false
11463
+ };
11464
+ async function describeFolder(dir, base, fallbackLabel, labelFrom = prettify) {
11465
+ const meta = await readExtensionMeta(dir);
11466
+ const styles = extensionStyleList(meta);
11467
+ return {
11468
+ ...base,
11469
+ label: meta.name?.trim() ? labelFrom(meta.name.trim()) : fallbackLabel,
11470
+ ...meta.description?.trim() ? { description: meta.description.trim() } : {},
11471
+ ...meta.author?.trim() ? { author: meta.author.trim() } : {},
11472
+ ...meta.preview !== undefined ? { preview: meta.preview } : {},
11473
+ ...meta.tokensFile?.trim() ? { tokensFile: meta.tokensFile.trim() } : {},
11474
+ ...styles.length > 0 ? { styles } : {},
11475
+ carries: extensionCarries(meta),
11476
+ dir
11477
+ };
11478
+ }
11479
+ async function describeEntry(projectDir, raw) {
11480
+ const warnings = [];
11481
+ let parsed = null;
11453
11482
  try {
11454
- await ensureGitignoreHasDist(projectDir);
11455
- } catch (e) {
11456
- throw new CreateProjectErrorImpl("scaffold-io", `Could not write .gitignore: ${e instanceof Error ? e.message : String(e)}`);
11483
+ parsed = parseExtensionSpecifier(raw.use);
11484
+ } catch (error2) {
11485
+ warnings.push(error2 instanceof Error ? error2.message : String(error2));
11457
11486
  }
11458
- const manifestPath = MANIFEST_FILENAMES.map((name2) => path17.join(projectDir, name2)).find(existsSync12) ?? path17.join(projectDir, MANIFEST_FILENAMES[0]);
11459
- const author = (options.author ?? "").trim() || DEFAULT_AUTHOR;
11460
- const substitutions = {
11461
- "{{TITLE}}": escapeYamlScalar(name),
11462
- "{{AUTHOR}}": escapeYamlScalar(author)
11487
+ const common = {
11488
+ use: raw.use,
11489
+ enabled: raw.enabled,
11490
+ ...raw.export ? { export: raw.export } : {}
11463
11491
  };
11464
- let openFile = path17.join(projectDir, "chapter-01.md");
11465
- try {
11466
- await fillTemplateFile(manifestPath, substitutions);
11467
- if (!customTemplateDir) {
11468
- await addBuiltInStyleSet(projectDir, starterThemeId);
11469
- }
11470
- if (preset) {
11471
- const { doc, file } = await loadManifestDoc(projectDir);
11472
- doc.set("preset", preset);
11473
- doc.set("targets", doc.createNode(targets ?? []));
11474
- if (options.customPage) {
11475
- const page = {
11476
- width: options.customPage.width,
11477
- height: options.customPage.height
11478
- };
11479
- if (options.customPage.tolerance !== undefined) {
11480
- page.tolerance = options.customPage.tolerance;
11481
- }
11482
- doc.set("page", doc.createNode(page));
11483
- }
11484
- await writeManifestDoc(file, doc);
11485
- }
11486
- const firstSource = await firstSourceFile(manifestPath);
11487
- if (firstSource)
11488
- openFile = path17.join(projectDir, firstSource);
11489
- await fillTemplateFile(openFile, { "{{TITLE}}": name }).catch(() => {});
11490
- } catch (e) {
11491
- throw new CreateProjectErrorImpl("scaffold-io", `Could not finalise the project files: ${e instanceof Error ? e.message : String(e)}`);
11492
+ const withWarnings = (entry) => warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
11493
+ if (!parsed) {
11494
+ return withWarnings({ ...common, kind: "npm", name: raw.use, label: raw.use, carries: NO_CARRIES });
11492
11495
  }
11493
- const requested = options.versionHistory ?? "local-git";
11494
- let versionHistory = "none";
11495
- let versionHistoryError;
11496
- if (requested === "local-git") {
11496
+ if (parsed.kind === "bundled") {
11497
+ const bundledName = parsed.name;
11498
+ const rec = RECOMMENDED_EXTENSIONS.find((r) => r.use === bundledName);
11499
+ return withWarnings({
11500
+ ...common,
11501
+ kind: "bundled",
11502
+ name: bundledName,
11503
+ label: rec?.label ?? bundledName,
11504
+ ...rec ? { description: rec.description } : {},
11505
+ carries: { ...NO_CARRIES, markdown: true }
11506
+ });
11507
+ }
11508
+ if (parsed.kind === "path") {
11509
+ const abs = path18.resolve(projectDir, parsed.path);
11510
+ const base2 = { ...common, kind: "path", name: parsed.path };
11511
+ let info2;
11497
11512
  try {
11498
- const { providerFor } = await import("./source-provider-51g6mt6y.js");
11499
- const provider = providerFor({ type: "local-folder", path: projectDir });
11500
- await provider.initVersionHistory({
11501
- projectDir,
11502
- authorName: options.author?.trim() || undefined,
11503
- initialMessage: "Created project"
11504
- });
11505
- versionHistory = "local-git";
11506
- } catch (e) {
11507
- versionHistory = "none";
11508
- const { findEnclosingRepoDir } = await import("./project-source-ekcyp63q.js");
11509
- const enclosingRepoDir = await findEnclosingRepoDir(projectDir);
11510
- versionHistoryError = enclosingRepoDir ? `This book was added to ${path17.basename(enclosingRepoDir)}'s shared version history; save a snapshot to record it.` : e instanceof Error ? e.message : String(e);
11513
+ info2 = await stat6(abs);
11514
+ } catch {
11515
+ warnings.push(`Not found: ${abs}`);
11516
+ return withWarnings({ ...base2, label: path18.basename(abs), carries: NO_CARRIES });
11517
+ }
11518
+ if (info2.isDirectory()) {
11519
+ return withWarnings(await describeFolder(abs, base2, prettify(path18.basename(abs))));
11511
11520
  }
11521
+ return withWarnings({
11522
+ ...base2,
11523
+ label: path18.basename(abs),
11524
+ carries: { ...NO_CARRIES, markdown: true }
11525
+ });
11512
11526
  }
11513
- const result = {
11514
- projectDir,
11515
- manifestPath,
11516
- openFile,
11517
- versionHistory
11527
+ const base = {
11528
+ ...common,
11529
+ kind: "npm",
11530
+ name: parsed.name,
11531
+ ...parsed.version ? { version: parsed.version } : {}
11518
11532
  };
11519
- if (versionHistoryError !== undefined) {
11520
- result.versionHistoryError = versionHistoryError;
11521
- }
11522
- return result;
11523
- }
11524
- async function ensureGitignoreHasDist(projectDir) {
11525
- const gitignorePath = path17.join(projectDir, ".gitignore");
11526
- let existing = null;
11527
- try {
11528
- existing = await readFile27(gitignorePath, "utf8");
11529
- } catch {}
11530
- if (existing === null) {
11531
- await writeFile6(gitignorePath, `dist/
11532
- `, "utf8");
11533
- return;
11533
+ if (!parsed.version) {
11534
+ warnings.push(`Not pinned — run \`gutterpress ext add ${parsed.name}\` to install it and pin an exact version.`);
11535
+ return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11534
11536
  }
11535
- const alreadyIgnoresDist = existing.split(`
11536
- `).some((line) => /^\/?dist\/?$/.test(line.trim()));
11537
- if (alreadyIgnoresDist)
11538
- return;
11539
- const needsLeadingNewline = existing.length > 0 && !existing.endsWith(`
11540
- `);
11541
- await writeFile6(gitignorePath, existing + (needsLeadingNewline ? `
11542
- ` : "") + `dist/
11543
- `, "utf8");
11544
- }
11545
- async function firstSourceFile(manifestPath) {
11546
- let text;
11537
+ let installRoot = null;
11547
11538
  try {
11548
- text = await readFile27(manifestPath, "utf8");
11549
- } catch {
11550
- return;
11551
- }
11552
- const filesIdx = text.search(/^\s*files:\s*$/m);
11553
- if (filesIdx === -1)
11554
- return;
11555
- const rest = text.slice(filesIdx);
11556
- const m = rest.match(/^\s*-\s*"?([^"\n]+?)"?\s*$/m);
11557
- return m ? m[1].trim() : undefined;
11558
- }
11559
- async function fillTemplateFile(filePath, substitutions) {
11560
- let text = await readFile27(filePath, "utf8");
11561
- for (const [token, value] of Object.entries(substitutions)) {
11562
- text = text.split(token).join(value);
11539
+ installRoot = await resolveVendoredPluginInstallRoot(projectDir, parsed.name, parsed.version);
11540
+ } catch (error2) {
11541
+ warnings.push(error2 instanceof Error ? error2.message : String(error2));
11563
11542
  }
11564
- await writeFile6(filePath, text, "utf8");
11565
- }
11566
-
11567
- // src/lib/extension-scaffold.ts
11568
- import { access as access2, mkdir as mkdir9, readFile as readFile28, writeFile as writeFile7 } from "node:fs/promises";
11569
- import { constants as FS2 } from "node:fs";
11570
- import path18 from "node:path";
11571
- var EXTENSION_KINDS = ["plugin", "theme"];
11572
- var RESERVED_PREFIX = "gp-";
11573
- var TEMPLATE_FILES = {
11574
- plugin: [
11575
- ["package.json", "package.json"],
11576
- ["README.md", "README.md"],
11577
- ["plugin.js.tpl", "plugin.js"],
11578
- ["styles/plugin.css", "styles/plugin.css"],
11579
- ["snippets/term-box.md", "snippets/term-box.md"],
11580
- ["test/fixture.md", "test/fixture.md"],
11581
- ["test/expected.html", "test/expected.html"],
11582
- ["test/plugin.test.js.tpl", "test/plugin.test.js"]
11583
- ],
11584
- theme: [
11585
- ["package.json", "package.json"],
11586
- ["README.md", "README.md"],
11587
- ["components.yaml", "components.yaml"],
11588
- ["snippets/callout.md", "snippets/callout.md"],
11589
- ["styles/tokens.css", "styles/tokens.css"],
11590
- ["styles/base.css", "styles/base.css"],
11591
- ["styles/components.css", "styles/components.css"],
11592
- ["styles/page-templates.css", "styles/page-templates.css"],
11593
- ["styles/page-rules.css", "styles/page-rules.css"],
11594
- ["styles/book.css", "styles/book.css"]
11595
- ]
11596
- };
11597
- var OPEN_FILE = {
11598
- plugin: "plugin.js",
11599
- theme: "styles/tokens.css"
11600
- };
11601
- var DEFAULT_DESCRIPTION = {
11602
- plugin: "A Gutterpress plugin.",
11603
- theme: "A Gutterpress theme."
11604
- };
11605
- var DEFAULT_AUTHOR2 = "Anonymous";
11606
-
11607
- class ExtensionScaffoldError extends Error {
11608
- code;
11609
- constructor(code, message) {
11610
- super(message);
11611
- this.name = "CreateProjectError";
11612
- this.code = code;
11543
+ if (!installRoot) {
11544
+ warnings.push(`Not installed — run \`gutterpress ext add ${raw.use}\`.`);
11545
+ return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11613
11546
  }
11614
- }
11615
- function resolveExtensionPrefix(slug, requested) {
11616
- const raw = (requested ?? `${slug}-`).trim();
11617
- const prefix = raw.endsWith("-") ? raw : `${raw}-`;
11618
- if (!/^[a-z][a-z0-9-]*-$/.test(prefix)) {
11619
- throw new ExtensionScaffoldError("invalid-name", `"${raw}" is not a usable prefix. Use lower-case letters, digits and hyphens, ` + `starting with a letter — for example "${slug}-" or "fn-".`);
11547
+ const pkgDir = vendoredNpmPluginPackageDir(installRoot, parsed.name);
11548
+ if (!existsSync12(path18.join(pkgDir, EXTENSION_MANIFEST_FILENAME))) {
11549
+ return withWarnings({ ...base, label: parsed.name, carries: { ...NO_CARRIES, markdown: true } });
11620
11550
  }
11621
- if (prefix === RESERVED_PREFIX) {
11622
- throw new ExtensionScaffoldError("invalid-name", `"${RESERVED_PREFIX}" is reserved for Gutterpress core's own classes and custom ` + `properties. Taking it would silently override them — choose your own prefix.`);
11551
+ const described = await describeFolder(pkgDir, base, parsed.name, (name) => name);
11552
+ return withWarnings({
11553
+ ...described,
11554
+ carries: { ...described.carries, markdown: true }
11555
+ });
11556
+ }
11557
+ async function listProjectExtensions(projectDir) {
11558
+ const { doc } = await loadManifestDoc(projectDir);
11559
+ const seq = doc.get("extensions", true);
11560
+ if (!isSeq3(seq))
11561
+ return [];
11562
+ const out = [];
11563
+ for (const item of seq.items) {
11564
+ const raw = rawEntry(item);
11565
+ if (!raw)
11566
+ continue;
11567
+ out.push(await describeEntry(projectDir, raw));
11623
11568
  }
11624
- return prefix;
11569
+ return out;
11625
11570
  }
11626
- function identifierFor(slug) {
11627
- const camel = slug.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase());
11628
- return /^[0-9]/.test(camel) ? `_${camel}` : camel;
11571
+ async function describeExtension(projectDir, use) {
11572
+ return (await listProjectExtensions(projectDir)).find((e) => sameExtension(e.use, use)) ?? null;
11629
11573
  }
11630
- function escapeJsonScalar(value) {
11631
- return JSON.stringify(value).slice(1, -1);
11574
+ function extensionConfigFor(entry) {
11575
+ return {
11576
+ use: entry.use,
11577
+ ...entry.kind === "path" ? { path: entry.name } : { name: entry.name, ...entry.version ? { version: entry.version } : {} },
11578
+ ...entry.export ? { export: entry.export } : {},
11579
+ options: {}
11580
+ };
11632
11581
  }
11633
- async function scaffoldExtension(options) {
11634
- const kind = options.kind;
11635
- if (kind !== "plugin" && kind !== "theme") {
11636
- throw new ExtensionScaffoldError("invalid-name", `Unknown extension kind "${String(kind)}". Choose one of: ${EXTENSION_KINDS.join(", ")}.`);
11637
- }
11638
- const name = (options.name ?? "").trim();
11639
- if (!name) {
11640
- throw new ExtensionScaffoldError("invalid-name", "A name is required.");
11641
- }
11642
- const slug = (options.folderName ?? slugify(name)).trim();
11643
- if (!slug || slug !== slugify(slug)) {
11644
- throw new ExtensionScaffoldError("invalid-name", `Could not derive a valid folder name from "${name}".`);
11645
- }
11646
- const prefix = resolveExtensionPrefix(slug, options.prefix);
11647
- const parentDir = options.parentDir;
11648
- if (!parentDir || !path18.isAbsolute(parentDir)) {
11649
- throw new ExtensionScaffoldError("parent-not-writable", "The save location must be an absolute path.");
11582
+ async function setExtensionEnabled(projectDir, use, enabled) {
11583
+ return withMutationLock(projectDir, async () => {
11584
+ const { doc, file } = await loadManifestDoc(projectDir);
11585
+ const seq = doc.get("extensions", true);
11586
+ const idx = isSeq3(seq) ? indexOfUse(seq, use) : -1;
11587
+ if (!isSeq3(seq) || idx < 0)
11588
+ throw new Error(`Extension "${use}" is not in the manifest.`);
11589
+ const item = seq.items[idx];
11590
+ if (enabled) {
11591
+ if (isMap2(item)) {
11592
+ item.delete("enabled");
11593
+ if (item.items.length === 1 && typeof item.get("use") === "string") {
11594
+ seq.items[idx] = new Scalar(item.get("use"));
11595
+ }
11596
+ }
11597
+ } else if (isMap2(item)) {
11598
+ item.set("enabled", false);
11599
+ } else {
11600
+ const map = new YAMLMap(doc.schema);
11601
+ map.set("use", entryUse(item));
11602
+ map.set("enabled", false);
11603
+ seq.items[idx] = map;
11604
+ }
11605
+ await writeManifestDoc(file, doc);
11606
+ });
11607
+ }
11608
+ async function removeExtension(projectDir, use) {
11609
+ return withMutationLock(projectDir, async () => {
11610
+ const { doc, file } = await loadManifestDoc(projectDir);
11611
+ const seq = doc.get("extensions", true);
11612
+ const idx = isSeq3(seq) ? indexOfUse(seq, use) : -1;
11613
+ if (!isSeq3(seq) || idx < 0)
11614
+ throw new Error(`Extension "${use}" is not in the manifest.`);
11615
+ const written = entryUse(seq.items[idx]);
11616
+ seq.items.splice(idx, 1);
11617
+ if (seq.items.length === 0)
11618
+ doc.delete("extensions");
11619
+ await writeManifestDoc(file, doc);
11620
+ let parsed = null;
11621
+ try {
11622
+ parsed = parseExtensionSpecifier(written);
11623
+ } catch {
11624
+ return;
11625
+ }
11626
+ if (parsed.kind === "npm" && parsed.version) {
11627
+ await rm4(vendoredNpmPluginRoot(projectDir, parsed.name, parsed.version), {
11628
+ recursive: true,
11629
+ force: true
11630
+ });
11631
+ clearVendoredPluginResolver(projectDir, parsed.name, parsed.version);
11632
+ }
11633
+ });
11634
+ }
11635
+ async function upsertEntry(projectDir, use, exportName) {
11636
+ const { doc, file } = await loadManifestDoc(projectDir);
11637
+ const seq = ensureSeq(doc, "extensions");
11638
+ const idx = indexOfUse(seq, use);
11639
+ if (idx >= 0) {
11640
+ const item = seq.items[idx];
11641
+ if (isMap2(item)) {
11642
+ item.set("use", use);
11643
+ if (exportName)
11644
+ item.set("export", exportName);
11645
+ else
11646
+ item.delete("export");
11647
+ if (item.items.length === 1)
11648
+ seq.items[idx] = new Scalar(use);
11649
+ } else if (exportName) {
11650
+ const map = new YAMLMap(doc.schema);
11651
+ map.set("use", use);
11652
+ map.set("export", exportName);
11653
+ seq.items[idx] = map;
11654
+ } else {
11655
+ seq.items[idx] = new Scalar(use);
11656
+ }
11657
+ } else if (exportName) {
11658
+ const map = new YAMLMap(doc.schema);
11659
+ map.set("use", use);
11660
+ map.set("export", exportName);
11661
+ seq.add(map);
11662
+ } else {
11663
+ seq.add(new Scalar(use));
11650
11664
  }
11665
+ await writeManifestDoc(file, doc);
11666
+ }
11667
+ async function addPathExtension(projectDir, abs, exportName) {
11651
11668
  try {
11652
- await access2(parentDir, FS2.W_OK);
11669
+ await stat6(abs);
11653
11670
  } catch {
11654
- throw new ExtensionScaffoldError("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
11671
+ throw new Error(`Extension not found: ${abs}`);
11655
11672
  }
11656
- const extensionDir = path18.join(parentDir, slug);
11657
- let targetExists = true;
11658
- try {
11659
- await access2(extensionDir, FS2.F_OK);
11660
- } catch {
11661
- targetExists = false;
11673
+ const use = manifestPathFor(projectDir, abs);
11674
+ await loadPlugin(extensionConfigFor({ use, kind: "path", name: use, export: exportName }), projectDir);
11675
+ await upsertEntry(projectDir, use, exportName);
11676
+ return await describeExtension(projectDir, use);
11677
+ }
11678
+ async function addExtension(projectDir, specifier, options = {}) {
11679
+ return withMutationLock(projectDir, () => addExtensionUnlocked(projectDir, specifier, options));
11680
+ }
11681
+ async function addExtensionUnlocked(projectDir, specifier, options) {
11682
+ const input = specifier.trim();
11683
+ const exportName = options.exportName?.trim() || undefined;
11684
+ if (path18.isAbsolute(input))
11685
+ return addPathExtension(projectDir, input, exportName);
11686
+ const parsed = parseExtensionSpecifier(input);
11687
+ if (parsed.kind === "path") {
11688
+ return addPathExtension(projectDir, path18.resolve(projectDir, parsed.path), exportName);
11662
11689
  }
11663
- if (targetExists) {
11664
- throw new ExtensionScaffoldError("target-exists", `A folder named "${slug}" already exists here. Choose a different name or location.`);
11690
+ if (parsed.kind === "bundled") {
11691
+ if (exportName)
11692
+ throw new Error(`Bundled extension "${parsed.name}" does not take a named export.`);
11693
+ await upsertEntry(projectDir, parsed.name);
11694
+ return await describeExtension(projectDir, parsed.name);
11665
11695
  }
11666
- const author = (options.author ?? "").trim() || DEFAULT_AUTHOR2;
11667
- const description = (options.description ?? "").trim() || DEFAULT_DESCRIPTION[kind];
11668
- const plain = {
11669
- "{{NAME}}": name,
11670
- "{{SLUG}}": slug,
11671
- "{{PREFIX}}": prefix,
11672
- "{{AUTHOR}}": author,
11673
- "{{DESCRIPTION}}": description,
11674
- "{{IDENT}}": identifierFor(slug)
11675
- };
11676
- const jsonSafe = Object.fromEntries(Object.entries(plain).map(([token, value]) => [token, escapeJsonScalar(value)]));
11677
- const entries = TEMPLATE_FILES[kind];
11678
- const written = [];
11696
+ const existing = await describeExtension(projectDir, parsed.name);
11697
+ const chosenExport = exportName ?? existing?.export;
11698
+ const installed = await installNpmPlugin(projectDir, input, options);
11699
+ const use = pinnedNpmSpecifier(installed.name, installed.version);
11679
11700
  try {
11680
- await mkdir9(extensionDir, { recursive: true });
11681
- for (const [assetName, relDest] of entries) {
11682
- const src = await getAssetPath(`extension-templates/${kind}/${assetName}`);
11683
- const dest = path18.join(extensionDir, relDest);
11684
- await mkdir9(path18.dirname(dest), { recursive: true });
11685
- const text = await readFile28(src, "utf8");
11686
- const table = relDest.endsWith(".json") ? jsonSafe : plain;
11687
- await writeFile7(dest, substitute(text, table), "utf8");
11688
- written.push(relDest);
11701
+ await loadPlugin(extensionConfigFor({
11702
+ use,
11703
+ kind: "npm",
11704
+ name: installed.name,
11705
+ version: installed.version,
11706
+ export: chosenExport
11707
+ }), projectDir);
11708
+ await options.__testFailBeforeManifestCommit?.();
11709
+ await upsertEntry(projectDir, use, chosenExport);
11710
+ } catch (cause) {
11711
+ try {
11712
+ await rollbackNpmPluginInstall(installed);
11713
+ clearVendoredPluginResolver(projectDir, installed.name, installed.version);
11714
+ } catch (rollbackError) {
11715
+ throw new Error(`Extension install failed and its previous vendor tree could not be restored: ` + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, { cause });
11689
11716
  }
11690
- } catch (e) {
11691
- throw new ExtensionScaffoldError("scaffold-io", `Could not create the ${kind} files: ${e instanceof Error ? e.message : String(e)}`);
11717
+ throw new Error(`Downloaded ${use}, but it is not a loadable markdown-it plugin: ` + `${cause instanceof Error ? cause.message : String(cause)}`, { cause });
11692
11718
  }
11693
- return {
11694
- extensionDir,
11695
- manifestPath: path18.join(extensionDir, "package.json"),
11696
- kind,
11697
- slug,
11698
- prefix,
11699
- openFile: path18.join(extensionDir, OPEN_FILE[kind]),
11700
- files: written
11701
- };
11719
+ const warnings = [...installed.warnings];
11720
+ await finalizeNpmPluginInstall(installed).catch((error2) => {
11721
+ warnings.push(`The extension was installed, but an old backup could not be removed: ` + `${error2 instanceof Error ? error2.message : String(error2)}`);
11722
+ });
11723
+ const entry = await describeExtension(projectDir, use);
11724
+ return warnings.length > 0 ? { ...entry, warnings: [...entry.warnings ?? [], ...warnings] } : entry;
11702
11725
  }
11703
- function substitute(text, table) {
11704
- let out = text;
11705
- for (const [token, value] of Object.entries(table)) {
11706
- out = out.split(token).join(value);
11726
+ async function uniqueExtensionId(projectDir, base) {
11727
+ const slug = slugify(base) || "extension";
11728
+ let candidate = slug;
11729
+ for (let n = 2;existsSync12(path18.join(projectDir, EXTENSIONS_DIR, candidate)); n++) {
11730
+ candidate = `${slug}-${n}`;
11707
11731
  }
11708
- return out;
11732
+ return candidate;
11733
+ }
11734
+ async function addBuiltInStyleSet(projectDir, id) {
11735
+ if (!BUILT_IN_STYLE_SET_IDS.includes(id)) {
11736
+ throw new Error(`Unknown built-in look "${id}" (one of: ${BUILT_IN_STYLE_SET_IDS.join(", ")}).`);
11737
+ }
11738
+ return withMutationLock(projectDir, async () => {
11739
+ const dest = path18.join(projectDir, EXTENSIONS_DIR, id);
11740
+ if (!existsSync12(dest)) {
11741
+ const src = path18.join(await getAssetsDir(), "themes", id);
11742
+ await mkdir9(path18.dirname(dest), { recursive: true });
11743
+ await cp3(src, dest, { recursive: true });
11744
+ }
11745
+ return addExtensionUnlocked(projectDir, `./${EXTENSIONS_DIR}/${id}`, {});
11746
+ });
11709
11747
  }
11710
-
11711
- // src/lib/project-templates.ts
11712
- import { isMap as isMap2, isScalar as isScalar2, isSeq as isSeq3, parseDocument as parseDocument2 } from "yaml";
11713
- var BUILT_IN_TEMPLATE_IDS = [
11714
- "book",
11715
- "zine",
11716
- "technical"
11717
- ];
11718
- var SKIP_ENTRIES = new Set([
11719
- ".git",
11720
- "node_modules",
11721
- "out",
11722
- "dist",
11723
- "build",
11724
- ".gutterpress",
11725
- ".DS_Store"
11726
- ]);
11727
11748
 
11728
11749
  // src/lib/extension-import.ts
11729
11750
  import { existsSync as existsSync13 } from "node:fs";
@@ -14242,4 +14263,4 @@ async function runLint(opts = {}) {
14242
14263
  }
14243
14264
  return { ok: true, riskyCount, containmentCount, filesLinted: linted };
14244
14265
  }
14245
- export { isPathSpecifier, parseExtensionSpecifier, PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, loadManifestWithPath, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, BUILT_IN_STYLE_SET_IDS, listProjectExtensions, setExtensionEnabled, removeExtension, addExtension, addBuiltInStyleSet, scaffoldProject, EXTENSION_KINDS, scaffoldExtension, BUILT_IN_TEMPLATE_IDS, importExtensionFromFile, importExtensionFromUrl, searchNpmExtensions, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listPublishProviders, publishProviderFor, resolvePublishFormat, runPublish, connectPublishProvider, disconnectPublishCredential, runLint };
14266
+ export { isPathSpecifier, parseExtensionSpecifier, PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, loadManifestWithPath, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, scaffoldProject, EXTENSION_KINDS, scaffoldExtension, BUILT_IN_TEMPLATE_IDS, BUILT_IN_STYLE_SET_IDS, listProjectExtensions, setExtensionEnabled, removeExtension, addExtension, addBuiltInStyleSet, importExtensionFromFile, importExtensionFromUrl, searchNpmExtensions, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listPublishProviders, publishProviderFor, resolvePublishFormat, runPublish, connectPublishProvider, disconnectPublishCredential, runLint };