gutterpress 0.11.17-alpha.2 → 0.11.17-alpha.3

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 (49) hide show
  1. package/dist/api/index.d.ts +4 -0
  2. package/dist/api/index.js +10 -2
  3. package/dist/{build-1p999cmg.js → build-394c1ee1.js} +4 -4
  4. package/dist/checks/pdf/index.d.ts +1 -0
  5. package/dist/checks/pdf/signature.d.ts +3 -0
  6. package/dist/{cli-70xgehq2.js → cli-3p1e95zc.js} +1 -1
  7. package/dist/{cli-6z5zthzt.js → cli-9nba5ve6.js} +562 -211
  8. package/dist/{cli-ppc0vz4b.js → cli-d57bsfpb.js} +1 -1
  9. package/dist/{cli-gymfg04q.js → cli-jyy9pqf3.js} +15 -10
  10. package/dist/cli.js +12 -12
  11. package/dist/{doctor-reqpnn3h.js → doctor-c459qv5j.js} +2 -2
  12. package/dist/engine/compiler/build.d.ts +3 -2
  13. package/dist/engine/compiler/postprocess.d.ts +6 -4
  14. package/dist/engine/shared/synthesis.d.ts +29 -5
  15. package/dist/{engine-5jzhjjvv.js → engine-3xqgm82d.js} +2 -2
  16. package/dist/{engine-078ggczp.js → engine-zdb41ay1.js} +1 -1
  17. package/dist/{ext-dsbfejt3.js → ext-ha7gg8h9.js} +4 -4
  18. package/dist/{gutterpress-viewer-zkv2stfy.js → gutterpress-viewer-1n793gwd.js} +5 -3
  19. package/dist/{index-c275p061.js → index-bscfmg5r.js} +1714 -1054
  20. package/dist/{index-5dcj1vts.js → index-hrx0gfkj.js} +23 -684
  21. package/dist/index-q1xqxrh9.js +949 -0
  22. package/dist/index.js +11 -3
  23. package/dist/lib/black-text-gray.d.ts +38 -8
  24. package/dist/lib/build-fingerprint.d.ts +2 -0
  25. package/dist/lib/engine.d.ts +2 -1
  26. package/dist/lib/lint-rules.d.ts +9 -0
  27. package/dist/lib/markdown/assemble.d.ts +3 -5
  28. package/dist/lib/markdown/index.d.ts +3 -8
  29. package/dist/lib/markdown/renderer.d.ts +9 -1
  30. package/dist/lib/page-size.d.ts +66 -0
  31. package/dist/lib/pdf-inspect.d.ts +6 -0
  32. package/dist/lib/printsafe.d.ts +8 -1
  33. package/dist/lib/project-scaffold.d.ts +3 -9
  34. package/dist/lib/remote-auth/github-repos.d.ts +7 -0
  35. package/dist/lib/remote-auth/online-backup.d.ts +51 -0
  36. package/dist/lib/snippets.d.ts +3 -0
  37. package/dist/{manifest.schema-40sfq49z.json → manifest.schema-gh0jjve9.json} +14 -2
  38. package/dist/{new-m5y2wepr.js → new-kky3z7kv.js} +4 -4
  39. package/dist/{plugin.js-m4a7m3t4.tpl → plugin.js-cswq7wvd.tpl} +4 -1
  40. package/dist/{plugin.test.js-6n8ct5e3.tpl → plugin.test.js-xgxrd8hf.tpl} +4 -1
  41. package/dist/{preflight-bwpqwwny.js → preflight-t8v6szq6.js} +4 -4
  42. package/dist/preview/file-watcher.d.ts +2 -4
  43. package/dist/{preview-62y7qhkp.js → preview-cxab4y5x.js} +4 -4
  44. package/dist/{publish-k2zyt3x6.js → publish-s2mtenb5.js} +4 -4
  45. package/dist/render.js +30 -5
  46. package/dist/schema/manifest.types.d.ts +17 -0
  47. package/dist/{validate-fgwwxtbn.js → validate-xaajdw4x.js} +4 -4
  48. package/package.json +1 -1
  49. package/dist/index-eyaj6nkc.js +0 -267
@@ -3,8 +3,10 @@ import {
3
3
  EXIT_CODES,
4
4
  MARGIN_BOX_IGNORED_PROPERTIES,
5
5
  getAssetPath,
6
- getAssetsDir
7
- } from "./index-eyaj6nkc.js";
6
+ getAssetsDir,
7
+ lastPageMode,
8
+ parseSize
9
+ } from "./index-q1xqxrh9.js";
8
10
  import {
9
11
  INSTALL_HINTS,
10
12
  execCapture,
@@ -140,7 +142,7 @@ var DTRPG_PRESET = {
140
142
  source: {
141
143
  files: null
142
144
  },
143
- print: { signature: 4, reserveLastPage: true },
145
+ print: { signature: 4, reserveLastPage: true, lastPage: undefined },
144
146
  pdfx: {
145
147
  flavor: "x1a",
146
148
  icc: "profiles/CGATS21_CRPC1.icc",
@@ -158,7 +160,8 @@ var DTRPG_PRESET = {
158
160
  },
159
161
  lint: {
160
162
  enabled: true,
161
- configPath: null
163
+ configPath: null,
164
+ rules: {}
162
165
  },
163
166
  validate: {
164
167
  enabled: true,
@@ -215,7 +218,7 @@ var BOOK_PRESET = {
215
218
  source: {
216
219
  files: null
217
220
  },
218
- print: { signature: 1, reserveLastPage: false },
221
+ print: { signature: 1, reserveLastPage: false, lastPage: undefined },
219
222
  pdfx: {
220
223
  flavor: "x1a",
221
224
  icc: "profiles/CGATS21_CRPC1.icc",
@@ -233,7 +236,8 @@ var BOOK_PRESET = {
233
236
  },
234
237
  lint: {
235
238
  enabled: true,
236
- configPath: null
239
+ configPath: null,
240
+ rules: {}
237
241
  },
238
242
  validate: {
239
243
  enabled: true,
@@ -606,6 +610,10 @@ function resolveWithPreset(cliOverrides, manifest, preset) {
606
610
  if (legacyPlugins !== undefined) {
607
611
  throw new UsageError(pluginsRemovedMessage(legacyPlugins));
608
612
  }
613
+ const lastPage = c.print?.lastPage ?? m.print?.lastPage;
614
+ if (lastPage !== undefined && lastPage !== "blank" && lastPage !== "short-by-one") {
615
+ throw new UsageError(`Manifest field \`print.lastPage\` must be "blank" or "short-by-one", got ${JSON.stringify(lastPage)}.`);
616
+ }
609
617
  const rawExtensions = c.extensions ?? m.extensions ?? [];
610
618
  if (!Array.isArray(rawExtensions)) {
611
619
  throw new UsageError("Manifest field `extensions` must be a list of specifiers.");
@@ -622,7 +630,10 @@ function resolveWithPreset(cliOverrides, manifest, preset) {
622
630
  print: mergeShape(c.print, m.print, preset.print),
623
631
  page: preset.page ? mergeShape(c.page, m.page, preset.page) : resolveCustomPage(c, m),
624
632
  ink: mergeShape(c.ink, m.ink, preset.ink),
625
- lint: mergeShape(c.lint, m.lint, preset.lint),
633
+ lint: {
634
+ ...mergeShape(c.lint, m.lint, preset.lint),
635
+ rules: { ...preset.lint.rules, ...m.lint?.rules, ...c.lint?.rules }
636
+ },
626
637
  validate: {
627
638
  enabled: c.validate?.enabled ?? m.validate?.enabled ?? preset.validate.enabled,
628
639
  checks: { ...preset.validate.checks, ...m.validate?.checks, ...c.validate?.checks },
@@ -1037,7 +1048,7 @@ function resolveContainerShape(name, pluginName, decl) {
1037
1048
  throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`section\` that is not a boolean.`);
1038
1049
  }
1039
1050
  if (decl.section) {
1040
- for (const field of ["tag", "label", "autoCloseAt"]) {
1051
+ for (const field of ["tag", "label", "autoCloseAt", "selfClosing"]) {
1041
1052
  if (decl[field] !== undefined) {
1042
1053
  throw new Error(`Plugin "${pluginName}"'s marker "@${name}" sets \`section: true\` and \`${field}\` — a section ` + `marker is a core @section, so \`${field}\` does not apply. Remove one of them.`);
1043
1054
  }
@@ -1094,11 +1105,26 @@ function resolveContainerShape(name, pluginName, decl) {
1094
1105
  }
1095
1106
  autoCloseAtEof = decl.autoCloseAt.includes("eof");
1096
1107
  }
1108
+ if (decl.selfClosing !== undefined && typeof decl.selfClosing !== "boolean") {
1109
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`selfClosing\` that is not a boolean.`);
1110
+ }
1111
+ if (decl.selfClosing && decl.autoCloseAt !== undefined) {
1112
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" sets \`selfClosing: true\` and \`autoCloseAt\` — a ` + `self-closing marker has no open container to auto-close. Remove one of them.`);
1113
+ }
1097
1114
  if (decl.validate !== undefined && typeof decl.validate !== "function") {
1098
1115
  throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`validate\` that is not a function.`);
1099
1116
  }
1100
1117
  const check = decl.validate ? { validate: decl.validate, validateOwner: pluginName } : {};
1101
- return { tag, classBase, variants, label, autoCloseAtEof, ...decl.section ? { section: true } : {}, ...check };
1118
+ return {
1119
+ tag,
1120
+ classBase,
1121
+ variants,
1122
+ label,
1123
+ autoCloseAtEof,
1124
+ ...decl.section ? { section: true } : {},
1125
+ ...decl.selfClosing ? { selfClosing: true } : {},
1126
+ ...check
1127
+ };
1102
1128
  }
1103
1129
  function resolveMarkerDeclaration(name, rawDecl, rawRegistry, originOf) {
1104
1130
  const pluginName = originOf.get(name);
@@ -1357,7 +1383,8 @@ function plugin(md, pluginOptions = {}) {
1357
1383
  addClasses(t, baseClass, meta.attrs && meta.attrs.class ? meta.attrs.class : "");
1358
1384
  attachDataAttrs(t, decl.baseKind, variant, meta.attrs || {});
1359
1385
  out.push(t);
1360
- declaredFrames.push({ kind: decl.baseKind, tag: decl.tag, decl, line: meta.__line });
1386
+ if (!decl.selfClosing)
1387
+ declaredFrames.push({ kind: decl.baseKind, tag: decl.tag, decl, line: meta.__line });
1361
1388
  if (decl.label) {
1362
1389
  const value = meta.attrs && meta.attrs[decl.label.attr];
1363
1390
  if (value) {
@@ -1368,6 +1395,8 @@ function plugin(md, pluginOptions = {}) {
1368
1395
  t.meta.labelled = true;
1369
1396
  }
1370
1397
  }
1398
+ if (decl.selfClosing)
1399
+ out.push(new state.Token("layout_component_close", decl.tag, -1));
1371
1400
  }
1372
1401
  const stack = {
1373
1402
  frames: [],
@@ -1646,7 +1675,7 @@ function plugin(md, pluginOptions = {}) {
1646
1675
  continue;
1647
1676
  }
1648
1677
  if (!closeDeclaredFrame(closeDecl.baseKind)) {
1649
- warn(state.env, line, "declared_marker_close_without_open", `@${kind} used without an open @${closeDecl.baseKind}; ignoring marker.`, meta);
1678
+ warn(state.env, line, "declared_marker_close_without_open", closeDecl.selfClosing ? `@${kind} is not a marker: @${closeDecl.baseKind} is self-closing and has nothing to close; ignoring marker.` : `@${kind} used without an open @${closeDecl.baseKind}; ignoring marker.`, meta);
1650
1679
  }
1651
1680
  continue;
1652
1681
  }
@@ -2859,7 +2888,8 @@ ${projectCss.trim()}` : null
2859
2888
  `);
2860
2889
  const preloadTags = (opts.preloadImages ?? []).map((href) => `
2861
2890
  <link rel="preload" as="image" href="${href}">`).join("");
2862
- const printAttrs = (opts.print?.signature && opts.print.signature > 1 ? ` data-gp-signature="${opts.print.signature}"` : "") + (opts.print?.reserveLastPage ? " data-gp-reserve-last-page" : "");
2891
+ const lastPage = opts.print && lastPageMode(opts.print);
2892
+ const printAttrs = (opts.print?.signature && opts.print.signature > 1 ? ` data-gp-signature="${opts.print.signature}"` : "") + (lastPage ? ` data-gp-last-page="${lastPage}"` : "");
2863
2893
  return `<!DOCTYPE html>
2864
2894
  <html lang="en"${printAttrs}>
2865
2895
  <head>
@@ -5641,7 +5671,7 @@ import git from "isomorphic-git";
5641
5671
  // package.json
5642
5672
  var package_default = {
5643
5673
  name: "gutterpress",
5644
- version: "0.11.17-alpha.2",
5674
+ version: "0.11.17-alpha.3",
5645
5675
  description: "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
5646
5676
  author: "itlackey",
5647
5677
  license: "MPL-2.0",
@@ -5895,7 +5925,8 @@ async function writeBuildFingerprint(input) {
5895
5925
  requestedFlavor: input.pdfx.requestedFlavor,
5896
5926
  resolvedFlavor: input.pdfx.resolvedFlavor,
5897
5927
  iccPath: input.pdfx.iccPath,
5898
- stripAnnotations: input.pdfx.stripAnnotations
5928
+ stripAnnotations: input.pdfx.stripAnnotations,
5929
+ blackText: input.pdfx.blackText ?? null
5899
5930
  },
5900
5931
  outputDir: input.recordedOutputDir ? path10.resolve(input.recordedOutputDir) : outputDir
5901
5932
  },
@@ -5982,13 +6013,64 @@ function* tokenize(s) {
5982
6013
  }
5983
6014
  }
5984
6015
  }
5985
- function rewriteBlackText(src) {
6016
+ var LARGE_TEXT_PT = 24;
6017
+ var SIZE_EPS = 0.05;
6018
+ var area = (m) => Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
6019
+
6020
+ class TextScale {
6021
+ ctm;
6022
+ font = 0;
6023
+ tm = 1;
6024
+ stack = [];
6025
+ constructor(ctm) {
6026
+ this.ctm = ctm;
6027
+ }
6028
+ get unit() {
6029
+ return this.ctm;
6030
+ }
6031
+ get size() {
6032
+ return this.font * this.tm * this.ctm;
6033
+ }
6034
+ op(op, nums) {
6035
+ switch (op) {
6036
+ case "q":
6037
+ this.stack.push([this.ctm, this.font]);
6038
+ return false;
6039
+ case "Q": {
6040
+ const p = this.stack.pop();
6041
+ if (p)
6042
+ [this.ctm, this.font] = p;
6043
+ return true;
6044
+ }
6045
+ case "cm":
6046
+ if (nums.length === 6)
6047
+ this.ctm *= area(nums);
6048
+ return true;
6049
+ case "BT":
6050
+ this.tm = 1;
6051
+ return true;
6052
+ case "Tm":
6053
+ if (nums.length === 6)
6054
+ this.tm = area(nums);
6055
+ return true;
6056
+ case "Tf":
6057
+ this.font = Math.abs(nums[nums.length - 1] ?? 0) || 0;
6058
+ return true;
6059
+ }
6060
+ return false;
6061
+ }
6062
+ }
6063
+ function rewriteBlackText(src, ctx = {}) {
5986
6064
  let fill = null;
5987
6065
  let stroke = null;
5988
6066
  const stack = [];
6067
+ const scale = new TextScale(ctx.baseScale ?? 1);
5989
6068
  let inText = false;
5990
6069
  let fillDirty = false;
5991
6070
  let strokeDirty = false;
6071
+ const spans = [];
6072
+ let lastOp = "";
6073
+ let rect = null;
5992
6074
  const edits = [];
5993
6075
  let operands = [];
5994
6076
  const rgbOf = () => {
@@ -5997,12 +6079,46 @@ function rewriteBlackText(src) {
5997
6079
  const v = operands.map((t) => Number(t.text));
5998
6080
  return v.some((x) => Number.isNaN(x)) ? null : { rgb: v, raw: operands.map((t) => t.text).join(" ") };
5999
6081
  };
6082
+ const isSmall = () => scale.size <= LARGE_TEXT_PT + SIZE_EPS;
6083
+ let fillIns = null;
6084
+ let strokeIns = null;
6085
+ const syncOne = (colour, dirty, ins, at, gray, op) => {
6086
+ if (!colour || !isNearBlack(colour.rgb))
6087
+ return [dirty, ins];
6088
+ const small = isSmall();
6089
+ if (small && !dirty) {
6090
+ const e = { start: at, end: at, text: ` ${gray}` };
6091
+ edits.push(e);
6092
+ return [true, e];
6093
+ }
6094
+ if (!small && dirty) {
6095
+ if (ins)
6096
+ ins.text = "";
6097
+ else
6098
+ edits.push({ start: at, end: at, text: ` ${colour.raw} ${op}` });
6099
+ return [false, null];
6100
+ }
6101
+ return [dirty, ins];
6102
+ };
6103
+ const sync = (at) => {
6104
+ [fillDirty, fillIns] = syncOne(fill, fillDirty, fillIns, at, "0 g", "rg");
6105
+ [strokeDirty, strokeIns] = syncOne(stroke, strokeDirty, strokeIns, at, "0 G", "RG");
6106
+ };
6107
+ const flush = (s) => {
6108
+ for (const r of s.rects) {
6109
+ if (r.thickness > 0.2 * s.maxSmall)
6110
+ continue;
6111
+ edits.push({ start: r.start, end: r.start, text: "q 0 g " }, { start: r.end, end: r.end, text: " Q" });
6112
+ }
6113
+ };
6000
6114
  for (const t of tokenize(src)) {
6001
6115
  if (!t.op) {
6002
6116
  operands.push(t);
6003
6117
  continue;
6004
6118
  }
6005
6119
  const op = t.text;
6120
+ const nums = operands.map((o) => Number(o.text));
6121
+ const sizeMayHaveChanged = scale.op(op, nums);
6006
6122
  switch (op) {
6007
6123
  case "q":
6008
6124
  stack.push([fill, stroke]);
@@ -6016,19 +6132,13 @@ function rewriteBlackText(src) {
6016
6132
  case "BT":
6017
6133
  inText = true;
6018
6134
  fillDirty = strokeDirty = false;
6019
- {
6020
- let ins = "";
6021
- if (fill && isNearBlack(fill.rgb)) {
6022
- ins += " 0 g";
6023
- fillDirty = true;
6024
- }
6025
- if (stroke && isNearBlack(stroke.rgb)) {
6026
- ins += " 0 G";
6027
- strokeDirty = true;
6028
- }
6029
- if (ins)
6030
- edits.push({ start: t.end, end: t.end, text: ins });
6031
- }
6135
+ fillIns = strokeIns = null;
6136
+ sync(t.end);
6137
+ break;
6138
+ case "Tf":
6139
+ case "Tm":
6140
+ if (inText && sizeMayHaveChanged)
6141
+ sync(t.end);
6032
6142
  break;
6033
6143
  case "ET":
6034
6144
  if (inText) {
@@ -6041,6 +6151,7 @@ function rewriteBlackText(src) {
6041
6151
  edits.push({ start: t.start, end: t.start, text: ins });
6042
6152
  }
6043
6153
  inText = false;
6154
+ fillIns = strokeIns = null;
6044
6155
  break;
6045
6156
  case "rg":
6046
6157
  case "RG": {
@@ -6050,7 +6161,7 @@ function rewriteBlackText(src) {
6050
6161
  fill = c;
6051
6162
  else
6052
6163
  stroke = c;
6053
- if (inText && c && isNearBlack(c.rgb)) {
6164
+ if (inText && c && isNearBlack(c.rgb) && isSmall()) {
6054
6165
  edits.push({ start: operands[0].start, end: t.end, text: isFill ? "0 g" : "0 G" });
6055
6166
  if (isFill)
6056
6167
  fillDirty = true;
@@ -6082,7 +6193,54 @@ function rewriteBlackText(src) {
6082
6193
  if (inText)
6083
6194
  strokeDirty = false;
6084
6195
  break;
6196
+ case "Do": {
6197
+ const name = operands[operands.length - 1]?.text;
6198
+ if (name?.startsWith("/"))
6199
+ ctx.onDo?.(name.slice(1), scale.unit);
6200
+ break;
6201
+ }
6202
+ case "BDC":
6203
+ case "BMC":
6204
+ spans.push({ hasSmall: false, maxSmall: 0, rects: [] });
6205
+ break;
6206
+ case "EMC": {
6207
+ const s = spans.pop();
6208
+ if (!s)
6209
+ break;
6210
+ const parent = spans[spans.length - 1];
6211
+ if (s.hasSmall)
6212
+ flush(s);
6213
+ else
6214
+ parent?.rects.push(...s.rects);
6215
+ if (parent && s.hasSmall) {
6216
+ parent.hasSmall = true;
6217
+ parent.maxSmall = Math.max(parent.maxSmall, s.maxSmall);
6218
+ }
6219
+ break;
6220
+ }
6221
+ case "Tj":
6222
+ case "TJ":
6223
+ case "'":
6224
+ case '"': {
6225
+ fillIns = strokeIns = null;
6226
+ const span = spans[spans.length - 1];
6227
+ if (span && inText && isSmall()) {
6228
+ span.hasSmall = true;
6229
+ span.maxSmall = Math.max(span.maxSmall, scale.size);
6230
+ }
6231
+ break;
6232
+ }
6233
+ case "re":
6234
+ rect = !inText && fill && isNearBlack(fill.rgb) && operands.length === 4 && nums.every((x) => !Number.isNaN(x)) ? { start: operands[0].start, thickness: Math.min(Math.abs(nums[2]), Math.abs(nums[3])) * scale.unit } : null;
6235
+ break;
6236
+ case "f":
6237
+ case "F":
6238
+ case "f*":
6239
+ if (rect && lastOp === "re")
6240
+ spans[spans.length - 1]?.rects.push({ ...rect, end: t.end });
6241
+ break;
6085
6242
  }
6243
+ lastOp = op;
6086
6244
  operands = [];
6087
6245
  }
6088
6246
  return applyEdits(src, edits);
@@ -6090,6 +6248,7 @@ function rewriteBlackText(src) {
6090
6248
  function applyEdits(s, edits) {
6091
6249
  if (!edits.length)
6092
6250
  return s;
6251
+ edits.sort((a, b) => a.start - b.start);
6093
6252
  let out = "";
6094
6253
  let pos = 0;
6095
6254
  for (const e of edits) {
@@ -6098,9 +6257,8 @@ function applyEdits(s, edits) {
6098
6257
  }
6099
6258
  return out + s.slice(pos);
6100
6259
  }
6101
- async function blackTextToGray(pdf) {
6102
- const { PDFDocument, PDFName, PDFRawStream, PDFArray, PDFRef, decodePDFRawStream } = await import("pdf-lib");
6103
- const doc = await PDFDocument.load(pdf, { updateMetadata: false });
6260
+ async function visitContent(doc, visit) {
6261
+ const { PDFName, PDFRawStream, PDFArray, PDFRef, PDFDict, decodePDFRawStream } = await import("pdf-lib");
6104
6262
  const ctx = doc.context;
6105
6263
  const SKIP = ["Filter", "DecodeParms", "Length"].map((k) => PDFName.of(k));
6106
6264
  let changed = false;
@@ -6119,19 +6277,15 @@ async function blackTextToGray(pdf) {
6119
6277
  ctx.assign(ref, ctx.flateStream(Buffer.from(text, "latin1"), entries));
6120
6278
  changed = true;
6121
6279
  };
6122
- for (const [ref, obj] of ctx.enumerateIndirectObjects()) {
6123
- if (!(obj instanceof PDFRawStream))
6124
- continue;
6125
- if (obj.dict.get(PDFName.of("Subtype")) !== PDFName.of("Form"))
6126
- continue;
6127
- const text = decode(obj);
6128
- if (text === null)
6129
- continue;
6130
- const next = rewriteBlackText(text);
6131
- if (next !== text)
6132
- replace(ref, obj, next);
6133
- }
6134
- for (const page of doc.getPages()) {
6280
+ const formScale = new Map;
6281
+ const formMatrixScale = (form) => {
6282
+ const m = form.dict.lookupMaybe(PDFName.of("Matrix"), PDFArray);
6283
+ const nums = m?.asArray().map((n) => Number(n.toString()));
6284
+ return nums?.length === 6 && !nums.some(Number.isNaN) ? area(nums) : 1;
6285
+ };
6286
+ const pages = doc.getPages();
6287
+ for (let p = 0;p < pages.length; p++) {
6288
+ const page = pages[p];
6135
6289
  const contents = page.node.Contents();
6136
6290
  const refs = [];
6137
6291
  if (contents instanceof PDFArray) {
@@ -6149,16 +6303,49 @@ async function blackTextToGray(pdf) {
6149
6303
  if (!refs.length || !streams.every((s) => s instanceof PDFRawStream))
6150
6304
  continue;
6151
6305
  const parts = streams.map((s) => decode(s));
6152
- if (parts.some((p) => p === null))
6306
+ if (parts.some((q) => q === null))
6153
6307
  continue;
6154
6308
  const joined = parts.join(`
6155
6309
  `);
6156
- const next = rewriteBlackText(joined);
6310
+ const xobjects = page.node.Resources()?.lookupMaybe(PDFName.of("XObject"), PDFDict);
6311
+ const next = visit(joined, {
6312
+ baseScale: 1,
6313
+ onDo: (name, scale) => {
6314
+ const ref = xobjects?.get(PDFName.of(name));
6315
+ const form = ref instanceof PDFRef ? ctx.lookup(ref) : undefined;
6316
+ if (!(ref instanceof PDFRef) || !(form instanceof PDFRawStream))
6317
+ return;
6318
+ if (form.dict.get(PDFName.of("Subtype")) !== PDFName.of("Form"))
6319
+ return;
6320
+ const at = scale * formMatrixScale(form);
6321
+ formScale.set(ref.tag, Math.min(formScale.get(ref.tag) ?? Infinity, at));
6322
+ }
6323
+ });
6157
6324
  if (next === joined)
6158
6325
  continue;
6159
6326
  replace(refs[0], streams[0], next);
6160
6327
  page.node.set(PDFName.of("Contents"), refs[0]);
6161
6328
  }
6329
+ for (const [ref, obj] of ctx.enumerateIndirectObjects()) {
6330
+ if (!(obj instanceof PDFRawStream))
6331
+ continue;
6332
+ if (obj.dict.get(PDFName.of("Subtype")) !== PDFName.of("Form"))
6333
+ continue;
6334
+ const text = decode(obj);
6335
+ if (text === null)
6336
+ continue;
6337
+ const next = visit(text, {
6338
+ baseScale: formScale.get(ref.tag) ?? 0
6339
+ });
6340
+ if (next !== text)
6341
+ replace(ref, obj, next);
6342
+ }
6343
+ return changed;
6344
+ }
6345
+ async function blackTextToGray(pdf) {
6346
+ const { PDFDocument } = await import("pdf-lib");
6347
+ const doc = await PDFDocument.load(pdf, { updateMetadata: false });
6348
+ const changed = await visitContent(doc, (src, info2) => rewriteBlackText(src, info2));
6162
6349
  return changed ? doc.save({ updateFieldAppearances: false }) : pdf;
6163
6350
  }
6164
6351
 
@@ -6766,6 +6953,32 @@ function getImagePixelSize(page, name, timeoutMs = 2000) {
6766
6953
  }
6767
6954
  });
6768
6955
  }
6956
+ var PAINT_OPS = new Set([
6957
+ "stroke",
6958
+ "closeStroke",
6959
+ "fill",
6960
+ "eoFill",
6961
+ "fillStroke",
6962
+ "eoFillStroke",
6963
+ "closeFillStroke",
6964
+ "closeEOFillStroke",
6965
+ "shadingFill",
6966
+ "paintImageXObject",
6967
+ "paintInlineImageXObject",
6968
+ "paintImageXObjectRepeat",
6969
+ "paintImageMaskXObject",
6970
+ "paintImageMaskXObjectGroup",
6971
+ "paintImageMaskXObjectRepeat",
6972
+ "paintSolidColorImageMask",
6973
+ "showText",
6974
+ "showSpacedText",
6975
+ "nextLineShowText",
6976
+ "nextLineSetSpacingShowText"
6977
+ ].map((name) => OPS[name]).filter((op) => op !== undefined));
6978
+ async function pageHasMarks(doc, pageNum) {
6979
+ const ops = await (await doc.getPage(pageNum)).getOperatorList();
6980
+ return ops.fnArray.some((fn, i) => fn === OPS.constructPath ? PAINT_OPS.has(ops.argsArray[i][0]) : PAINT_OPS.has(fn));
6981
+ }
6769
6982
  async function getImageResolutions(doc) {
6770
6983
  const { imagesByPage } = await getOpPass(doc);
6771
6984
  const out = [];
@@ -7269,8 +7482,62 @@ var check8 = {
7269
7482
  };
7270
7483
  registerCheck(check8);
7271
7484
 
7272
- // src/checks/pdf/bookmarks.ts
7485
+ // src/checks/pdf/signature.ts
7273
7486
  var check9 = {
7487
+ id: "pdf.print.signature",
7488
+ name: "Signature",
7489
+ description: "Fails when the interior's page count does not fit the print signature (a multiple of it ending on a blank page, or one short of a multiple)",
7490
+ category: "pdf",
7491
+ phase: "post-build",
7492
+ enabledWhen: (config) => config.print.signature > 1,
7493
+ async run(ctx) {
7494
+ if (!ctx.pdfPath)
7495
+ return [];
7496
+ const doc = await loadPdf(ctx.pdfPath);
7497
+ if (!doc) {
7498
+ return [inspectionFailed(check9.id, "Could not parse the PDF's page count.", { file: ctx.pdfPath })];
7499
+ }
7500
+ const size = ctx.config.print.signature;
7501
+ const pages = doc.numPages;
7502
+ if ((pages + 1) % size === 0)
7503
+ return [];
7504
+ const data = { pages, signature: size };
7505
+ if (pages % size !== 0) {
7506
+ return [
7507
+ finding(check9.id, {
7508
+ severity: "error",
7509
+ message: `Page count ${pages} does not fit a signature of ${size}: it must be a multiple of ${size}, or one short of one.`,
7510
+ file: ctx.pdfPath,
7511
+ detail: "Set `print.signature` in the manifest and rebuild; the build pads the interior for you.",
7512
+ code: "signature-page-count",
7513
+ data
7514
+ })
7515
+ ];
7516
+ }
7517
+ if (!lastPageMode(ctx.config.print))
7518
+ return [];
7519
+ try {
7520
+ if (!await pageHasMarks(doc, pages))
7521
+ return [];
7522
+ } catch {
7523
+ return [inspectionFailed(check9.id, "Could not read the last page.", { file: ctx.pdfPath })];
7524
+ }
7525
+ return [
7526
+ finding(check9.id, {
7527
+ severity: "error",
7528
+ message: `The last page (${pages}) has content, but the printer needs it blank for its own information.`,
7529
+ file: ctx.pdfPath,
7530
+ detail: "The build adds that page for you (`print.lastPage: blank`, or `short-by-one` to stop a page short of a multiple " + `of ${size} and let the printer add it). Check that the interior was built with this manifest, and that nothing in it ` + "forces content onto the final page.",
7531
+ code: "signature-last-page",
7532
+ data
7533
+ })
7534
+ ];
7535
+ }
7536
+ };
7537
+ registerCheck(check9);
7538
+
7539
+ // src/checks/pdf/bookmarks.ts
7540
+ var check10 = {
7274
7541
  id: "pdf.nav.bookmarks",
7275
7542
  name: "Bookmarks",
7276
7543
  description: "Checks for PDF outline (bookmarks) tree",
@@ -7284,14 +7551,14 @@ var check9 = {
7284
7551
  const doc = await loadPdf(ctx.pdfPath);
7285
7552
  if (!doc) {
7286
7553
  return [
7287
- inspectionFailed(check9.id, "Could not inspect PDF for bookmarks.", {
7554
+ inspectionFailed(check10.id, "Could not inspect PDF for bookmarks.", {
7288
7555
  file: ctx.pdfPath
7289
7556
  })
7290
7557
  ];
7291
7558
  }
7292
7559
  if (await getOutlineCount(doc) === 0) {
7293
7560
  return [
7294
- finding(check9.id, {
7561
+ finding(check10.id, {
7295
7562
  severity: "warning",
7296
7563
  message: "PDF does not contain bookmarks (outline tree).",
7297
7564
  file: ctx.pdfPath
@@ -7301,10 +7568,10 @@ var check9 = {
7301
7568
  return [];
7302
7569
  }
7303
7570
  };
7304
- registerCheck(check9);
7571
+ registerCheck(check10);
7305
7572
 
7306
7573
  // src/checks/pdf/toc-links.ts
7307
- var check10 = {
7574
+ var check11 = {
7308
7575
  id: "pdf.nav.toc-links",
7309
7576
  name: "TOC Links",
7310
7577
  description: "Detects link annotations on TOC pages",
@@ -7319,7 +7586,7 @@ var check10 = {
7319
7586
  if (!doc) {
7320
7587
  return [
7321
7588
  {
7322
- checkId: check10.id,
7589
+ checkId: check11.id,
7323
7590
  severity: "warning",
7324
7591
  message: "Could not inspect PDF for TOC links.",
7325
7592
  file: ctx.pdfPath
@@ -7330,7 +7597,7 @@ var check10 = {
7330
7597
  if (!hasLinks) {
7331
7598
  return [
7332
7599
  {
7333
- checkId: check10.id,
7600
+ checkId: check11.id,
7334
7601
  severity: "warning",
7335
7602
  message: "No link annotations found in PDF (expected for TOC).",
7336
7603
  file: ctx.pdfPath
@@ -7340,10 +7607,10 @@ var check10 = {
7340
7607
  return [];
7341
7608
  }
7342
7609
  };
7343
- registerCheck(check10);
7610
+ registerCheck(check11);
7344
7611
 
7345
7612
  // src/checks/pdf/cross-refs.ts
7346
- var check11 = {
7613
+ var check12 = {
7347
7614
  id: "pdf.nav.cross-refs",
7348
7615
  name: "Cross References",
7349
7616
  description: "Counts and verifies internal link annotations",
@@ -7356,7 +7623,7 @@ var check11 = {
7356
7623
  if (!doc) {
7357
7624
  return [
7358
7625
  {
7359
- checkId: check11.id,
7626
+ checkId: check12.id,
7360
7627
  severity: "info",
7361
7628
  message: "Could not inspect PDF for cross-references.",
7362
7629
  file: ctx.pdfPath
@@ -7366,7 +7633,7 @@ var check11 = {
7366
7633
  const linkCount = await countLinkAnnotations(doc);
7367
7634
  return [
7368
7635
  {
7369
- checkId: check11.id,
7636
+ checkId: check12.id,
7370
7637
  severity: "info",
7371
7638
  message: `PDF contains ${linkCount} internal link annotation${linkCount !== 1 ? "s" : ""}.`,
7372
7639
  file: ctx.pdfPath
@@ -7374,10 +7641,10 @@ var check11 = {
7374
7641
  ];
7375
7642
  }
7376
7643
  };
7377
- registerCheck(check11);
7644
+ registerCheck(check12);
7378
7645
 
7379
7646
  // src/checks/pdf/page-labels.ts
7380
- var check12 = {
7647
+ var check13 = {
7381
7648
  id: "pdf.nav.page-labels",
7382
7649
  name: "Page Labels",
7383
7650
  description: "Verifies PDF page labels / folio numbering",
@@ -7392,7 +7659,7 @@ var check12 = {
7392
7659
  const labels = await getPageLabels(doc);
7393
7660
  return [
7394
7661
  {
7395
- checkId: check12.id,
7662
+ checkId: check13.id,
7396
7663
  severity: "info",
7397
7664
  message: labels && labels.length > 0 ? "PDF contains page labels." : "PDF does not contain explicit page labels.",
7398
7665
  file: ctx.pdfPath
@@ -7400,10 +7667,10 @@ var check12 = {
7400
7667
  ];
7401
7668
  }
7402
7669
  };
7403
- registerCheck(check12);
7670
+ registerCheck(check13);
7404
7671
 
7405
7672
  // src/checks/pdf/image-resolution.ts
7406
- var check13 = {
7673
+ var check14 = {
7407
7674
  id: "pdf.print.image-resolution",
7408
7675
  name: "Image Resolution",
7409
7676
  description: "Checks DPI of images embedded in the PDF",
@@ -7430,7 +7697,7 @@ var check13 = {
7430
7697
  lowResPages.sort((a, b) => a - b);
7431
7698
  return [
7432
7699
  {
7433
- checkId: check13.id,
7700
+ checkId: check14.id,
7434
7701
  severity: "warning",
7435
7702
  message: `Low-resolution images (below ${minDpi} DPI) found on pages: ${lowResPages.join(", ")}`,
7436
7703
  file: ctx.pdfPath
@@ -7440,10 +7707,10 @@ var check13 = {
7440
7707
  return [];
7441
7708
  }
7442
7709
  };
7443
- registerCheck(check13);
7710
+ registerCheck(check14);
7444
7711
 
7445
7712
  // src/checks/pdf/transparency.ts
7446
- var check14 = {
7713
+ var check15 = {
7447
7714
  id: "pdf.print.transparency",
7448
7715
  name: "Transparency",
7449
7716
  description: "Checks for transparency markers (/Transparency, /SMask, /BM) in the PDF",
@@ -7465,7 +7732,7 @@ var check14 = {
7465
7732
  if (found.length > 0) {
7466
7733
  return [
7467
7734
  {
7468
- checkId: check14.id,
7735
+ checkId: check15.id,
7469
7736
  severity: "warning",
7470
7737
  message: `Transparency detected in PDF: ${found.join(", ")}. This may cause issues with some printers.`,
7471
7738
  file: ctx.pdfPath
@@ -7475,7 +7742,7 @@ var check14 = {
7475
7742
  return [];
7476
7743
  }
7477
7744
  };
7478
- registerCheck(check14);
7745
+ registerCheck(check15);
7479
7746
 
7480
7747
  // src/checks/pdf/bleed.ts
7481
7748
  function findBox(bytes, box) {
@@ -7485,7 +7752,7 @@ function findBox(bytes, box) {
7485
7752
  return null;
7486
7753
  return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
7487
7754
  }
7488
- var check15 = {
7755
+ var check16 = {
7489
7756
  id: "pdf.print.bleed",
7490
7757
  name: "Bleed Box",
7491
7758
  description: "Compares MediaBox vs TrimBox/BleedBox to verify bleed area",
@@ -7499,7 +7766,7 @@ var check15 = {
7499
7766
  const bytes = await readPdfBytes(ctx.pdfPath).catch(() => "");
7500
7767
  if (!bytes) {
7501
7768
  return [
7502
- inspectionFailed(check15.id, "Could not inspect PDF for bleed boxes.", {
7769
+ inspectionFailed(check16.id, "Could not inspect PDF for bleed boxes.", {
7503
7770
  file: ctx.pdfPath
7504
7771
  })
7505
7772
  ];
@@ -7509,14 +7776,14 @@ var check15 = {
7509
7776
  const bleed = findBox(bytes, "BleedBox");
7510
7777
  if (!media) {
7511
7778
  return [
7512
- inspectionFailed(check15.id, "Could not parse MediaBox from PDF.", {
7779
+ inspectionFailed(check16.id, "Could not parse MediaBox from PDF.", {
7513
7780
  file: ctx.pdfPath
7514
7781
  })
7515
7782
  ];
7516
7783
  }
7517
7784
  if (!trim && !bleed) {
7518
7785
  return [
7519
- finding(check15.id, {
7786
+ finding(check16.id, {
7520
7787
  severity: "warning",
7521
7788
  message: "No TrimBox or BleedBox found. Bleed area cannot be verified.",
7522
7789
  file: ctx.pdfPath
@@ -7529,7 +7796,7 @@ var check15 = {
7529
7796
  const bleedSize = ctx.config.validate.pdf.bleedSize;
7530
7797
  if (mediaW - trimW < bleedSize * 2 * 0.9) {
7531
7798
  return [
7532
- finding(check15.id, {
7799
+ finding(check16.id, {
7533
7800
  severity: "warning",
7534
7801
  message: `Bleed area appears insufficient. Expected at least ${bleedSize}pt on each side.`,
7535
7802
  file: ctx.pdfPath
@@ -7540,10 +7807,10 @@ var check15 = {
7540
7807
  return [];
7541
7808
  }
7542
7809
  };
7543
- registerCheck(check15);
7810
+ registerCheck(check16);
7544
7811
 
7545
7812
  // src/checks/pdf/pdfx-metadata.ts
7546
- var check16 = {
7813
+ var check17 = {
7547
7814
  id: "pdf.print.pdfx-metadata",
7548
7815
  name: "PDF/X Metadata",
7549
7816
  description: "Verifies PDF/X DOCINFO metadata via qpdf JSON",
@@ -7562,22 +7829,22 @@ var check16 = {
7562
7829
  const objects = parseQpdfObjectsJson(stdout);
7563
7830
  if (!objects) {
7564
7831
  return [
7565
- inspectionFailed(check16.id, "Unable to parse qpdf JSON output for PDF/X metadata checks.", { file: ctx.pdfPath })
7832
+ inspectionFailed(check17.id, "Unable to parse qpdf JSON output for PDF/X metadata checks.", { file: ctx.pdfPath })
7566
7833
  ];
7567
7834
  }
7568
- return getPdfxMetadataIssues(objects, ctx.config.pdfx.flavor).map((message) => finding(check16.id, {
7835
+ return getPdfxMetadataIssues(objects, ctx.config.pdfx.flavor).map((message) => finding(check17.id, {
7569
7836
  severity: "error",
7570
7837
  message,
7571
7838
  file: ctx.pdfPath
7572
7839
  }));
7573
7840
  } catch {
7574
7841
  return [
7575
- inspectionFailed(check16.id, "Failed to inspect PDF objects with qpdf for PDF/X metadata.", { file: ctx.pdfPath })
7842
+ inspectionFailed(check17.id, "Failed to inspect PDF objects with qpdf for PDF/X metadata.", { file: ctx.pdfPath })
7576
7843
  ];
7577
7844
  }
7578
7845
  }
7579
7846
  };
7580
- registerCheck(check16);
7847
+ registerCheck(check17);
7581
7848
 
7582
7849
  // src/checks/source/htmlhint.ts
7583
7850
  import { existsSync as existsSync9 } from "node:fs";
@@ -7585,7 +7852,7 @@ import { readFile as readFile15 } from "node:fs/promises";
7585
7852
  import htmlhint from "htmlhint";
7586
7853
  var { HTMLHint } = htmlhint;
7587
7854
  var CONFIG_NAMES2 = [".htmlhintrc"];
7588
- var check17 = {
7855
+ var check18 = {
7589
7856
  id: "source.htmlhint",
7590
7857
  name: "HTMLHint",
7591
7858
  description: "Runs htmlhint with project config to validate generated HTML",
@@ -7605,7 +7872,7 @@ var check17 = {
7605
7872
  const html = await readFile15(ctx.htmlPath, "utf8");
7606
7873
  const messages = HTMLHint.verify(html, ruleset);
7607
7874
  return messages.map((m) => ({
7608
- checkId: check17.id,
7875
+ checkId: check18.id,
7609
7876
  severity: m.type === "error" ? "error" : "warning",
7610
7877
  message: `${m.rule.id}: ${m.message}`,
7611
7878
  file: ctx.htmlPath,
@@ -7614,13 +7881,35 @@ var check17 = {
7614
7881
  }));
7615
7882
  }
7616
7883
  };
7617
- registerCheck(check17);
7884
+ registerCheck(check18);
7618
7885
 
7619
7886
  // src/checks/source/stylelint.ts
7620
7887
  import { readFile as readFile16 } from "node:fs/promises";
7621
7888
 
7622
7889
  // src/lib/printsafe.ts
7623
7890
  import postcss2 from "postcss";
7891
+
7892
+ // src/lib/lint-rules.ts
7893
+ function ruleLevel(rules, id) {
7894
+ const v = rules?.[id];
7895
+ if (v === "off" || v === false)
7896
+ return "off";
7897
+ if (v === "warn" || v === "warning")
7898
+ return "warn";
7899
+ if (v === "error")
7900
+ return "error";
7901
+ return;
7902
+ }
7903
+ var DIRECTIVE = /^\s*gutterpress-disable-next-line\s+(.*?)\s*$/;
7904
+ function parseDisableDirective(commentText) {
7905
+ const m = DIRECTIVE.exec(commentText);
7906
+ if (!m)
7907
+ return null;
7908
+ const ids = m[1].split(/\s--(?:\s|$)/)[0].split(/[\s,]+/).filter(Boolean);
7909
+ return ids.length > 0 ? ids : null;
7910
+ }
7911
+
7912
+ // src/lib/printsafe.ts
7624
7913
  var ruleRemoteUrls = "printsafe/no-remote-urls";
7625
7914
  var ruleRiskyProps = "printsafe/no-risky-print-effects";
7626
7915
  var ruleSyntax = "printsafe/syntax-error";
@@ -7645,9 +7934,11 @@ var riskyProperties = new Set([
7645
7934
  "animation",
7646
7935
  "animation-name",
7647
7936
  "transition",
7648
- "will-change",
7649
- "clip-path"
7937
+ "will-change"
7650
7938
  ]);
7939
+ function isRasterizingClipPath(decl) {
7940
+ return decl.prop.toLowerCase() === "clip-path" && /\burl\(/i.test(decl.value);
7941
+ }
7651
7942
  var marginBoxAtRuleNames = new Set([
7652
7943
  "top-left-corner",
7653
7944
  "top-left",
@@ -7774,7 +8065,34 @@ function nodeLoc(node) {
7774
8065
  column: node.source?.start?.column ?? 1
7775
8066
  };
7776
8067
  }
7777
- function checkCss(css, from) {
8068
+ function checkCss(css, from, options = {}) {
8069
+ return filterFindings(checkCssRaw(css, from), css, options.rules);
8070
+ }
8071
+ function filterFindings(findings, css, rules) {
8072
+ const silenced = new Map;
8073
+ for (const m of css.matchAll(/\/\*([\s\S]*?)\*\//g)) {
8074
+ const ids = parseDisableDirective(m[1]);
8075
+ if (!ids)
8076
+ continue;
8077
+ const endLine = css.slice(0, m.index + m[0].length).split(`
8078
+ `).length;
8079
+ const set = silenced.get(endLine + 1) ?? new Set;
8080
+ for (const id of ids)
8081
+ set.add(id);
8082
+ silenced.set(endLine + 1, set);
8083
+ }
8084
+ const out = [];
8085
+ for (const f of findings) {
8086
+ if (silenced.get(f.line)?.has(f.rule))
8087
+ continue;
8088
+ const level = ruleLevel(rules, f.rule);
8089
+ if (level === "off")
8090
+ continue;
8091
+ out.push(level === "warn" ? { ...f, severity: "warning" } : level === "error" ? { ...f, severity: "error" } : f);
8092
+ }
8093
+ return out;
8094
+ }
8095
+ function checkCssRaw(css, from) {
7778
8096
  let root;
7779
8097
  try {
7780
8098
  root = postcss2.parse(css, from ? { from } : undefined);
@@ -7823,6 +8141,13 @@ function checkCss(css, from) {
7823
8141
  message: `Property is high-risk for print/PDF: 'filter' rasterizes its subtree to a 300 DPI bitmap in the printed PDF (text becomes unselectable, unsearchable, and inaccessible), and it dominates build time (~90% measured; 57.0s -> 6.2s over 60pp when scoped — see ENGINE.md §10). Scope it to the smallest possible selector. It also means the render-parity gate cannot see a text-only change inside it (docs/render-parity-gate.md, "Known blind spot").`,
7824
8142
  ...nodeLoc(decl)
7825
8143
  });
8144
+ } else if (isRasterizingClipPath(decl)) {
8145
+ warnings.push({
8146
+ rule: ruleRiskyProps,
8147
+ severity: "warning",
8148
+ message: `Property is high-risk for print/PDF (can force rasterization): ${decl.prop} with url(...) references an SVG mask. Use a basic shape (polygon(), inset(), circle(), ellipse(), path()) instead — those print as vector clips.`,
8149
+ ...nodeLoc(decl)
8150
+ });
7826
8151
  } else if (riskyProperties.has(prop)) {
7827
8152
  warnings.push({
7828
8153
  rule: ruleRiskyProps,
@@ -7876,7 +8201,7 @@ function checkCss(css, from) {
7876
8201
  }
7877
8202
 
7878
8203
  // src/checks/source/stylelint.ts
7879
- var check18 = {
8204
+ var check19 = {
7880
8205
  id: "source.stylelint",
7881
8206
  name: "Print-safety CSS",
7882
8207
  description: "Checks CSS for print-safety issues (remote URLs, rasterizing effects, and page-layer containment)",
@@ -7896,16 +8221,16 @@ var check18 = {
7896
8221
  css = await readFile16(file, "utf8");
7897
8222
  } catch (err) {
7898
8223
  results.push({
7899
- checkId: check18.id,
8224
+ checkId: check19.id,
7900
8225
  severity: "error",
7901
8226
  message: `Cannot read stylesheet: ${err instanceof Error ? err.message : String(err)}`,
7902
8227
  file
7903
8228
  });
7904
8229
  continue;
7905
8230
  }
7906
- for (const w of checkCss(css, file)) {
8231
+ for (const w of checkCss(css, file, { rules: ctx.config.lint.rules })) {
7907
8232
  results.push({
7908
- checkId: check18.id,
8233
+ checkId: check19.id,
7909
8234
  severity: w.severity,
7910
8235
  message: `${w.rule}: ${w.message}`,
7911
8236
  file,
@@ -7917,7 +8242,7 @@ var check18 = {
7917
8242
  return results;
7918
8243
  }
7919
8244
  };
7920
- registerCheck(check18);
8245
+ registerCheck(check19);
7921
8246
 
7922
8247
  // src/checks/source/css-ownership.ts
7923
8248
  import { readFile as readFile17 } from "node:fs/promises";
@@ -8038,7 +8363,7 @@ function checkOwnership(root, file, maps) {
8038
8363
  });
8039
8364
  return out;
8040
8365
  }
8041
- var check19 = {
8366
+ var check20 = {
8042
8367
  id: "source.css-ownership",
8043
8368
  name: "CSS ownership contract",
8044
8369
  description: "Checks an optional per-project CSS ownership contract (.gutterpress/css-contract.yaml): which file may declare which properties/at-rules, and which selectors must never appear unscoped.",
@@ -8061,7 +8386,7 @@ var check19 = {
8061
8386
  } catch (err) {
8062
8387
  return [
8063
8388
  {
8064
- checkId: check19.id,
8389
+ checkId: check20.id,
8065
8390
  severity: "error",
8066
8391
  message: `Failed to parse CSS ownership contract "${configPath}": ${err instanceof Error ? err.message : String(err)}`,
8067
8392
  file: configPath
@@ -8103,7 +8428,7 @@ var check19 = {
8103
8428
  }
8104
8429
  for (const v of violations) {
8105
8430
  results.push({
8106
- checkId: check19.id,
8431
+ checkId: check20.id,
8107
8432
  severity: "warning",
8108
8433
  message: v.message,
8109
8434
  file: absFile,
@@ -8115,7 +8440,7 @@ var check19 = {
8115
8440
  return results;
8116
8441
  }
8117
8442
  };
8118
- registerCheck(check19);
8443
+ registerCheck(check20);
8119
8444
 
8120
8445
  // src/checks/source/local-refs.ts
8121
8446
  import { existsSync as existsSync10 } from "node:fs";
@@ -8202,7 +8527,7 @@ async function* renderedLocalRefs(ctx, checkId, results) {
8202
8527
  }
8203
8528
 
8204
8529
  // src/checks/source/local-refs.ts
8205
- var check20 = {
8530
+ var check21 = {
8206
8531
  id: "source.links.local-refs",
8207
8532
  name: "Local Markdown References",
8208
8533
  description: "Checks local markdown links and image refs resolve on disk",
@@ -8210,12 +8535,12 @@ var check20 = {
8210
8535
  phase: "pre-build",
8211
8536
  async run(ctx) {
8212
8537
  const results = [];
8213
- for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check20.id, results)) {
8538
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check21.id, results)) {
8214
8539
  if (isNonFilesystemRef(ref))
8215
8540
  continue;
8216
8541
  const escape = kind === "image" ? proseImageRefError(ref, ctx.inputDir) : null;
8217
8542
  if (escape) {
8218
- results.push(finding(check20.id, {
8543
+ results.push(finding(check21.id, {
8219
8544
  severity: "error",
8220
8545
  message: escape,
8221
8546
  file,
@@ -8225,7 +8550,7 @@ var check20 = {
8225
8550
  }
8226
8551
  if (localRefExists(ref, kind, file, ctx.inputDir))
8227
8552
  continue;
8228
- results.push(finding(check20.id, {
8553
+ results.push(finding(check21.id, {
8229
8554
  severity: kind === "image" ? "warning" : "error",
8230
8555
  code: kind === "image" ? "missing-image-placeholder" : undefined,
8231
8556
  message: kind === "image" ? `Local image not found; build will substitute a magenta placeholder: ${ref}` : `Local reference not found: ${ref}`,
@@ -8250,7 +8575,7 @@ function localRefExists(ref, kind, sourceFile, projectRoot) {
8250
8575
  return existsSync10(fileRelative);
8251
8576
  }
8252
8577
  }
8253
- registerCheck(check20);
8578
+ registerCheck(check21);
8254
8579
 
8255
8580
  // src/lib/build-staging.ts
8256
8581
  import path11 from "node:path";
@@ -8516,7 +8841,7 @@ async function createStageRoot() {
8516
8841
  }
8517
8842
 
8518
8843
  // src/checks/source/dangling-links.ts
8519
- var check21 = {
8844
+ var check22 = {
8520
8845
  id: "source.links.dangling",
8521
8846
  name: "Dangling Links",
8522
8847
  description: "Flags relative links the printed book cannot open (their href is dropped at print time)",
@@ -8524,10 +8849,10 @@ var check21 = {
8524
8849
  phase: "pre-build",
8525
8850
  async run(ctx) {
8526
8851
  const results = [];
8527
- for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check21.id, results)) {
8852
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check22.id, results)) {
8528
8853
  if (kind !== "link" || isPrintResolvableHref(ref))
8529
8854
  continue;
8530
- results.push(finding(check21.id, {
8855
+ results.push(finding(check22.id, {
8531
8856
  severity: "warning",
8532
8857
  code: "dangling-link",
8533
8858
  data: { ref },
@@ -8539,11 +8864,11 @@ var check21 = {
8539
8864
  return results;
8540
8865
  }
8541
8866
  };
8542
- registerCheck(check21);
8867
+ registerCheck(check22);
8543
8868
 
8544
8869
  // src/checks/source/accessibility-alt-text.ts
8545
8870
  import { readFile as readFile19 } from "node:fs/promises";
8546
- var check22 = {
8871
+ var check23 = {
8547
8872
  id: "source.accessibility.alt-text",
8548
8873
  name: "Image Alt Text",
8549
8874
  description: "Checks markdown images include non-empty alt text",
@@ -8572,7 +8897,7 @@ var check22 = {
8572
8897
  if (alt.trim())
8573
8898
  continue;
8574
8899
  results.push({
8575
- checkId: check22.id,
8900
+ checkId: check23.id,
8576
8901
  severity: "warning",
8577
8902
  message: "Image is missing alt text",
8578
8903
  file,
@@ -8597,11 +8922,11 @@ function extractImageAlts(line) {
8597
8922
  }
8598
8923
  return alts;
8599
8924
  }
8600
- registerCheck(check22);
8925
+ registerCheck(check23);
8601
8926
 
8602
8927
  // src/checks/source/accessibility-heading-order.ts
8603
8928
  import { readFile as readFile20 } from "node:fs/promises";
8604
- var check23 = {
8929
+ var check24 = {
8605
8930
  id: "source.accessibility.heading-order",
8606
8931
  name: "Heading Order",
8607
8932
  description: "Checks markdown heading levels do not jump by more than one",
@@ -8619,6 +8944,7 @@ var check23 = {
8619
8944
  `);
8620
8945
  let inFence = false;
8621
8946
  let prevLevel;
8947
+ let silencedLine = -1;
8622
8948
  for (let i = 0;i < lines.length; i++) {
8623
8949
  const line = lines[i] ?? "";
8624
8950
  if (/^\s*(```|~~~)/.test(line)) {
@@ -8627,13 +8953,18 @@ var check23 = {
8627
8953
  }
8628
8954
  if (inFence)
8629
8955
  continue;
8956
+ const comment = /^\s*<!--(.*)-->\s*$/.exec(line);
8957
+ if (comment && parseDisableDirective(comment[1])?.includes(check24.id)) {
8958
+ silencedLine = i + 1;
8959
+ continue;
8960
+ }
8630
8961
  const match = line.match(/^(#{1,6})\s+\S/);
8631
8962
  if (!match)
8632
8963
  continue;
8633
8964
  const level = match[1].length;
8634
- if (prevLevel != null && level > prevLevel + 1) {
8965
+ if (prevLevel != null && level > prevLevel + 1 && i !== silencedLine) {
8635
8966
  results.push({
8636
- checkId: check23.id,
8967
+ checkId: check24.id,
8637
8968
  severity: "warning",
8638
8969
  message: `Heading level jump from h${prevLevel} to h${level}`,
8639
8970
  file,
@@ -8647,11 +8978,11 @@ var check23 = {
8647
8978
  return results;
8648
8979
  }
8649
8980
  };
8650
- registerCheck(check23);
8981
+ registerCheck(check24);
8651
8982
 
8652
8983
  // src/checks/source/layout-markers.ts
8653
8984
  import { readFile as readFile21 } from "node:fs/promises";
8654
- var check24 = {
8985
+ var check25 = {
8655
8986
  id: "source.markdown.layout-markers",
8656
8987
  name: "Layout Markers",
8657
8988
  description: "Reports @page/@section/@chapter marker arguments Gutterpress could not understand, plus any unknown gp-* class",
@@ -8663,7 +8994,7 @@ var check24 = {
8663
8994
  return [];
8664
8995
  const results = [];
8665
8996
  const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
8666
- results.push(inspectionFailed(check24.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
8997
+ results.push(inspectionFailed(check25.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
8667
8998
  });
8668
8999
  const md = createMarkdownRenderer(plugins);
8669
9000
  for (const file of files) {
@@ -8672,7 +9003,7 @@ var check24 = {
8672
9003
  const env = {};
8673
9004
  md.render(content, env);
8674
9005
  for (const w of env.layoutWarnings ?? []) {
8675
- results.push(finding(check24.id, {
9006
+ results.push(finding(check25.id, {
8676
9007
  severity: w.severity ?? "warning",
8677
9008
  message: w.message,
8678
9009
  file,
@@ -8681,13 +9012,13 @@ var check24 = {
8681
9012
  }));
8682
9013
  }
8683
9014
  } catch (error2) {
8684
- results.push(inspectionFailed(check24.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
9015
+ results.push(inspectionFailed(check25.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
8685
9016
  }
8686
9017
  }
8687
9018
  return results;
8688
9019
  }
8689
9020
  };
8690
- registerCheck(check24);
9021
+ registerCheck(check25);
8691
9022
 
8692
9023
  // src/checks/source/merge-markers.ts
8693
9024
  import { readFile as readFile22 } from "node:fs/promises";
@@ -8720,7 +9051,7 @@ var CLOSE_SENTINEL = ">>>>>>> online version";
8720
9051
  function withoutOnlineTag(relPath) {
8721
9052
  return relPath.replace(/\.online(?=\.[^./]*$|$)/, "");
8722
9053
  }
8723
- var check25 = {
9054
+ var check26 = {
8724
9055
  id: "source.sync.merge-markers",
8725
9056
  name: "Combined Versions",
8726
9057
  description: "Finds passages and files still holding two versions (yours and the online copy) after a sync",
@@ -8734,7 +9065,7 @@ var check25 = {
8734
9065
  try {
8735
9066
  content = await readFile22(file, "utf8");
8736
9067
  } catch {
8737
- results.push(inspectionFailed(check25.id, `Could not read source file: ${file}`, { file }));
9068
+ results.push(inspectionFailed(check26.id, `Could not read source file: ${file}`, { file }));
8738
9069
  continue;
8739
9070
  }
8740
9071
  if (!content.includes(OPEN_SENTINEL) && !content.includes(CLOSE_SENTINEL))
@@ -8746,7 +9077,7 @@ var check25 = {
8746
9077
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
8747
9078
  if (line === OPEN_SENTINEL) {
8748
9079
  inBlock = true;
8749
- results.push(finding(check25.id, {
9080
+ results.push(finding(check26.id, {
8750
9081
  severity: "error",
8751
9082
  code: "two-versions-passage",
8752
9083
  message: "This passage has two versions (yours and the online copy) — keep what you want, then delete the marker lines.",
@@ -8755,7 +9086,7 @@ var check25 = {
8755
9086
  }));
8756
9087
  } else if (line === CLOSE_SENTINEL) {
8757
9088
  if (!inBlock) {
8758
- results.push(finding(check25.id, {
9089
+ results.push(finding(check26.id, {
8759
9090
  severity: "error",
8760
9091
  code: "leftover-version-marker",
8761
9092
  message: "This marker line is left over from combining two versions — delete it.",
@@ -8776,7 +9107,7 @@ var check25 = {
8776
9107
  });
8777
9108
  for (const sibling of siblings.sort()) {
8778
9109
  const rel = path12.relative(ctx.inputDir, sibling).split(path12.sep).join("/");
8779
- results.push(finding(check25.id, {
9110
+ results.push(finding(check26.id, {
8780
9111
  severity: "warning",
8781
9112
  code: "kept-both-versions",
8782
9113
  message: `Two versions of ${withoutOnlineTag(rel)} are in your book — keep the one you want, then delete the other.`,
@@ -8784,12 +9115,12 @@ var check25 = {
8784
9115
  }));
8785
9116
  }
8786
9117
  } catch (error2) {
8787
- results.push(inspectionFailed(check25.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
9118
+ results.push(inspectionFailed(check26.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
8788
9119
  }
8789
9120
  return results;
8790
9121
  }
8791
9122
  };
8792
- registerCheck(check25);
9123
+ registerCheck(check26);
8793
9124
 
8794
9125
  // src/checks/asset/image-file-size.ts
8795
9126
  import { stat as stat4 } from "node:fs/promises";
@@ -9015,7 +9346,7 @@ async function collectImageFiles(dirs, exts, ignore2 = ASSET_SCAN_IGNORE_GLOBS)
9015
9346
  }
9016
9347
 
9017
9348
  // src/checks/asset/image-file-size.ts
9018
- var check26 = {
9349
+ var check27 = {
9019
9350
  id: "asset.image.file-size",
9020
9351
  name: "Image File Size",
9021
9352
  description: "Checks that image files do not exceed the maximum size limit",
@@ -9036,14 +9367,14 @@ var check26 = {
9036
9367
  if (info2.size > maxSize) {
9037
9368
  const sizeMb = (info2.size / 1e6).toFixed(1);
9038
9369
  const maxMb = (maxSize / 1e6).toFixed(1);
9039
- results.push(finding(check26.id, {
9370
+ results.push(finding(check27.id, {
9040
9371
  severity: "warning",
9041
9372
  message: `Image file too large: ${sizeMb}MB (max ${maxMb}MB)`,
9042
9373
  file
9043
9374
  }));
9044
9375
  }
9045
9376
  } catch {
9046
- results.push(inspectionFailed(check26.id, `Could not stat image file: ${file}`, {
9377
+ results.push(inspectionFailed(check27.id, `Could not stat image file: ${file}`, {
9047
9378
  file
9048
9379
  }));
9049
9380
  }
@@ -9051,10 +9382,10 @@ var check26 = {
9051
9382
  return results;
9052
9383
  }
9053
9384
  };
9054
- registerCheck(check26);
9385
+ registerCheck(check27);
9055
9386
 
9056
9387
  // src/checks/asset/image-resolution.ts
9057
- var check27 = {
9388
+ var check28 = {
9058
9389
  id: "asset.image.resolution",
9059
9390
  name: "Image Resolution",
9060
9391
  description: "Checks source image DPI from embedded density metadata",
@@ -9076,7 +9407,7 @@ var check27 = {
9076
9407
  const { xDpi, yDpi } = info2;
9077
9408
  if (xDpi > 0 && yDpi > 0 && (xDpi < minDpi || yDpi < minDpi)) {
9078
9409
  results.push({
9079
- checkId: check27.id,
9410
+ checkId: check28.id,
9080
9411
  severity: "warning",
9081
9412
  message: `Image resolution too low: ${xDpi}x${yDpi} DPI (minimum ${minDpi} DPI)`,
9082
9413
  file
@@ -9086,7 +9417,7 @@ var check27 = {
9086
9417
  return results;
9087
9418
  }
9088
9419
  };
9089
- registerCheck(check27);
9420
+ registerCheck(check28);
9090
9421
 
9091
9422
  // src/checks/asset/image-color-space.ts
9092
9423
  var LABEL = {
@@ -9094,7 +9425,7 @@ var LABEL = {
9094
9425
  gray: "Gray",
9095
9426
  cmyk: "CMYK"
9096
9427
  };
9097
- var check28 = {
9428
+ var check29 = {
9098
9429
  id: "asset.image.color-space",
9099
9430
  name: "Image Color Space",
9100
9431
  description: "Validates image color spaces against allowed list",
@@ -9115,7 +9446,7 @@ var check28 = {
9115
9446
  const cs = info2?.colorSpace;
9116
9447
  if (cs && !allowedLower.has(cs)) {
9117
9448
  results.push({
9118
- checkId: check28.id,
9449
+ checkId: check29.id,
9119
9450
  severity: "warning",
9120
9451
  message: `Image uses ${LABEL[cs] ?? cs} color space (allowed: ${allowed.join(", ")})`,
9121
9452
  file
@@ -9125,10 +9456,10 @@ var check28 = {
9125
9456
  return results;
9126
9457
  }
9127
9458
  };
9128
- registerCheck(check28);
9459
+ registerCheck(check29);
9129
9460
 
9130
9461
  // src/checks/asset/image-alpha.ts
9131
- var check29 = {
9462
+ var check30 = {
9132
9463
  id: "asset.image.alpha-channel",
9133
9464
  name: "Image Alpha Channel",
9134
9465
  description: "Checks for alpha channels in PNG/TIFF images",
@@ -9146,7 +9477,7 @@ var check29 = {
9146
9477
  const info2 = await inspectImage(file);
9147
9478
  if (info2?.hasAlpha) {
9148
9479
  results.push({
9149
- checkId: check29.id,
9480
+ checkId: check30.id,
9150
9481
  severity: "warning",
9151
9482
  message: "Image contains alpha channel, which may cause print issues",
9152
9483
  file
@@ -9156,10 +9487,10 @@ var check29 = {
9156
9487
  return results;
9157
9488
  }
9158
9489
  };
9159
- registerCheck(check29);
9490
+ registerCheck(check30);
9160
9491
 
9161
9492
  // src/checks/asset/image-tac.ts
9162
- var check30 = {
9493
+ var check31 = {
9163
9494
  id: "asset.image.tac-raster",
9164
9495
  name: "Image TAC (Raster)",
9165
9496
  description: "Rasterizes and checks TAC per image using Ghostscript",
@@ -9178,7 +9509,7 @@ var check30 = {
9178
9509
  const ghostscript = await resolveGhostscript();
9179
9510
  if (!ghostscript) {
9180
9511
  return [
9181
- inspectionFailed(check30.id, "Could not inspect image ink coverage: Ghostscript executable not found")
9512
+ inspectionFailed(check31.id, "Could not inspect image ink coverage: Ghostscript executable not found")
9182
9513
  ];
9183
9514
  }
9184
9515
  for (const file of files) {
@@ -9196,7 +9527,7 @@ var check30 = {
9196
9527
  if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
9197
9528
  const tac = (nums[0] + nums[1] + nums[2] + nums[3]) * 100;
9198
9529
  if (tac > maxTac) {
9199
- results.push(finding(check30.id, {
9530
+ results.push(finding(check31.id, {
9200
9531
  severity: "warning",
9201
9532
  message: `Image TAC exceeds limit: ${tac.toFixed(1)}% (max ${maxTac}%)`,
9202
9533
  file,
@@ -9208,16 +9539,16 @@ var check30 = {
9208
9539
  }
9209
9540
  }
9210
9541
  } catch {
9211
- results.push(inspectionFailed(check30.id, `Could not inspect image ink coverage: ${file}`, { file }));
9542
+ results.push(inspectionFailed(check31.id, `Could not inspect image ink coverage: ${file}`, { file }));
9212
9543
  }
9213
9544
  }
9214
9545
  return results;
9215
9546
  }
9216
9547
  };
9217
- registerCheck(check30);
9548
+ registerCheck(check31);
9218
9549
 
9219
9550
  // src/checks/asset/approved-fonts.ts
9220
- var check31 = {
9551
+ var check32 = {
9221
9552
  id: "asset.font.approved-files",
9222
9553
  name: "Approved Font Files",
9223
9554
  description: "Checks font files against the approved file patterns",
@@ -9256,7 +9587,7 @@ var check31 = {
9256
9587
  for (const font of allFonts) {
9257
9588
  if (!approvedFonts.has(font)) {
9258
9589
  results.push({
9259
- checkId: check31.id,
9590
+ checkId: check32.id,
9260
9591
  severity: "warning",
9261
9592
  message: "Font file not in approved list",
9262
9593
  file: font
@@ -9266,7 +9597,7 @@ var check31 = {
9266
9597
  return results;
9267
9598
  }
9268
9599
  };
9269
- registerCheck(check31);
9600
+ registerCheck(check32);
9270
9601
 
9271
9602
  // src/checks/asset/font-license.ts
9272
9603
  import { existsSync as existsSync11 } from "node:fs";
@@ -9281,7 +9612,7 @@ var LICENSE_NAMES = [
9281
9612
  "OFL-1.1.txt",
9282
9613
  "COPYING"
9283
9614
  ];
9284
- var check32 = {
9615
+ var check33 = {
9285
9616
  id: "asset.font.license",
9286
9617
  name: "Font License",
9287
9618
  description: "Checks for license files in font directories",
@@ -9308,7 +9639,7 @@ var check32 = {
9308
9639
  const hasLicense = LICENSE_NAMES.some((name) => existsSync11(resolve7(fontDir, name)));
9309
9640
  if (!hasLicense) {
9310
9641
  results.push({
9311
- checkId: check32.id,
9642
+ checkId: check33.id,
9312
9643
  severity: "warning",
9313
9644
  message: `No font license file found in directory`,
9314
9645
  file: fontDir
@@ -9318,10 +9649,10 @@ var check32 = {
9318
9649
  return results;
9319
9650
  }
9320
9651
  };
9321
- registerCheck(check32);
9652
+ registerCheck(check33);
9322
9653
 
9323
9654
  // src/checks/heuristic/text-density.ts
9324
- var check33 = {
9655
+ var check34 = {
9325
9656
  id: "heuristic.whitespace.text-density",
9326
9657
  name: "Text Density",
9327
9658
  description: "Checks characters-per-page ratio",
@@ -9350,7 +9681,7 @@ var check33 = {
9350
9681
  });
9351
9682
  if (lowPages.length > 0) {
9352
9683
  results.push({
9353
- checkId: check33.id,
9684
+ checkId: check34.id,
9354
9685
  severity: "info",
9355
9686
  message: `Low text density on pages: ${lowPages.join(", ")} (below ${range.min} chars)`,
9356
9687
  file: ctx.pdfPath
@@ -9358,7 +9689,7 @@ var check33 = {
9358
9689
  }
9359
9690
  if (highPages.length > 0) {
9360
9691
  results.push({
9361
- checkId: check33.id,
9692
+ checkId: check34.id,
9362
9693
  severity: "info",
9363
9694
  message: `High text density on pages: ${highPages.join(", ")} (above ${range.max} chars)`,
9364
9695
  file: ctx.pdfPath
@@ -9367,11 +9698,11 @@ var check33 = {
9367
9698
  return results;
9368
9699
  }
9369
9700
  };
9370
- registerCheck(check33);
9701
+ registerCheck(check34);
9371
9702
 
9372
9703
  // src/checks/heuristic/section-density.ts
9373
9704
  import { readFile as readFile24 } from "node:fs/promises";
9374
- var check34 = {
9705
+ var check35 = {
9375
9706
  id: "heuristic.chunking.section-density",
9376
9707
  name: "Section Density",
9377
9708
  description: "Checks heading/paragraph/callout density from source Markdown",
@@ -9397,7 +9728,7 @@ var check34 = {
9397
9728
  const line = lines[i];
9398
9729
  if (/^#{1,6}\s/.test(line)) {
9399
9730
  if (paragraphCount > maxParas) {
9400
- results.push(finding(check34.id, {
9731
+ results.push(finding(check35.id, {
9401
9732
  severity: "info",
9402
9733
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
9403
9734
  file,
@@ -9417,7 +9748,7 @@ var check34 = {
9417
9748
  }
9418
9749
  }
9419
9750
  if (paragraphCount > maxParas) {
9420
- results.push(finding(check34.id, {
9751
+ results.push(finding(check35.id, {
9421
9752
  severity: "info",
9422
9753
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
9423
9754
  file,
@@ -9425,7 +9756,7 @@ var check34 = {
9425
9756
  }));
9426
9757
  }
9427
9758
  } catch {
9428
- results.push(inspectionFailed(check34.id, `Could not read source file: ${file}`, {
9759
+ results.push(inspectionFailed(check35.id, `Could not read source file: ${file}`, {
9429
9760
  file
9430
9761
  }));
9431
9762
  }
@@ -9433,10 +9764,10 @@ var check34 = {
9433
9764
  return results;
9434
9765
  }
9435
9766
  };
9436
- registerCheck(check34);
9767
+ registerCheck(check35);
9437
9768
 
9438
9769
  // src/checks/heuristic/layer-count.ts
9439
- var check35 = {
9770
+ var check36 = {
9440
9771
  id: "heuristic.decoration.layer-count",
9441
9772
  name: "Layer Count",
9442
9773
  description: "Counts image objects per page",
@@ -9461,7 +9792,7 @@ var check35 = {
9461
9792
  heavyPages.sort((a, b) => a - b);
9462
9793
  return [
9463
9794
  {
9464
- checkId: check35.id,
9795
+ checkId: check36.id,
9465
9796
  severity: "info",
9466
9797
  message: `Pages with many image layers (>${maxLayers}): ${heavyPages.join(", ")}`,
9467
9798
  file: ctx.pdfPath
@@ -9471,10 +9802,10 @@ var check35 = {
9471
9802
  return [];
9472
9803
  }
9473
9804
  };
9474
- registerCheck(check35);
9805
+ registerCheck(check36);
9475
9806
 
9476
9807
  // src/checks/heuristic/placement-variance.ts
9477
- var check36 = {
9808
+ var check37 = {
9478
9809
  id: "heuristic.layout.placement-variance",
9479
9810
  name: "Placement Variance",
9480
9811
  description: "Analyzes text baseline coordinates for layout consistency",
@@ -9492,7 +9823,7 @@ var check36 = {
9492
9823
  const uniqueX = new Set(positions.map((p) => Math.round(p.x)));
9493
9824
  return [
9494
9825
  {
9495
- checkId: check36.id,
9826
+ checkId: check37.id,
9496
9827
  severity: "info",
9497
9828
  message: `Layout analysis: ${uniqueX.size} unique horizontal text positions across ${positions.length} text blocks.`,
9498
9829
  file: ctx.pdfPath
@@ -9500,7 +9831,7 @@ var check36 = {
9500
9831
  ];
9501
9832
  }
9502
9833
  };
9503
- registerCheck(check36);
9834
+ registerCheck(check37);
9504
9835
 
9505
9836
  // src/checks/runner.ts
9506
9837
  function getCheckSeverityOverride(checkId, config) {
@@ -9530,25 +9861,32 @@ async function runChecks(ctx, opts = {}) {
9530
9861
  }
9531
9862
  const release = retainPdfCache();
9532
9863
  try {
9533
- for (const check37 of checks2) {
9864
+ for (const check38 of checks2) {
9534
9865
  try {
9535
- const results = await check37.run(ctx);
9536
- const severityOverride = getCheckSeverityOverride(check37.id, ctx.config);
9866
+ let results = await check38.run(ctx);
9867
+ const severityOverride = getCheckSeverityOverride(check38.id, ctx.config);
9537
9868
  if (severityOverride) {
9538
9869
  for (const r of results) {
9539
9870
  r.severity = severityOverride;
9540
9871
  }
9541
9872
  }
9873
+ const lintLevel = ruleLevel(ctx.config.lint.rules, check38.id);
9874
+ if (lintLevel === "off")
9875
+ results = [];
9876
+ else if (lintLevel) {
9877
+ for (const r of results)
9878
+ r.severity = lintLevel === "warn" ? "warning" : "error";
9879
+ }
9542
9880
  if (results.length === 0) {
9543
- passed.push(check37.id);
9881
+ passed.push(check38.id);
9544
9882
  } else {
9545
9883
  allResults.push(...results);
9546
9884
  }
9547
9885
  } catch (err) {
9548
9886
  allResults.push({
9549
- checkId: check37.id,
9887
+ checkId: check38.id,
9550
9888
  severity: "error",
9551
- message: `Check "${check37.id}" threw: ${err instanceof Error ? err.message : String(err)}`
9889
+ message: `Check "${check38.id}" threw: ${err instanceof Error ? err.message : String(err)}`
9552
9890
  });
9553
9891
  }
9554
9892
  }
@@ -9588,15 +9926,15 @@ function emptyReport() {
9588
9926
  async function checkToolAvailability(config, opts = {}) {
9589
9927
  const { checks: checks2 } = selectChecks(opts, config);
9590
9928
  const toolToChecks = new Map;
9591
- for (const check37 of checks2) {
9592
- if (!check37.requiredTools?.length)
9929
+ for (const check38 of checks2) {
9930
+ if (!check38.requiredTools?.length)
9593
9931
  continue;
9594
- for (const tool of check37.requiredTools) {
9932
+ for (const tool of check38.requiredTools) {
9595
9933
  const existing = toolToChecks.get(tool);
9596
9934
  if (existing) {
9597
- existing.push(check37.id);
9935
+ existing.push(check38.id);
9598
9936
  } else {
9599
- toolToChecks.set(tool, [check37.id]);
9937
+ toolToChecks.set(tool, [check38.id]);
9600
9938
  }
9601
9939
  }
9602
9940
  }
@@ -9612,11 +9950,11 @@ async function checkToolAvailability(config, opts = {}) {
9612
9950
  const missing = results.filter((r) => !r.found).map((r) => r.tool);
9613
9951
  const missingSet = new Set(missing);
9614
9952
  const skippedChecks = [];
9615
- for (const check37 of checks2) {
9616
- if (!check37.requiredTools?.length)
9953
+ for (const check38 of checks2) {
9954
+ if (!check38.requiredTools?.length)
9617
9955
  continue;
9618
- if (check37.requiredTools.some((t) => missingSet.has(t))) {
9619
- skippedChecks.push(check37.id);
9956
+ if (check38.requiredTools.some((t) => missingSet.has(t))) {
9957
+ skippedChecks.push(check38.id);
9620
9958
  }
9621
9959
  }
9622
9960
  return { available, missing, skippedChecks, toolToChecks };
@@ -9889,7 +10227,7 @@ async function executeValidation(args) {
9889
10227
  };
9890
10228
  if (args.fix) {
9891
10229
  const selected = selectChecks(runnerOptions, config).checks;
9892
- if (selected.some((check37) => check37.id === MARKDOWNLINT_CHECK_ID)) {
10230
+ if (selected.some((check38) => check38.id === MARKDOWNLINT_CHECK_ID)) {
9893
10231
  await applyMarkdownlintFixes(context);
9894
10232
  } else {
9895
10233
  log.warn(`--fix: ${MARKDOWNLINT_CHECK_ID} is not part of this run — no files were fixed.`);
@@ -10241,7 +10579,8 @@ class HtmlOutput {
10241
10579
  requestedFlavor: null,
10242
10580
  resolvedFlavor: config.pdfx.flavor,
10243
10581
  iccPath: null,
10244
- stripAnnotations: null
10582
+ stripAnnotations: null,
10583
+ blackText: null
10245
10584
  }
10246
10585
  }, `Wrote: ${path13.join(outDir, BOOK_HTML)}`, { htmlPath: htmlFile, pdfPath: null });
10247
10586
  }
@@ -10261,12 +10600,12 @@ class PdfOutput {
10261
10600
  const rawPdf = pdfxMode ? path13.join(stage, "raw.pdf") : path13.resolve(pdfFile);
10262
10601
  await fsp2.mkdir(path13.dirname(path13.resolve(pdfFile)), { recursive: true });
10263
10602
  log.info("Rendering HTML to PDF via the Gutterpress engine (native Chromium pagination)");
10264
- const { buildNativePdf } = await import("./engine-5jzhjjvv.js");
10603
+ const { buildNativePdf } = await import("./engine-3xqgm82d.js");
10265
10604
  const engineDiagnostics = await buildNativePdf(htmlFile, rawPdf, {
10266
10605
  title: config.title,
10267
10606
  author: config.authors.length > 0 ? config.authors.join(", ") : undefined,
10268
10607
  signature: config.print.signature,
10269
- reserveLastPage: config.print.reserveLastPage,
10608
+ lastPage: lastPageMode(config.print),
10270
10609
  allowShrink: opts.allowShrink
10271
10610
  }, browser);
10272
10611
  for (const d of engineDiagnostics)
@@ -10325,7 +10664,8 @@ class PdfOutput {
10325
10664
  requestedFlavor: pdfxMode ?? null,
10326
10665
  resolvedFlavor: config.pdfx.flavor,
10327
10666
  iccPath: effectiveIccPath,
10328
- stripAnnotations: shouldStripAnnotations
10667
+ stripAnnotations: shouldStripAnnotations,
10668
+ blackText: pdfxMode ? config.pdfx.blackText : null
10329
10669
  }
10330
10670
  }, `Wrote: ${reportedPdf}`, { htmlPath: htmlFile, pdfPath: reportedPdf }, pdfName, engineDiagnostics);
10331
10671
  } finally {
@@ -11726,9 +12066,169 @@ async function getSystemDiagnostics() {
11726
12066
  };
11727
12067
  }
11728
12068
  // src/lib/project-scaffold.ts
11729
- import { access, copyFile, cp as cp2, mkdir as mkdir8, readdir as readdir6, readFile as readFile28, stat as stat6, writeFile as writeFile8 } from "node:fs/promises";
11730
- import { constants as FS, existsSync as existsSync13 } from "node:fs";
12069
+ import { access, copyFile, cp as cp2, mkdir as mkdir9, readdir as readdir6, readFile as readFile29, stat as stat6, writeFile as writeFile9 } from "node:fs/promises";
12070
+ import { constants as FS, existsSync as existsSync14 } from "node:fs";
12071
+ import path20 from "node:path";
12072
+
12073
+ // src/lib/page-size.ts
12074
+ import { existsSync as existsSync13 } from "node:fs";
12075
+ import { mkdir as mkdir8, readFile as readFile28, writeFile as writeFile8 } from "node:fs/promises";
11731
12076
  import path19 from "node:path";
12077
+ import postcss4 from "postcss";
12078
+ import { Scalar as Scalar2 } from "yaml";
12079
+ function isPositivePoints(value) {
12080
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
12081
+ }
12082
+ function writePageBounds(doc, page) {
12083
+ const out = { width: page.width, height: page.height };
12084
+ const tolerance = page.tolerance ?? numberAt(doc, ["page", "tolerance"]);
12085
+ if (tolerance !== undefined)
12086
+ out.tolerance = tolerance;
12087
+ doc.set("page", doc.createNode(out));
12088
+ }
12089
+ function numberAt(doc, keyPath) {
12090
+ const v = doc.getIn(keyPath);
12091
+ return isPositivePoints(v) ? v : undefined;
12092
+ }
12093
+ function pageSizeForChoice(choice) {
12094
+ if (!PRESET_IDS.includes(choice.preset)) {
12095
+ throw new UsageError(`Unknown preset "${choice.preset}". Choose one of: ${PRESET_IDS.join(", ")}.`);
12096
+ }
12097
+ if (choice.preset === "custom") {
12098
+ const { page } = choice;
12099
+ if (!page || !isPositivePoints(page.width) || !isPositivePoints(page.height)) {
12100
+ throw new UsageError("A custom page size needs a positive width and height in points (72pt = 1in).");
12101
+ }
12102
+ return { width: page.width, height: page.height };
12103
+ }
12104
+ const base = PRESETS[choice.preset].page;
12105
+ return { width: base.width, height: base.height };
12106
+ }
12107
+ var round3 = (n) => Math.round(n * 1000) / 1000;
12108
+ function cssLength(pt) {
12109
+ const inches = round3(pt / 72);
12110
+ return Math.abs(inches * 72 - pt) < 0.000001 ? `${inches}in` : `${round3(pt)}pt`;
12111
+ }
12112
+ function formatPageSize(page) {
12113
+ return `${cssLength(page.width)} ${cssLength(page.height)}`;
12114
+ }
12115
+ var SAME_SIZE_PT = 0.5;
12116
+ function sameSize(a, b) {
12117
+ return Math.abs(a.width - b.width) < SAME_SIZE_PT && Math.abs(a.height - b.height) < SAME_SIZE_PT;
12118
+ }
12119
+ function plainPageRules(root) {
12120
+ const rules = [];
12121
+ root.walkAtRules("page", (rule) => {
12122
+ if (rule.params.trim() === "")
12123
+ rules.push(rule);
12124
+ });
12125
+ return rules;
12126
+ }
12127
+ function sizeDeclsOf(rule) {
12128
+ const decls = [];
12129
+ rule.each((node) => {
12130
+ if (node.type === "decl" && node.prop.toLowerCase() === "size")
12131
+ decls.push(node);
12132
+ });
12133
+ return decls;
12134
+ }
12135
+ function readPageSizeFromCss(css) {
12136
+ let root;
12137
+ try {
12138
+ root = postcss4.parse(css);
12139
+ } catch {
12140
+ return null;
12141
+ }
12142
+ const decls = plainPageRules(root).flatMap(sizeDeclsOf);
12143
+ const last = decls[decls.length - 1];
12144
+ return last ? parseSize(last.value) ?? null : null;
12145
+ }
12146
+ function setPageSizeInCss(css, page) {
12147
+ const root = postcss4.parse(css);
12148
+ const decls = plainPageRules(root).flatMap(sizeDeclsOf);
12149
+ const last = decls[decls.length - 1];
12150
+ const value = formatPageSize(page);
12151
+ if (last) {
12152
+ const current = parseSize(last.value);
12153
+ if (current && sameSize(current, page))
12154
+ return css;
12155
+ last.value = value;
12156
+ return root.toString();
12157
+ }
12158
+ const rule = `@page {
12159
+ size: ${value};
12160
+ }
12161
+ `;
12162
+ if (css.trim() === "")
12163
+ return css + rule;
12164
+ return `${css}${css.endsWith(`
12165
+ `) ? "" : `
12166
+ `}
12167
+ ${rule}`;
12168
+ }
12169
+ async function ownStylesheets(projectDir) {
12170
+ const rels = await resolveActiveStyles(projectDir).catch(() => []);
12171
+ return rels.map((rel) => path19.resolve(projectDir, rel)).filter((abs) => {
12172
+ const inside = path19.relative(projectDir, abs);
12173
+ return !inside.startsWith("..") && !path19.isAbsolute(inside) && existsSync13(abs);
12174
+ });
12175
+ }
12176
+ var relDisplay2 = (projectDir, abs) => path19.relative(projectDir, abs).split(path19.sep).join("/");
12177
+ async function findCssSize(projectDir, sheets) {
12178
+ let found = null;
12179
+ for (const file of sheets) {
12180
+ const size = readPageSizeFromCss(await readFile28(file, "utf8"));
12181
+ if (size)
12182
+ found = { file, size };
12183
+ }
12184
+ return found;
12185
+ }
12186
+ async function readPageSetup(projectDir) {
12187
+ const { doc } = await loadManifestDoc(projectDir);
12188
+ const declared = doc.get("preset");
12189
+ const preset = declared === undefined || declared === null ? "dtrpg" : PRESET_IDS.includes(String(declared)) ? declared : null;
12190
+ const base = preset ? PRESETS[preset].page : null;
12191
+ const width = numberAt(doc, ["page", "width"]) ?? base?.width;
12192
+ const height = numberAt(doc, ["page", "height"]) ?? base?.height;
12193
+ const tolerance = numberAt(doc, ["page", "tolerance"]) ?? base?.tolerance ?? 0.5;
12194
+ const found = await findCssSize(projectDir, await ownStylesheets(projectDir));
12195
+ return {
12196
+ preset,
12197
+ bounds: width !== undefined && height !== undefined ? { width, height, tolerance } : null,
12198
+ css: found ? { ...found.size, file: relDisplay2(projectDir, found.file) } : null
12199
+ };
12200
+ }
12201
+ async function setPageSetup(projectDir, choice) {
12202
+ const size = pageSizeForChoice(choice);
12203
+ const sheets = await ownStylesheets(projectDir);
12204
+ const found = await findCssSize(projectDir, sheets);
12205
+ const newSheet = path19.join(projectDir, "styles", "book.css");
12206
+ const target = found?.file ?? sheets[sheets.length - 1] ?? newSheet;
12207
+ const before = existsSync13(target) ? await readFile28(target, "utf8") : "";
12208
+ let after;
12209
+ try {
12210
+ after = setPageSizeInCss(before, size);
12211
+ } catch (e) {
12212
+ throw new UsageError(`Could not update the page size: ${relDisplay2(projectDir, target)} has a CSS error ` + `(${e instanceof Error ? e.message : String(e)}). Fix it, then try again.`);
12213
+ }
12214
+ const { doc, file } = await loadManifestDoc(projectDir);
12215
+ doc.set("preset", choice.preset);
12216
+ if (choice.preset === "custom")
12217
+ writePageBounds(doc, size);
12218
+ else
12219
+ doc.delete("page");
12220
+ if (target === newSheet && !sheets.includes(newSheet)) {
12221
+ ensureSeq(doc, "styles").add(new Scalar2("styles/book.css"));
12222
+ }
12223
+ await writeManifestDoc(file, doc);
12224
+ if (after !== before || !existsSync13(target)) {
12225
+ await mkdir8(path19.dirname(target), { recursive: true });
12226
+ await writeFile8(target, after, "utf8");
12227
+ }
12228
+ return readPageSetup(projectDir);
12229
+ }
12230
+
12231
+ // src/lib/project-scaffold.ts
11732
12232
  function starterOverridesCss() {
11733
12233
  return `/**
11734
12234
  * Your book's own styles.
@@ -11757,9 +12257,6 @@ function escapeYamlScalar(value) {
11757
12257
  return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
11758
12258
  }
11759
12259
  var DEFAULT_AUTHOR = "Anonymous";
11760
- function isPositivePoints(value) {
11761
- return typeof value === "number" && Number.isFinite(value) && value > 0;
11762
- }
11763
12260
  function requirePreset(preset, customPage) {
11764
12261
  if (!preset || !PRESET_IDS.includes(preset)) {
11765
12262
  const got = preset ? ` (got "${preset}")` : "";
@@ -11801,7 +12298,7 @@ async function scaffoldProject(options) {
11801
12298
  throw new CreateProjectErrorImpl("invalid-name", `Could not derive a valid folder name from "${name}".`);
11802
12299
  }
11803
12300
  const parentDir = options.parentDir;
11804
- if (!parentDir || !path19.isAbsolute(parentDir)) {
12301
+ if (!parentDir || !path20.isAbsolute(parentDir)) {
11805
12302
  throw new CreateProjectErrorImpl("parent-not-writable", "The save location must be an absolute path.");
11806
12303
  }
11807
12304
  try {
@@ -11809,7 +12306,7 @@ async function scaffoldProject(options) {
11809
12306
  } catch {
11810
12307
  throw new CreateProjectErrorImpl("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
11811
12308
  }
11812
- const projectDir = path19.join(parentDir, folderName);
12309
+ const projectDir = path20.join(parentDir, folderName);
11813
12310
  let targetExists = true;
11814
12311
  try {
11815
12312
  await access(projectDir, FS.F_OK);
@@ -11824,23 +12321,23 @@ async function scaffoldProject(options) {
11824
12321
  const preset = customTemplateDir && options.preset === undefined ? undefined : requirePreset(options.preset, options.customPage);
11825
12322
  const targets = preset ? resolveScaffoldTargets(options.targets, preset) : undefined;
11826
12323
  try {
11827
- await mkdir8(projectDir, { recursive: true });
12324
+ await mkdir9(projectDir, { recursive: true });
11828
12325
  if (customTemplateDir) {
11829
12326
  const entries = await readdir6(customTemplateDir, { withFileTypes: true });
11830
12327
  for (const entry of entries) {
11831
12328
  if (entry.name === ".gutterpress-template.json")
11832
12329
  continue;
11833
- await cp2(path19.join(customTemplateDir, entry.name), path19.join(projectDir, entry.name), { recursive: true });
12330
+ await cp2(path20.join(customTemplateDir, entry.name), path20.join(projectDir, entry.name), { recursive: true });
11834
12331
  }
11835
- await mkdir8(path19.join(projectDir, "assets"), { recursive: true });
12332
+ await mkdir9(path20.join(projectDir, "assets"), { recursive: true });
11836
12333
  } else {
11837
12334
  const tplManifest = await getAssetPath(`templates/${template}/manifest.yaml`);
11838
12335
  const tplChapter = await getAssetPath(`templates/${template}/chapter-01.md`);
11839
- await mkdir8(path19.join(projectDir, "assets"), { recursive: true });
11840
- await copyFile(tplManifest, path19.join(projectDir, MANIFEST_FILENAMES[0]));
11841
- await copyFile(tplChapter, path19.join(projectDir, "chapter-01.md"));
11842
- await mkdir8(path19.join(projectDir, "styles"), { recursive: true });
11843
- await writeFile8(path19.join(projectDir, "styles", "book.css"), starterOverridesCss(), "utf8");
12336
+ await mkdir9(path20.join(projectDir, "assets"), { recursive: true });
12337
+ await copyFile(tplManifest, path20.join(projectDir, MANIFEST_FILENAMES[0]));
12338
+ await copyFile(tplChapter, path20.join(projectDir, "chapter-01.md"));
12339
+ await mkdir9(path20.join(projectDir, "styles"), { recursive: true });
12340
+ await writeFile9(path20.join(projectDir, "styles", "book.css"), starterOverridesCss(), "utf8");
11844
12341
  }
11845
12342
  } catch (e) {
11846
12343
  throw new CreateProjectErrorImpl("scaffold-io", `Could not create the book files: ${e instanceof Error ? e.message : String(e)}`);
@@ -11850,34 +12347,26 @@ async function scaffoldProject(options) {
11850
12347
  } catch (e) {
11851
12348
  throw new CreateProjectErrorImpl("scaffold-io", `Could not write .gitignore: ${e instanceof Error ? e.message : String(e)}`);
11852
12349
  }
11853
- const manifestPath = MANIFEST_FILENAMES.map((name2) => path19.join(projectDir, name2)).find(existsSync13) ?? path19.join(projectDir, MANIFEST_FILENAMES[0]);
12350
+ const manifestPath = MANIFEST_FILENAMES.map((name2) => path20.join(projectDir, name2)).find(existsSync14) ?? path20.join(projectDir, MANIFEST_FILENAMES[0]);
11854
12351
  const author = (options.author ?? "").trim() || DEFAULT_AUTHOR;
11855
12352
  const substitutions = {
11856
12353
  "{{TITLE}}": escapeYamlScalar(name),
11857
12354
  "{{AUTHOR}}": escapeYamlScalar(author)
11858
12355
  };
11859
- let openFile = path19.join(projectDir, "chapter-01.md");
12356
+ let openFile = path20.join(projectDir, "chapter-01.md");
11860
12357
  try {
11861
12358
  await fillTemplateFile(manifestPath, substitutions);
11862
12359
  if (preset) {
11863
12360
  const { doc, file } = await loadManifestDoc(projectDir);
11864
12361
  doc.set("preset", preset);
11865
12362
  doc.set("targets", doc.createNode(targets ?? []));
11866
- if (options.customPage) {
11867
- const page = {
11868
- width: options.customPage.width,
11869
- height: options.customPage.height
11870
- };
11871
- if (options.customPage.tolerance !== undefined) {
11872
- page.tolerance = options.customPage.tolerance;
11873
- }
11874
- doc.set("page", doc.createNode(page));
11875
- }
12363
+ if (options.customPage)
12364
+ writePageBounds(doc, options.customPage);
11876
12365
  await writeManifestDoc(file, doc);
11877
12366
  }
11878
12367
  const firstSource = await firstSourceFile(manifestPath);
11879
12368
  if (firstSource)
11880
- openFile = path19.join(projectDir, firstSource);
12369
+ openFile = path20.join(projectDir, firstSource);
11881
12370
  await fillTemplateFile(openFile, { "{{TITLE}}": name }).catch(() => {});
11882
12371
  } catch (e) {
11883
12372
  throw new CreateProjectErrorImpl("scaffold-io", `Could not finalize the book files: ${e instanceof Error ? e.message : String(e)}`);
@@ -11899,7 +12388,7 @@ async function scaffoldProject(options) {
11899
12388
  versionHistory = "none";
11900
12389
  const { findEnclosingRepoDir } = await import("./project-source-ekcyp63q.js");
11901
12390
  const enclosingRepoDir = await findEnclosingRepoDir(projectDir);
11902
- versionHistoryError = enclosingRepoDir ? `This book was added to ${path19.basename(enclosingRepoDir)}'s shared version history; save a snapshot to record it.` : e instanceof Error ? e.message : String(e);
12391
+ versionHistoryError = enclosingRepoDir ? `This book was added to ${path20.basename(enclosingRepoDir)}'s shared version history; save a snapshot to record it.` : e instanceof Error ? e.message : String(e);
11903
12392
  }
11904
12393
  }
11905
12394
  const result = {
@@ -11919,7 +12408,7 @@ function prettifyFolderName(base) {
11919
12408
  }
11920
12409
  async function adoptFolder(options) {
11921
12410
  const dir = options.dir;
11922
- if (typeof dir !== "string" || !path19.isAbsolute(dir)) {
12411
+ if (typeof dir !== "string" || !path20.isAbsolute(dir)) {
11923
12412
  throw new CreateProjectErrorImpl("scaffold-io", "An absolute folder path is required.");
11924
12413
  }
11925
12414
  let st;
@@ -11931,11 +12420,11 @@ async function adoptFolder(options) {
11931
12420
  if (!st.isDirectory()) {
11932
12421
  throw new CreateProjectErrorImpl("scaffold-io", `Not a folder: ${dir}`);
11933
12422
  }
11934
- if (MANIFEST_FILENAMES.some((name) => existsSync13(path19.join(dir, name)))) {
12423
+ if (MANIFEST_FILENAMES.some((name) => existsSync14(path20.join(dir, name)))) {
11935
12424
  throw new CreateProjectErrorImpl("target-exists", "This folder is already a Gutterpress book.");
11936
12425
  }
11937
12426
  const template = options.template ?? "book";
11938
- const title = (options.title ?? "").trim() || prettifyFolderName(path19.basename(dir));
12427
+ const title = (options.title ?? "").trim() || prettifyFolderName(path20.basename(dir));
11939
12428
  const author = (options.author ?? "").trim() || DEFAULT_AUTHOR;
11940
12429
  let mdFiles;
11941
12430
  try {
@@ -11943,14 +12432,14 @@ async function adoptFolder(options) {
11943
12432
  mdFiles = entries.filter((e) => e.isFile() && /\.(md|markdown)$/i.test(e.name)).map((e) => e.name).sort((a, b) => a.localeCompare(b));
11944
12433
  if (mdFiles.length === 0) {
11945
12434
  const tplChapter = await getAssetPath(`templates/${template}/chapter-01.md`);
11946
- await copyFile(tplChapter, path19.join(dir, "chapter-01.md"));
11947
- await fillTemplateFile(path19.join(dir, "chapter-01.md"), { "{{TITLE}}": title }).catch(() => {});
12435
+ await copyFile(tplChapter, path20.join(dir, "chapter-01.md"));
12436
+ await fillTemplateFile(path20.join(dir, "chapter-01.md"), { "{{TITLE}}": title }).catch(() => {});
11948
12437
  mdFiles = ["chapter-01.md"];
11949
12438
  }
11950
- await mkdir8(path19.join(dir, "styles"), { recursive: true });
11951
- const bookCssPath = path19.join(dir, "styles", "book.css");
11952
- if (!existsSync13(bookCssPath)) {
11953
- await writeFile8(bookCssPath, starterOverridesCss(), "utf8");
12439
+ await mkdir9(path20.join(dir, "styles"), { recursive: true });
12440
+ const bookCssPath = path20.join(dir, "styles", "book.css");
12441
+ if (!existsSync14(bookCssPath)) {
12442
+ await writeFile9(bookCssPath, starterOverridesCss(), "utf8");
11954
12443
  }
11955
12444
  const filesYaml = mdFiles.map((f) => ` - "${escapeYamlScalar(f)}"`).join(`
11956
12445
  `);
@@ -11966,8 +12455,8 @@ ${filesYaml}
11966
12455
  styles:
11967
12456
  - styles/book.css
11968
12457
  `;
11969
- await writeFile8(path19.join(dir, MANIFEST_FILENAMES[0]), manifest, "utf8");
11970
- await mkdir8(path19.join(dir, "assets"), { recursive: true });
12458
+ await writeFile9(path20.join(dir, MANIFEST_FILENAMES[0]), manifest, "utf8");
12459
+ await mkdir9(path20.join(dir, "assets"), { recursive: true });
11971
12460
  await ensureProjectGitignore(dir);
11972
12461
  } catch (e) {
11973
12462
  if (e instanceof CreateProjectErrorImpl)
@@ -11994,8 +12483,8 @@ styles:
11994
12483
  }
11995
12484
  const result = {
11996
12485
  projectDir: dir,
11997
- manifestPath: path19.join(dir, MANIFEST_FILENAMES[0]),
11998
- openFile: path19.join(dir, mdFiles[0]),
12486
+ manifestPath: path20.join(dir, MANIFEST_FILENAMES[0]),
12487
+ openFile: path20.join(dir, mdFiles[0]),
11999
12488
  versionHistory
12000
12489
  };
12001
12490
  if (versionHistoryError !== undefined)
@@ -12005,7 +12494,7 @@ styles:
12005
12494
  async function firstSourceFile(manifestPath) {
12006
12495
  let text;
12007
12496
  try {
12008
- text = await readFile28(manifestPath, "utf8");
12497
+ text = await readFile29(manifestPath, "utf8");
12009
12498
  } catch {
12010
12499
  return;
12011
12500
  }
@@ -12017,16 +12506,16 @@ async function firstSourceFile(manifestPath) {
12017
12506
  return m ? m[1].trim() : undefined;
12018
12507
  }
12019
12508
  async function fillTemplateFile(filePath, substitutions) {
12020
- let text = await readFile28(filePath, "utf8");
12509
+ let text = await readFile29(filePath, "utf8");
12021
12510
  for (const [token, value] of Object.entries(substitutions)) {
12022
12511
  text = text.split(token).join(value);
12023
12512
  }
12024
- await writeFile8(filePath, text, "utf8");
12513
+ await writeFile9(filePath, text, "utf8");
12025
12514
  }
12026
12515
  // src/lib/extension-scaffold.ts
12027
- import { access as access2, mkdir as mkdir9, readFile as readFile29, writeFile as writeFile9 } from "node:fs/promises";
12516
+ import { access as access2, mkdir as mkdir10, readFile as readFile30, writeFile as writeFile10 } from "node:fs/promises";
12028
12517
  import { constants as FS2 } from "node:fs";
12029
- import path20 from "node:path";
12518
+ import path21 from "node:path";
12030
12519
  var EXTENSION_KINDS = ["plugin", "theme"];
12031
12520
  var RESERVED_PREFIX = "gp-";
12032
12521
  var TEMPLATE_FILES = {
@@ -12104,7 +12593,7 @@ async function scaffoldExtension(options) {
12104
12593
  }
12105
12594
  const prefix = resolveExtensionPrefix(slug, options.prefix);
12106
12595
  const parentDir = options.parentDir;
12107
- if (!parentDir || !path20.isAbsolute(parentDir)) {
12596
+ if (!parentDir || !path21.isAbsolute(parentDir)) {
12108
12597
  throw new ExtensionScaffoldError("parent-not-writable", "The save location must be an absolute path.");
12109
12598
  }
12110
12599
  try {
@@ -12112,7 +12601,7 @@ async function scaffoldExtension(options) {
12112
12601
  } catch {
12113
12602
  throw new ExtensionScaffoldError("parent-not-writable", `The chosen save location can't be written to: ${parentDir}`);
12114
12603
  }
12115
- const extensionDir = path20.join(parentDir, slug);
12604
+ const extensionDir = path21.join(parentDir, slug);
12116
12605
  let targetExists = true;
12117
12606
  try {
12118
12607
  await access2(extensionDir, FS2.F_OK);
@@ -12136,14 +12625,14 @@ async function scaffoldExtension(options) {
12136
12625
  const entries = TEMPLATE_FILES[kind];
12137
12626
  const written = [];
12138
12627
  try {
12139
- await mkdir9(extensionDir, { recursive: true });
12628
+ await mkdir10(extensionDir, { recursive: true });
12140
12629
  for (const [assetName, relDest] of entries) {
12141
12630
  const src = await getAssetPath(`extension-templates/${kind}/${assetName}`);
12142
- const dest = path20.join(extensionDir, relDest);
12143
- await mkdir9(path20.dirname(dest), { recursive: true });
12144
- const text = await readFile29(src, "utf8");
12631
+ const dest = path21.join(extensionDir, relDest);
12632
+ await mkdir10(path21.dirname(dest), { recursive: true });
12633
+ const text = await readFile30(src, "utf8");
12145
12634
  const table = relDest.endsWith(".json") ? jsonSafe : plain;
12146
- await writeFile9(dest, substitute(text, table), "utf8");
12635
+ await writeFile10(dest, substitute(text, table), "utf8");
12147
12636
  written.push(relDest);
12148
12637
  }
12149
12638
  } catch (e) {
@@ -12151,11 +12640,11 @@ async function scaffoldExtension(options) {
12151
12640
  }
12152
12641
  return {
12153
12642
  extensionDir,
12154
- manifestPath: path20.join(extensionDir, "package.json"),
12643
+ manifestPath: path21.join(extensionDir, "package.json"),
12155
12644
  kind,
12156
12645
  slug,
12157
12646
  prefix,
12158
- openFile: path20.join(extensionDir, OPEN_FILE[kind]),
12647
+ openFile: path21.join(extensionDir, OPEN_FILE[kind]),
12159
12648
  files: written
12160
12649
  };
12161
12650
  }
@@ -12167,10 +12656,10 @@ function substitute(text, table) {
12167
12656
  return out;
12168
12657
  }
12169
12658
  // src/lib/project-templates.ts
12170
- import { cp as cp3, mkdir as mkdir10, readFile as readFile30, readdir as readdir7, stat as stat7, writeFile as writeFile10 } from "node:fs/promises";
12171
- import { constants as FS3, existsSync as existsSync14 } from "node:fs";
12659
+ import { cp as cp3, mkdir as mkdir11, readFile as readFile31, readdir as readdir7, stat as stat7, writeFile as writeFile11 } from "node:fs/promises";
12660
+ import { constants as FS3, existsSync as existsSync15 } from "node:fs";
12172
12661
  import { access as access3 } from "node:fs/promises";
12173
- import path21 from "node:path";
12662
+ import path22 from "node:path";
12174
12663
  import { isMap as isMap2, isScalar as isScalar2, isSeq as isSeq3, parseDocument as parseDocument2 } from "yaml";
12175
12664
  var BUILT_IN_TEMPLATE_IDS = [
12176
12665
  "book",
@@ -12179,7 +12668,7 @@ var BUILT_IN_TEMPLATE_IDS = [
12179
12668
  ];
12180
12669
  async function readTemplateChoices(manifestPath) {
12181
12670
  try {
12182
- const text = await readFile30(manifestPath, "utf8");
12671
+ const text = await readFile31(manifestPath, "utf8");
12183
12672
  const doc = parseDocument2(text);
12184
12673
  const out = {};
12185
12674
  const preset = doc.get("preset");
@@ -12255,7 +12744,7 @@ function rewriteEntry(node, newPath) {
12255
12744
  node.value = newPath;
12256
12745
  }
12257
12746
  function commonAncestorDir(absFiles) {
12258
- const dirSegs = absFiles.map((f) => path21.resolve(f).split(path21.sep).slice(0, -1));
12747
+ const dirSegs = absFiles.map((f) => path22.resolve(f).split(path22.sep).slice(0, -1));
12259
12748
  if (dirSegs.length === 0)
12260
12749
  return "";
12261
12750
  let common = dirSegs[0];
@@ -12265,22 +12754,22 @@ function commonAncestorDir(absFiles) {
12265
12754
  i++;
12266
12755
  common = common.slice(0, i);
12267
12756
  }
12268
- return common.join(path21.sep) || path21.sep;
12757
+ return common.join(path22.sep) || path22.sep;
12269
12758
  }
12270
12759
  function pickVendorDir(templateDir, base) {
12271
- const preferred = path21.basename(base) || VENDOR_DIR;
12272
- if (!existsSync14(path21.join(templateDir, preferred)))
12760
+ const preferred = path22.basename(base) || VENDOR_DIR;
12761
+ if (!existsSync15(path22.join(templateDir, preferred)))
12273
12762
  return preferred;
12274
12763
  for (let n = 1;; n++) {
12275
12764
  const candidate = `${preferred}-${n}`;
12276
- if (!existsSync14(path21.join(templateDir, candidate)))
12765
+ if (!existsSync15(path22.join(templateDir, candidate)))
12277
12766
  return candidate;
12278
12767
  }
12279
12768
  }
12280
12769
  var TEMPLATE_META_FILE = ".gutterpress-template.json";
12281
12770
  async function reconcileSharedRefs(templateDir, sourceProjectDir, mode) {
12282
12771
  const { doc, file } = await loadManifestDoc(templateDir);
12283
- const escapes = (p) => escapesProjectRoot(sourceProjectDir, path21.resolve(sourceProjectDir, p));
12772
+ const escapes = (p) => escapesProjectRoot(sourceProjectDir, path22.resolve(sourceProjectDir, p));
12284
12773
  const stylesSeq = doc.get("styles", true);
12285
12774
  const extensionsSeq = doc.get("extensions", true);
12286
12775
  const escapingStyles = collectEscaping(isSeq3(stylesSeq) ? stylesSeq.items : [], scalarString, escapes);
@@ -12296,16 +12785,16 @@ async function reconcileSharedRefs(templateDir, sourceProjectDir, mode) {
12296
12785
  return { vendoredRefs: [], excludedRefs };
12297
12786
  }
12298
12787
  const styleClosure = await collectStyleDependencies(sourceProjectDir, escapingStyles.map((s) => s.value));
12299
- const extensionFiles = escapingExtensions.map((p) => path21.resolve(sourceProjectDir, p.value));
12788
+ const extensionFiles = escapingExtensions.map((p) => path22.resolve(sourceProjectDir, p.value));
12300
12789
  const externalFiles = [...new Set([...styleClosure, ...extensionFiles])].filter((f) => escapesProjectRoot(sourceProjectDir, f));
12301
12790
  const base = commonAncestorDir(externalFiles);
12302
12791
  const vendorDir = pickVendorDir(templateDir, base);
12303
- await Promise.all(externalFiles.filter((abs) => existsSync14(abs)).map(async (abs) => {
12304
- const dest = path21.join(templateDir, vendorDir, path21.relative(base, abs));
12305
- await mkdir10(path21.dirname(dest), { recursive: true });
12792
+ await Promise.all(externalFiles.filter((abs) => existsSync15(abs)).map(async (abs) => {
12793
+ const dest = path22.join(templateDir, vendorDir, path22.relative(base, abs));
12794
+ await mkdir11(path22.dirname(dest), { recursive: true });
12306
12795
  await cp3(abs, dest, { recursive: true });
12307
12796
  }));
12308
- const bookLocal = (value) => toPosixPath(path21.join(vendorDir, path21.relative(base, path21.resolve(sourceProjectDir, value))));
12797
+ const bookLocal = (value) => toPosixPath(path22.join(vendorDir, path22.relative(base, path22.resolve(sourceProjectDir, value))));
12309
12798
  const vendoredRefs = [];
12310
12799
  for (const { node, value } of escapingStyles) {
12311
12800
  const rewritten = bookLocal(value);
@@ -12325,7 +12814,7 @@ async function saveProjectAsTemplate(options) {
12325
12814
  const id = slugify(name);
12326
12815
  if (!id)
12327
12816
  throw new Error(`Could not derive a template id from "${name}".`);
12328
- const dir = path21.join(templatesRoot, id);
12817
+ const dir = path22.join(templatesRoot, id);
12329
12818
  let exists = true;
12330
12819
  try {
12331
12820
  await access3(dir, FS3.F_OK);
@@ -12335,20 +12824,20 @@ async function saveProjectAsTemplate(options) {
12335
12824
  if (exists) {
12336
12825
  throw new Error(`A template named "${name}" already exists. Choose a different name.`);
12337
12826
  }
12338
- await mkdir10(dir, { recursive: true });
12827
+ await mkdir11(dir, { recursive: true });
12339
12828
  const entries = await readdir7(projectDir, { withFileTypes: true });
12340
12829
  for (const entry of entries) {
12341
12830
  if (SKIP_ENTRIES.has(entry.name))
12342
12831
  continue;
12343
12832
  if (entry.name === TEMPLATE_META_FILE)
12344
12833
  continue;
12345
- await cp3(path21.join(projectDir, entry.name), path21.join(dir, entry.name), { recursive: true });
12834
+ await cp3(path22.join(projectDir, entry.name), path22.join(dir, entry.name), { recursive: true });
12346
12835
  }
12347
12836
  const manifestName = MANIFEST_FILENAMES.find((name2) => entries.some((entry) => entry.name === name2));
12348
12837
  const shared = manifestName ? await reconcileSharedRefs(dir, projectDir, options.sharedRefs ?? "vendor") : { vendoredRefs: [], excludedRefs: [] };
12349
12838
  if (manifestName)
12350
- await retokeniseManifest(path21.join(dir, manifestName));
12351
- await writeFile10(path21.join(dir, TEMPLATE_META_FILE), JSON.stringify({ label: name }, null, 2), "utf8");
12839
+ await retokeniseManifest(path22.join(dir, manifestName));
12840
+ await writeFile11(path22.join(dir, TEMPLATE_META_FILE), JSON.stringify({ label: name }, null, 2), "utf8");
12352
12841
  return {
12353
12842
  id,
12354
12843
  label: name,
@@ -12361,12 +12850,12 @@ async function saveProjectAsTemplate(options) {
12361
12850
  async function retokeniseManifest(manifestPath) {
12362
12851
  let text;
12363
12852
  try {
12364
- text = await readFile30(manifestPath, "utf8");
12853
+ text = await readFile31(manifestPath, "utf8");
12365
12854
  } catch {
12366
12855
  return;
12367
12856
  }
12368
12857
  text = text.replace(/^title:\s*.*$/m, 'title: "{{TITLE}}"').replace(/^(authors:[^\n]*\n[ \t]*-[ \t]*).*$/m, '$1"{{AUTHOR}}"');
12369
- await writeFile10(manifestPath, text, "utf8");
12858
+ await writeFile11(manifestPath, text, "utf8");
12370
12859
  }
12371
12860
  async function listCustomTemplates(templatesRoot) {
12372
12861
  let entries;
@@ -12379,10 +12868,10 @@ async function listCustomTemplates(templatesRoot) {
12379
12868
  for (const entry of entries) {
12380
12869
  if (!entry.isDirectory())
12381
12870
  continue;
12382
- const dir = path21.join(templatesRoot, entry.name);
12871
+ const dir = path22.join(templatesRoot, entry.name);
12383
12872
  let label = prettify(entry.name);
12384
12873
  try {
12385
- const meta = JSON.parse(await readFile30(path21.join(dir, TEMPLATE_META_FILE), "utf8"));
12874
+ const meta = JSON.parse(await readFile31(path22.join(dir, TEMPLATE_META_FILE), "utf8"));
12386
12875
  if (typeof meta.label === "string" && meta.label)
12387
12876
  label = meta.label;
12388
12877
  } catch {}
@@ -12392,7 +12881,7 @@ async function listCustomTemplates(templatesRoot) {
12392
12881
  description: "Your saved template.",
12393
12882
  kind: "custom",
12394
12883
  dir,
12395
- ...await readTemplateChoices(path21.join(dir, MANIFEST_FILENAMES[0]))
12884
+ ...await readTemplateChoices(path22.join(dir, MANIFEST_FILENAMES[0]))
12396
12885
  });
12397
12886
  }
12398
12887
  out.sort((a, b) => a.label.localeCompare(b.label));
@@ -12404,12 +12893,12 @@ async function importTemplateFromFolder(options) {
12404
12893
  if (!srcStat.isDirectory()) {
12405
12894
  throw new Error("The chosen path is not a folder.");
12406
12895
  }
12407
- const name = options.name?.trim() || prettify(path21.basename(sourceDir));
12896
+ const name = options.name?.trim() || prettify(path22.basename(sourceDir));
12408
12897
  return saveProjectAsTemplate({ projectDir: sourceDir, name, templatesRoot });
12409
12898
  }
12410
12899
  // src/lib/snippets.ts
12411
- import { readdir as readdir8, readFile as readFile31, writeFile as writeFile11, mkdir as mkdir11, rm as rm4 } from "node:fs/promises";
12412
- import path22 from "node:path";
12900
+ import { readdir as readdir8, readFile as readFile32, writeFile as writeFile12, mkdir as mkdir12, rm as rm4 } from "node:fs/promises";
12901
+ import path23 from "node:path";
12413
12902
  var SNIPPETS_DIR = "snippets";
12414
12903
  var LEVEL_RANK = { project: 0, extension: 1, core: 2 };
12415
12904
  var PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
@@ -12429,9 +12918,9 @@ function substituteVariables(template, values) {
12429
12918
  return template.replace(PLACEHOLDER_RE, (_full, name) => Object.prototype.hasOwnProperty.call(values, name) ? values[name] : "");
12430
12919
  }
12431
12920
  function resolveSnippetPath(projectDir, fileName) {
12432
- const dir = path22.resolve(projectDir, SNIPPETS_DIR);
12433
- const full = path22.resolve(dir, fileName);
12434
- if (full !== path22.join(dir, path22.basename(fileName)) || path22.dirname(full) !== dir) {
12921
+ const dir = path23.resolve(projectDir, SNIPPETS_DIR);
12922
+ const full = path23.resolve(dir, fileName);
12923
+ if (full !== path23.join(dir, path23.basename(fileName)) || path23.dirname(full) !== dir) {
12435
12924
  throw new Error(`Unsafe snippet filename: ${fileName}`);
12436
12925
  }
12437
12926
  return full;
@@ -12449,7 +12938,7 @@ async function scanSnippetFiles(dir) {
12449
12938
  continue;
12450
12939
  let body = "";
12451
12940
  try {
12452
- body = await readFile31(path22.join(dir, fileName), "utf8");
12941
+ body = await readFile32(path23.join(dir, fileName), "utf8");
12453
12942
  } catch {
12454
12943
  continue;
12455
12944
  }
@@ -12464,7 +12953,7 @@ async function scanSnippetFiles(dir) {
12464
12953
  return entries;
12465
12954
  }
12466
12955
  async function listSnippets(projectDir) {
12467
- const files = await scanSnippetFiles(path22.join(projectDir, SNIPPETS_DIR));
12956
+ const files = await scanSnippetFiles(path23.join(projectDir, SNIPPETS_DIR));
12468
12957
  return files.map((file) => ({ ...file, source: { kind: "project" } }));
12469
12958
  }
12470
12959
  async function saveSnippet(projectDir, name, body) {
@@ -12472,9 +12961,9 @@ async function saveSnippet(projectDir, name, body) {
12472
12961
  if (!stem)
12473
12962
  throw new Error(`Could not derive a filename from "${name}".`);
12474
12963
  const fileName = `${stem}.md`;
12475
- const dir = path22.join(projectDir, SNIPPETS_DIR);
12476
- await mkdir11(dir, { recursive: true });
12477
- await writeFile11(resolveSnippetPath(projectDir, fileName), body, "utf8");
12964
+ const dir = path23.join(projectDir, SNIPPETS_DIR);
12965
+ await mkdir12(dir, { recursive: true });
12966
+ await writeFile12(resolveSnippetPath(projectDir, fileName), body, "utf8");
12478
12967
  return { name, fileName, variables: extractVariables(body), body, source: { kind: "project" } };
12479
12968
  }
12480
12969
  async function deleteSnippet(projectDir, fileName) {
@@ -12504,7 +12993,7 @@ async function collectLibrary(projectDir) {
12504
12993
  continue;
12505
12994
  const source = { kind: "extension", ref: entry.use, name: entry.label };
12506
12995
  const folder = await declaredSnippetsFolder(entry);
12507
- const snippets2 = folder ? (await scanSnippetFiles(path22.join(entry.dir, folder))).map((file) => ({ ...file, source })) : [];
12996
+ const snippets2 = folder ? (await scanSnippetFiles(path23.join(entry.dir, folder))).map((file) => ({ ...file, source })) : [];
12508
12997
  loaded.push({ entry, source, folder, snippets: snippets2, markers: await declaredMarkers(entry, projectDir) });
12509
12998
  }
12510
12999
  let registry;
@@ -12518,24 +13007,29 @@ async function collectLibrary(projectDir) {
12518
13007
  const resolved = registry.get(name);
12519
13008
  if (!resolved || resolved.deprecated !== undefined)
12520
13009
  continue;
12521
- components.push({ name, source, variants: Object.keys(resolved.variants ?? {}) });
13010
+ components.push({
13011
+ name,
13012
+ source,
13013
+ variants: Object.keys(resolved.variants ?? {}),
13014
+ ...resolved.selfClosing ? { selfClosing: true } : {}
13015
+ });
12522
13016
  if (!entry.dir)
12523
13017
  continue;
12524
13018
  const own = typeof decl.snippet === "string" && decl.snippet.trim() ? decl.snippet.trim() : null;
12525
- const rel = own ?? path22.join(folder ?? SNIPPETS_DIR, `${name}.md`);
13019
+ const rel = own ?? path23.join(folder ?? SNIPPETS_DIR, `${name}.md`);
12526
13020
  if (pathEscapesFolder(rel))
12527
13021
  continue;
12528
- const listed = folder && path22.dirname(path22.join(entry.dir, rel)) === path22.join(entry.dir, folder) ? snippets2.find((s) => s.fileName === path22.basename(rel)) : undefined;
13022
+ const listed = folder && path23.dirname(path23.join(entry.dir, rel)) === path23.join(entry.dir, folder) ? snippets2.find((s) => s.fileName === path23.basename(rel)) : undefined;
12529
13023
  if (listed) {
12530
13024
  listed.component = name;
12531
13025
  continue;
12532
13026
  }
12533
- const body = await readFile31(path22.join(entry.dir, rel), "utf8").catch(() => null);
13027
+ const body = await readFile32(path23.join(entry.dir, rel), "utf8").catch(() => null);
12534
13028
  if (body === null)
12535
13029
  continue;
12536
13030
  snippets2.push({
12537
- name: prettify(path22.basename(rel).replace(/\.md$/i, "")),
12538
- fileName: rel.split(path22.sep).join("/"),
13031
+ name: prettify(path23.basename(rel).replace(/\.md$/i, "")),
13032
+ fileName: rel.split(path23.sep).join("/"),
12539
13033
  variables: extractVariables(body),
12540
13034
  body,
12541
13035
  source,
@@ -12579,10 +13073,10 @@ async function listMarkerComponents(projectDir) {
12579
13073
  return (await collectLibrary(projectDir)).components;
12580
13074
  }
12581
13075
  // src/lib/extension-import.ts
12582
- import { existsSync as existsSync15 } from "node:fs";
12583
- import { cp as cp4, mkdir as mkdir12, mkdtemp as mkdtemp2, readFile as readFile32, rm as rm5, writeFile as writeFile12 } from "node:fs/promises";
13076
+ import { existsSync as existsSync16 } from "node:fs";
13077
+ import { cp as cp4, mkdir as mkdir13, mkdtemp as mkdtemp2, readFile as readFile33, rm as rm5, writeFile as writeFile13 } from "node:fs/promises";
12584
13078
  import { tmpdir as tmpdir2 } from "node:os";
12585
- import path23 from "node:path";
13079
+ import path24 from "node:path";
12586
13080
  import { unzipSync } from "fflate";
12587
13081
  var MAX_THEME_ARCHIVE_BYTES = 25 * 1024 * 1024;
12588
13082
  var MAX_THEME_UNZIPPED_BYTES = 25 * 1024 * 1024;
@@ -12649,11 +13143,11 @@ function mb2(n) {
12649
13143
  return `${Math.round(n / (1024 * 1024))}MB`;
12650
13144
  }
12651
13145
  async function finalizeExtensionImport(projectDir, sourceDir, relPaths, fallbackName) {
12652
- const cssPath = path23.join(sourceDir, "theme.css");
12653
- if (!existsSync15(cssPath)) {
13146
+ const cssPath = path24.join(sourceDir, "theme.css");
13147
+ if (!existsSync16(cssPath)) {
12654
13148
  throw new Error("A theme must contain a theme.css file.");
12655
13149
  }
12656
- const css = await readFile32(cssPath, "utf8");
13150
+ const css = await readFile33(cssPath, "utf8");
12657
13151
  const { reject, warnings: printFindings } = classifyThemeCssFindings(checkCss(css, "theme.css"));
12658
13152
  if (reject) {
12659
13153
  throw new Error(`theme.css could not be parsed — ${reject.message}`);
@@ -12662,7 +13156,7 @@ async function finalizeExtensionImport(projectDir, sourceDir, relPaths, fallback
12662
13156
  for (const w of printFindings) {
12663
13157
  warnings.push({ code: "print-safety", message: w.message });
12664
13158
  }
12665
- const metaExists = existsSync15(path23.join(sourceDir, EXTENSION_MANIFEST_FILENAME));
13159
+ const metaExists = existsSync16(path24.join(sourceDir, EXTENSION_MANIFEST_FILENAME));
12666
13160
  const meta = metaExists ? await readExtensionMeta(sourceDir) : {};
12667
13161
  if (!metaExists) {
12668
13162
  warnings.push({
@@ -12681,7 +13175,7 @@ async function finalizeExtensionImport(projectDir, sourceDir, relPaths, fallback
12681
13175
  for (let i = 0;i < extraSheets.length; i++) {
12682
13176
  const rel = extraSheets[i];
12683
13177
  const sheetPath = extraSheetPaths[i];
12684
- const sheetCss = await readFile32(sheetPath, "utf8");
13178
+ const sheetCss = await readFile33(sheetPath, "utf8");
12685
13179
  const { reject: sheetReject, warnings: sheetFindings } = classifyThemeCssFindings(checkCss(sheetCss, rel));
12686
13180
  if (sheetReject) {
12687
13181
  throw new Error(`${rel} could not be parsed — ${sheetReject.message}`);
@@ -12700,8 +13194,8 @@ async function finalizeExtensionImport(projectDir, sourceDir, relPaths, fallback
12700
13194
  });
12701
13195
  }
12702
13196
  const id = await uniqueExtensionId(projectDir, meta.name?.trim() || fallbackName);
12703
- const dest = path23.join(projectDir, EXTENSIONS_DIR, id);
12704
- await mkdir12(path23.dirname(dest), { recursive: true });
13197
+ const dest = path24.join(projectDir, EXTENSIONS_DIR, id);
13198
+ await mkdir13(path24.dirname(dest), { recursive: true });
12705
13199
  await cp4(sourceDir, dest, { recursive: true });
12706
13200
  await completeLandedManifest(dest, id, meta, metaExists);
12707
13201
  const entry = await addExtension(projectDir, `./${EXTENSIONS_DIR}/${id}`);
@@ -12710,18 +13204,18 @@ async function finalizeExtensionImport(projectDir, sourceDir, relPaths, fallback
12710
13204
  async function completeLandedManifest(dest, id, meta, metaExists) {
12711
13205
  if (metaExists && extensionStyleList(meta).length > 0)
12712
13206
  return;
12713
- const file = path23.join(dest, EXTENSION_MANIFEST_FILENAME);
13207
+ const file = path24.join(dest, EXTENSION_MANIFEST_FILENAME);
12714
13208
  let pkg = {};
12715
13209
  if (metaExists) {
12716
13210
  try {
12717
- const parsed = JSON.parse(await readFile32(file, "utf8"));
13211
+ const parsed = JSON.parse(await readFile33(file, "utf8"));
12718
13212
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
12719
13213
  pkg = parsed;
12720
13214
  }
12721
13215
  } catch {}
12722
13216
  }
12723
13217
  const gutterpress = pkg.gutterpress && typeof pkg.gutterpress === "object" && !Array.isArray(pkg.gutterpress) ? pkg.gutterpress : {};
12724
- await writeFile12(file, JSON.stringify({
13218
+ await writeFile13(file, JSON.stringify({
12725
13219
  ...pkg,
12726
13220
  name: typeof pkg.name === "string" && pkg.name.trim() ? pkg.name : id,
12727
13221
  gutterpress: { ...gutterpress, styles: ["theme.css"] }
@@ -12768,7 +13262,7 @@ async function importExtensionFromZip(projectDir, archive) {
12768
13262
  throw new Error("No theme.css found in the package. A theme needs a theme.css at the package root or inside a single folder.");
12769
13263
  }
12770
13264
  const prefix = root === "" ? "" : `${root}/`;
12771
- const tmp = await mkdtemp2(path23.join(tmpdir2(), "gutterpress-theme-"));
13265
+ const tmp = await mkdtemp2(path24.join(tmpdir2(), "gutterpress-theme-"));
12772
13266
  try {
12773
13267
  const relPaths = [];
12774
13268
  for (const [name, data] of Object.entries(files)) {
@@ -12780,13 +13274,13 @@ async function importExtensionFromZip(projectDir, archive) {
12780
13274
  const rel = prefix ? norm.slice(prefix.length) : norm;
12781
13275
  if (!rel || rel.split("/").some((seg) => seg === ".."))
12782
13276
  continue;
12783
- const dest = path23.join(tmp, rel);
12784
- if (dest !== tmp && !dest.startsWith(tmp + path23.sep)) {
13277
+ const dest = path24.join(tmp, rel);
13278
+ if (dest !== tmp && !dest.startsWith(tmp + path24.sep)) {
12785
13279
  throw new Error(`The package contains an unsafe path and was rejected: ${name}`);
12786
13280
  }
12787
13281
  relPaths.push(rel);
12788
- await mkdir12(path23.dirname(dest), { recursive: true });
12789
- await writeFile12(dest, data);
13282
+ await mkdir13(path24.dirname(dest), { recursive: true });
13283
+ await writeFile13(dest, data);
12790
13284
  }
12791
13285
  return await finalizeExtensionImport(projectDir, tmp, relPaths, root || "imported-look");
12792
13286
  } finally {
@@ -12799,10 +13293,10 @@ async function importExtensionFromCssText(projectDir, css, name) {
12799
13293
  throw new Error(`The CSS could not be parsed — ${reject.message}`);
12800
13294
  }
12801
13295
  const displayName = name.trim() || "Imported theme";
12802
- const tmp = await mkdtemp2(path23.join(tmpdir2(), "gutterpress-theme-"));
13296
+ const tmp = await mkdtemp2(path24.join(tmpdir2(), "gutterpress-theme-"));
12803
13297
  try {
12804
- await writeFile12(path23.join(tmp, "theme.css"), css, "utf8");
12805
- await writeFile12(path23.join(tmp, EXTENSION_MANIFEST_FILENAME), JSON.stringify({ name: slugify(displayName, "look"), gutterpress: { styles: ["theme.css"] } }, null, 2), "utf8");
13298
+ await writeFile13(path24.join(tmp, "theme.css"), css, "utf8");
13299
+ await writeFile13(path24.join(tmp, EXTENSION_MANIFEST_FILENAME), JSON.stringify({ name: slugify(displayName, "look"), gutterpress: { styles: ["theme.css"] } }, null, 2), "utf8");
12806
13300
  const result = await finalizeExtensionImport(projectDir, tmp, ["theme.css", EXTENSION_MANIFEST_FILENAME], displayName);
12807
13301
  result.warnings.unshift({
12808
13302
  code: "no-theme-json",
@@ -12814,14 +13308,14 @@ async function importExtensionFromCssText(projectDir, css, name) {
12814
13308
  }
12815
13309
  }
12816
13310
  async function importExtensionFromFile(projectDir, filePath) {
12817
- const ext = path23.extname(filePath).toLowerCase();
13311
+ const ext = path24.extname(filePath).toLowerCase();
12818
13312
  if (ext === ".zip") {
12819
- const buf = await readFile32(filePath);
13313
+ const buf = await readFile33(filePath);
12820
13314
  return importExtensionFromZip(projectDir, new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
12821
13315
  }
12822
13316
  if (ext === ".css") {
12823
- const css = await readFile32(filePath, "utf8");
12824
- return importExtensionFromCssText(projectDir, css, prettify(path23.basename(filePath, ".css")));
13317
+ const css = await readFile33(filePath, "utf8");
13318
+ return importExtensionFromCssText(projectDir, css, prettify(path24.basename(filePath, ".css")));
12825
13319
  }
12826
13320
  throw new Error("Choose a .zip extension package or a .css stylesheet.");
12827
13321
  }
@@ -12869,7 +13363,7 @@ async function importExtensionFromUrl(projectDir, url) {
12869
13363
  let baseName;
12870
13364
  if (looksLikeCssUrl(trimmed)) {
12871
13365
  css = await fetchText(trimmed);
12872
- baseName = path23.basename(new URL(trimmed, "https://x/").pathname, ".css") || "look";
13366
+ baseName = path24.basename(new URL(trimmed, "https://x/").pathname, ".css") || "look";
12873
13367
  } else {
12874
13368
  const base = trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
12875
13369
  css = await fetchText(`${base}theme.css`);
@@ -12878,14 +13372,14 @@ async function importExtensionFromUrl(projectDir, url) {
12878
13372
  if (parsed && typeof parsed === "object")
12879
13373
  meta = parsed;
12880
13374
  } catch {}
12881
- baseName = typeof meta.name === "string" && meta.name || path23.basename(new URL(base, "https://x/").pathname.replace(/\/$/, "")) || "look";
13375
+ baseName = typeof meta.name === "string" && meta.name || path24.basename(new URL(base, "https://x/").pathname.replace(/\/$/, "")) || "look";
12882
13376
  }
12883
13377
  if (!css.trim())
12884
13378
  throw new Error(`Fetched CSS from ${trimmed} was empty.`);
12885
13379
  assertLooksLikeCss(css, trimmed);
12886
- const tmp = await mkdtemp2(path23.join(tmpdir2(), "gutterpress-ext-"));
13380
+ const tmp = await mkdtemp2(path24.join(tmpdir2(), "gutterpress-ext-"));
12887
13381
  try {
12888
- await writeFile12(path23.join(tmp, "theme.css"), css, "utf8");
13382
+ await writeFile13(path24.join(tmp, "theme.css"), css, "utf8");
12889
13383
  const gp = meta.gutterpress && typeof meta.gutterpress === "object" && !Array.isArray(meta.gutterpress) ? meta.gutterpress : {};
12890
13384
  const finalMeta = {
12891
13385
  name: typeof meta.name === "string" && meta.name || slugify(baseName, "look"),
@@ -12897,7 +13391,7 @@ async function importExtensionFromUrl(projectDir, url) {
12897
13391
  preview: typeof gp.preview === "string" ? gp.preview : null
12898
13392
  }
12899
13393
  };
12900
- await writeFile12(path23.join(tmp, EXTENSION_MANIFEST_FILENAME), JSON.stringify(finalMeta, null, 2), "utf8");
13394
+ await writeFile13(path24.join(tmp, EXTENSION_MANIFEST_FILENAME), JSON.stringify(finalMeta, null, 2), "utf8");
12901
13395
  return await finalizeExtensionImport(projectDir, tmp, ["theme.css", EXTENSION_MANIFEST_FILENAME], baseName);
12902
13396
  } finally {
12903
13397
  await rm5(tmp, { recursive: true, force: true });
@@ -13018,7 +13512,7 @@ async function searchNpmExtensions(query, options = {}) {
13018
13512
  return { matches, total: pages.reduce((sum, page) => sum + page.total, 0) };
13019
13513
  }
13020
13514
  // src/lib/manifest-config.ts
13021
- import { isSeq as isSeq4, isMap as isMap3, YAMLSeq as YAMLSeq3, Scalar as Scalar2 } from "yaml";
13515
+ import { isSeq as isSeq4, isMap as isMap3, YAMLSeq as YAMLSeq3, Scalar as Scalar3 } from "yaml";
13022
13516
  function hasKey(obj, key) {
13023
13517
  return Object.prototype.hasOwnProperty.call(obj, key);
13024
13518
  }
@@ -13101,7 +13595,7 @@ async function setActiveStyles(projectDir, paths) {
13101
13595
  } else {
13102
13596
  const seq = new YAMLSeq3(doc.schema);
13103
13597
  for (const p of paths)
13104
- seq.add(new Scalar2(p));
13598
+ seq.add(new Scalar3(p));
13105
13599
  doc.set("styles", seq);
13106
13600
  }
13107
13601
  await writeManifestDoc(file, doc);
@@ -13643,7 +14137,8 @@ async function listGitHubRepositories(credential, options = {}) {
13643
14137
  fullName: r.full_name,
13644
14138
  private: r.private,
13645
14139
  defaultBranch: r.default_branch,
13646
- htmlUrl: r.html_url
14140
+ htmlUrl: r.html_url,
14141
+ ...r.size === 0 && !r.archived && r.permissions?.push !== false ? { maybeEmpty: true } : {}
13647
14142
  });
13648
14143
  }
13649
14144
  if (body.length < PER_PAGE)
@@ -13708,10 +14203,12 @@ async function listGitHubBranches(credential, owner, repo, options = {}) {
13708
14203
  }
13709
14204
  return branches;
13710
14205
  }
13711
- // src/lib/remote-auth/clone.ts
13712
- import { mkdir as mkdir13, readdir as readdir9, rm as rm6 } from "node:fs/promises";
13713
- import path24 from "node:path";
13714
- import git3 from "isomorphic-git";
14206
+ // src/lib/remote-auth/online-backup.ts
14207
+ import git5 from "isomorphic-git";
14208
+
14209
+ // src/lib/remote-auth/sync.ts
14210
+ import git4 from "isomorphic-git";
14211
+ import { randomUUID as randomUUID4 } from "node:crypto";
13715
14212
 
13716
14213
  // src/lib/remote-auth/git-http.ts
13717
14214
  import httpNode from "isomorphic-git/http/node";
@@ -13804,617 +14301,68 @@ function withIdleTimeout(http3, idleMs = GIT_HTTP_IDLE_TIMEOUT_MS, uploadMs = GI
13804
14301
  }
13805
14302
  var defaultGitHttp = withIdleTimeout(httpNode);
13806
14303
 
13807
- // src/lib/remote-auth/transport.ts
14304
+ // src/lib/remote-auth/converge-merge.ts
14305
+ import { mkdir as mkdir14, unlink as unlink3, writeFile as writeFile14 } from "node:fs/promises";
14306
+ import path25 from "node:path";
13808
14307
  import git2 from "isomorphic-git";
13809
- async function repoDirFor(projectDir) {
13810
- const source = await detectProjectSource(projectDir);
13811
- if (source.type === "local-git-folder")
13812
- return gitScopeFor(source);
13813
- return projectDir;
13814
- }
13815
- function isCredentialTransmissionSafe(url) {
13816
- try {
13817
- const u = new URL(url);
13818
- if (u.protocol === "https:")
13819
- return true;
13820
- if (u.protocol === "http:") {
13821
- const host = u.hostname.toLowerCase();
13822
- return host === "127.0.0.1" || host === "[::1]" || host === "localhost";
13823
- }
13824
- return false;
13825
- } catch {
13826
- return false;
13827
- }
13828
- }
13829
-
13830
- class InsecureTransportError extends Error {
13831
- code = "InsecureTransport";
13832
- constructor() {
13833
- super("This online address isn't secure, so the saved connection wasn't sent — " + "connections are never sent over an insecure address. Switch the address " + "to a secure one (starting with https) to sync with a saved connection.");
13834
- this.name = "InsecureTransportError";
13835
- }
14308
+ import diff3Merge from "diff3";
14309
+ function isMergeConflictError(e) {
14310
+ return e?.code === "MergeConflictError";
13836
14311
  }
13837
- function isInsecureTransportError(e) {
13838
- return e?.code === "InsecureTransport";
14312
+ function isCheckoutConflict(e) {
14313
+ return e?.code === "CheckoutConflictError";
13839
14314
  }
13840
- function onAuthFor(credential) {
13841
- if (!credential)
13842
- return {};
13843
- return {
13844
- onAuth: (url) => {
13845
- if (!isCredentialTransmissionSafe(url))
13846
- throw new InsecureTransportError;
13847
- return {
13848
- username: credential.kind === "github-oauth" ? "x-access-token" : credential.username || credential.token,
13849
- password: credential.token
13850
- };
13851
- }
13852
- };
14315
+ var CONVERGE_PREPARE_MESSAGE = "Getting your changes ready to combine with the online version";
14316
+ var CONVERGE_RESTORE_MESSAGE = "Kept both versions of the files that can't be combined";
14317
+ var CONVERGE_MERGE_MESSAGE = "Combined your changes with the online version";
14318
+ var MARKER_UNSAFE_EXT = /\.svg$/i;
14319
+ var OUR_LABEL = "your version";
14320
+ var THEIR_LABEL = "online version";
14321
+ function stringLooksBinary(s) {
14322
+ return s.includes("\x00");
13853
14323
  }
13854
- async function resolveTransport(dir, options) {
13855
- const origin = await syncRemoteFor(dir);
13856
- if (!origin?.url)
13857
- throw new Error(MSG_NO_REMOTE);
13858
- const { cleanUrl, credential: urlCredential } = extractUrlCredential(origin.url);
13859
- let parsed;
13860
- try {
13861
- parsed = new URL(cleanUrl);
13862
- } catch {
13863
- throw new Error(MSG_SSH_REMOTE);
13864
- }
13865
- if (!/^https?:$/.test(parsed.protocol))
13866
- throw new Error(MSG_SSH_REMOTE);
13867
- const host = credentialHostKey(cleanUrl);
13868
- let credential = options.credential ?? undefined;
13869
- if (!credential && options.tokenStore) {
13870
- try {
13871
- credential = await options.tokenStore.get(host) ?? undefined;
13872
- } catch {
13873
- credential = undefined;
14324
+ var LINEBREAKS = /^.*(\r?\n|$)/gm;
14325
+ function mergeWithMarkers(baseContent, ourContent, theirContent) {
14326
+ const result = diff3Merge(ourContent.match(LINEBREAKS) ?? [], baseContent.match(LINEBREAKS) ?? [], theirContent.match(LINEBREAKS) ?? []);
14327
+ let merged = "";
14328
+ for (const item of result) {
14329
+ if (item.ok)
14330
+ merged += item.ok.join("");
14331
+ if (item.conflict) {
14332
+ merged += `<<<<<<< ${OUR_LABEL}
14333
+ `;
14334
+ merged += item.conflict.a.join("");
14335
+ merged += `=======
14336
+ `;
14337
+ merged += item.conflict.b.join("");
14338
+ merged += `>>>>>>> ${THEIR_LABEL}
14339
+ `;
13874
14340
  }
13875
14341
  }
13876
- if (!credential && urlCredential)
13877
- credential = urlCredential;
13878
- return {
13879
- remote: origin.remote,
13880
- url: cleanUrl,
13881
- host,
13882
- ...credential ? { credential } : {}
13883
- };
13884
- }
13885
- function classifyTransportFailure(e) {
13886
- if (isInsecureTransportError(e))
13887
- return "insecure_transport";
13888
- const err = e;
13889
- if (err?.code === "HttpError") {
13890
- const status = err.data?.statusCode;
13891
- if (status === 401 || status === 403 || status === 404)
13892
- return "auth_required";
13893
- }
13894
- const msg = `${err?.message ?? String(e)} ${err?.data?.prettyDetails ?? ""}`;
13895
- if (/\b401\b|\b403\b|\b404\b|unauthorized|authentication|not authorized|permission denied|forbidden|access denied|not allowed to push|pre-receive hook declined|hook declined/i.test(msg)) {
13896
- return "auth_required";
13897
- }
13898
- if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|fetch failed|couldn't reach|socket hang ?up/i.test(msg)) {
13899
- return "network_unavailable";
13900
- }
13901
- return null;
14342
+ return merged;
13902
14343
  }
13903
- function failureOutcome(e, snapshotId) {
13904
- const kind = classifyTransportFailure(e);
13905
- const base = snapshotId ? { snapshotId } : {};
13906
- if (kind === "auth_required")
13907
- return { status: "auth", message: MSG_AUTH, ...base };
13908
- if (kind === "network_unavailable")
13909
- return { status: "offline", message: MSG_OFFLINE, ...base };
13910
- if (kind === "insecure_transport") {
13911
- return { status: "error", message: MSG_INSECURE_TRANSPORT, ...base };
13912
- }
13913
- return {
13914
- status: "error",
13915
- message: "Syncing didn't complete. Your work is saved on this computer — please try again.",
13916
- ...base
13917
- };
14344
+ async function readBlob(dir, cache2, oid, filepath) {
14345
+ return git2.readBlob({ fs: gitFs, dir, cache: cache2, oid, filepath });
13918
14346
  }
13919
- function setupErrorMessage(e) {
13920
- if (e instanceof Error && (e.message === MSG_NO_REMOTE || e.message === MSG_SSH_REMOTE || e.message === MSG_NO_BRANCH)) {
13921
- return e.message;
13922
- }
13923
- return null;
13924
- }
13925
- async function snapshotBeforeAction(args) {
13926
- const { projectDir, dir, cache: cache2 } = args;
13927
- const hasChanges = await hasPendingChanges(dir, cache2);
13928
- const staleStaging = gitFs.existsSync(snapshotStagingMarkerPath(dir));
13929
- if (!hasChanges && !staleStaging)
13930
- return;
13931
- const snap = await snapshotWorkingTreeUnlocked({
13932
- projectDir,
13933
- repoRoot: dir,
13934
- message: args.message?.trim() || SYNC_SNAPSHOT_MESSAGE,
13935
- authorName: args.authorName,
13936
- authorEmail: args.authorEmail,
13937
- cache: cache2
13938
- });
13939
- return snap.id;
13940
- }
13941
- async function currentBranchOrThrow(dir) {
13942
- const branch = await git2.currentBranch({ fs: gitFs, dir });
13943
- if (!branch)
13944
- throw new Error(MSG_NO_BRANCH);
13945
- return branch;
13946
- }
13947
- async function resolveRefOrNull(dir, ref) {
13948
- try {
13949
- return await git2.resolveRef({ fs: gitFs, dir, ref });
13950
- } catch {
13951
- return null;
13952
- }
13953
- }
13954
- async function guardTrackingRef(dir, ref, cache2, fn) {
13955
- const before = await resolveRefOrNull(dir, ref);
13956
- try {
13957
- return await fn();
13958
- } catch (e) {
13959
- try {
13960
- const after = await resolveRefOrNull(dir, ref);
13961
- if (after && after !== before) {
13962
- const landed = await git2.readObject({ fs: gitFs, dir, oid: after, cache: cache2 }).then(() => true, () => false);
13963
- if (!landed) {
13964
- if (before)
13965
- await git2.writeRef({ fs: gitFs, dir, ref, value: before, force: true });
13966
- else
13967
- await git2.deleteRef({ fs: gitFs, dir, ref });
13968
- }
13969
- }
13970
- } catch {}
13971
- throw e;
13972
- }
13973
- }
13974
- async function fetchRemoteTip(dir, branch, transport, http3, cache2) {
13975
- try {
13976
- const trackingRef = `refs/remotes/${transport.remote}/${branch}`;
13977
- const result = await guardTrackingRef(dir, trackingRef, cache2, () => git2.fetch({
13978
- fs: gitFs,
13979
- http: http3,
13980
- dir,
13981
- cache: cache2,
13982
- remote: transport.remote,
13983
- ref: trackingRef,
13984
- remoteRef: branch,
13985
- singleBranch: true,
13986
- tags: false,
13987
- ...onAuthFor(transport.credential)
13988
- }));
13989
- return result.fetchHead ?? null;
13990
- } catch (e) {
13991
- const code = e?.code;
13992
- if (code === "NoRefspecError")
13993
- return null;
13994
- if (code === "NotFoundError" || e?.data?.statusCode === 404) {
13995
- const err = new Error("The online repository couldn't be accessed with the saved connection. Reconnect and try again.", { cause: e });
13996
- err.code = "HttpError";
13997
- err.data = { statusCode: 401 };
13998
- throw err;
13999
- }
14000
- throw e;
14001
- }
14002
- }
14003
- function short(oid) {
14004
- return oid ? oid.slice(0, 8) : "none";
14005
- }
14006
-
14007
- // src/lib/remote-auth/clone.ts
14008
- function sanitizeCloneFolderName(name) {
14009
- return String(name ?? "").replace(/[\\/]/g, "-").replace(/^\.+/, "").trim();
14010
- }
14011
- async function assertCloneTarget(dir) {
14012
- try {
14013
- const entries = await readdir9(dir);
14014
- if (entries.length > 0) {
14015
- throw new Error("That folder already has files in it. Choose an empty folder for the book.");
14016
- }
14017
- } catch (e) {
14018
- if (e.code === "ENOENT")
14019
- return;
14020
- throw e;
14021
- }
14022
- }
14023
- function friendlyCloneError(e) {
14024
- if (isInsecureTransportError(e)) {
14025
- return new Error("That repository address isn't secure, so the saved connection wasn't sent — connections are never sent over an insecure address. Use a secure address (starting with https), or a local loopback address for a server on this computer.", { cause: e });
14026
- }
14027
- const msg = e instanceof Error ? e.message : String(e);
14028
- if (/401|403|auth|credential/i.test(msg)) {
14029
- return new Error("GitHub didn't accept the connection for this repository. Reconnect GitHub and try again.", { cause: e });
14030
- }
14031
- if (/404|not found/i.test(msg)) {
14032
- return new Error("That repository couldn't be found. It may have been renamed or you may no longer have access.", { cause: e });
14033
- }
14034
- if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network|fetch failed/i.test(msg)) {
14035
- return new Error(OFFLINE_MESSAGE, { cause: e });
14036
- }
14037
- if (e instanceof Error && /already has files/i.test(e.message)) {
14038
- return e;
14039
- }
14040
- return new Error("The book couldn't be downloaded from GitHub. Please try again.", { cause: e });
14041
- }
14042
- async function cloneRepository(options) {
14043
- const { dir, onProgress, tokenStore } = options;
14044
- if (!path24.isAbsolute(dir)) {
14045
- throw new Error("cloneRepository requires an absolute destination path.");
14046
- }
14047
- let parsed;
14048
- try {
14049
- parsed = new URL(options.url);
14050
- } catch {
14051
- throw new Error("That repository address is not a valid web URL.");
14052
- }
14053
- if (!/^https?:$/.test(parsed.protocol)) {
14054
- throw new Error("Only HTTPS repository addresses are supported. SSH addresses (git@…) can't be used here.");
14055
- }
14056
- const { cleanUrl, credential: urlCredential } = extractUrlCredential(options.url);
14057
- const credential = options.credential ?? urlCredential;
14058
- if (urlCredential && tokenStore && !await tokenStore.get(urlCredential.host)) {
14059
- await tokenStore.set(urlCredential.host, urlCredential);
14060
- }
14061
- return withRepoLock(dir, async () => {
14062
- await assertCloneTarget(dir);
14063
- const dirExistedBefore = gitFs.existsSync(dir);
14064
- await mkdir13(dir, { recursive: true });
14065
- try {
14066
- await git3.clone({
14067
- fs: gitFs,
14068
- http: options.httpClient ?? defaultGitHttp,
14069
- dir,
14070
- url: cleanUrl,
14071
- singleBranch: true,
14072
- ...options.branch ? { ref: options.branch } : {},
14073
- ...options.depth ? { depth: options.depth } : {},
14074
- ...onAuthFor(credential),
14075
- ...onProgress ? {
14076
- onProgress: (p) => onProgress({
14077
- phase: p.phase,
14078
- loaded: p.loaded,
14079
- ...p.total ? { total: p.total } : {}
14080
- })
14081
- } : {}
14082
- });
14083
- } catch (e) {
14084
- const friendly = friendlyCloneError(e);
14085
- if (!dirExistedBefore) {
14086
- try {
14087
- await rm6(dir, { recursive: true, force: true });
14088
- } catch {
14089
- throw new Error(`${friendly.message} A partial download was left at ${dir} — delete that folder before trying again.`, { cause: e });
14090
- }
14091
- }
14092
- throw friendly;
14093
- }
14094
- let branch;
14095
- try {
14096
- branch = await git3.currentBranch({ fs: gitFs, dir }) ?? undefined;
14097
- } catch {
14098
- branch = undefined;
14099
- }
14100
- return { projectDir: dir, ...branch ? { branch } : {} };
14101
- });
14102
- }
14103
- // src/lib/remote-auth/test-access.ts
14104
- import git4 from "isomorphic-git";
14105
- import httpNode2 from "isomorphic-git/http/node";
14106
- function isSshRemoteUrl(url) {
14107
- const trimmed = url.trim();
14108
- if (/^ssh:\/\//i.test(trimmed))
14109
- return true;
14110
- return /^[\w.-]+@[\w.][\w.-]*:/.test(trimmed) || /^[\w.-]+@\[/.test(trimmed);
14111
- }
14112
- var FAILURE_MESSAGES = {
14113
- auth: "The Git server didn't accept the saved connection. Reconnect to this server with a current access token and try again.",
14114
- "not-found": "The server answered, but no repository was found at that address. It may have been moved, renamed, or you may no longer have access.",
14115
- unreachable: "Couldn't reach the Git server. Check your internet connection (and VPN, if this is a private server), then try again.",
14116
- "ssh-unsupported": "This book's online address uses SSH (git@…), which gutterpress can't check or sync with. Everything on this computer still works — sync with your usual Git tool.",
14117
- "insecure-transport": "This address isn't secure, so the saved connection wasn't sent — connections are never sent over an insecure address. Switch the address to a secure one (starting with https), or to a local loopback address for a server on this computer, to use a saved connection.",
14118
- tls: "The server's security certificate couldn't be verified. If this is a private server with its own certificate, ask its administrator about trusting it (NODE_EXTRA_CA_CERTS).",
14119
- unknown: "The connection test failed unexpectedly. The server may not be a Git server, or it may be temporarily unavailable."
14120
- };
14121
- function failure(reason) {
14122
- return { ok: false, reason, message: FAILURE_MESSAGES[reason] };
14123
- }
14124
- function classifyRemoteAccessError(e) {
14125
- if (isInsecureTransportError(e))
14126
- return failure("insecure-transport");
14127
- const msg = e instanceof Error ? e.message : String(e);
14128
- if (/401|403|auth|credential|denied|cancel/i.test(msg))
14129
- return failure("auth");
14130
- if (/404|not found/i.test(msg))
14131
- return failure("not-found");
14132
- if (/certificate|self.signed|unable_to_verify|cert_|tls|ssl/i.test(msg) && !/fetch failed/i.test(msg)) {
14133
- return failure("tls");
14134
- }
14135
- if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|network|fetch failed|timed out/i.test(msg)) {
14136
- return failure("unreachable");
14137
- }
14138
- return failure("unknown");
14139
- }
14140
- async function testRemoteAccess(options) {
14141
- if (isSshRemoteUrl(options.url))
14142
- return failure("ssh-unsupported");
14143
- let parsed;
14144
- try {
14145
- parsed = new URL(options.url);
14146
- } catch {
14147
- return failure("unknown");
14148
- }
14149
- if (!/^https?:$/.test(parsed.protocol))
14150
- return failure("ssh-unsupported");
14151
- const { cleanUrl, credential: urlCredential } = extractUrlCredential(options.url);
14152
- const credential = options.credential ?? urlCredential;
14153
- const timeoutMs = options.timeoutMs ?? 15000;
14154
- let timer;
14155
- const timeout = new Promise((resolve9) => {
14156
- timer = setTimeout(() => resolve9(failure("unreachable")), timeoutMs);
14157
- });
14158
- const probe = (async () => {
14159
- try {
14160
- const refs = await git4.listServerRefs({
14161
- http: options.httpClient ?? httpNode2,
14162
- url: cleanUrl,
14163
- protocolVersion: 1,
14164
- symrefs: true,
14165
- ...onAuthFor(credential),
14166
- ...credential ? { onAuthFailure: () => ({ cancel: true }) } : {}
14167
- });
14168
- const head = refs.find((r) => r.ref === "HEAD" && r.target);
14169
- const defaultBranch = head?.target?.replace(/^refs\/heads\//, "");
14170
- return {
14171
- ok: true,
14172
- refCount: refs.filter((r) => r.ref !== "HEAD").length,
14173
- ...defaultBranch ? { defaultBranch } : {}
14174
- };
14175
- } catch (e) {
14176
- return classifyRemoteAccessError(e);
14177
- }
14178
- })();
14179
- try {
14180
- return await Promise.race([probe, timeout]);
14181
- } finally {
14182
- clearTimeout(timer);
14183
- }
14184
- }
14185
- // src/lib/remote-auth/generic-auth.ts
14186
- function normalizeForgeHost(input) {
14187
- return credentialHostKey(input);
14188
- }
14189
- function knownForgeTokenUrl(host) {
14190
- const h = normalizeForgeHost(host);
14191
- if (!h)
14192
- return null;
14193
- const name = h.split(":")[0];
14194
- if (name === GITHUB_HOST || name.endsWith(`.${GITHUB_HOST}`))
14195
- return null;
14196
- if (name === "bitbucket.org") {
14197
- return "https://bitbucket.org/account/settings/app-passwords/";
14198
- }
14199
- if (name === "dev.azure.com" || name.endsWith(".visualstudio.com")) {
14200
- return "https://dev.azure.com/_usersSettings/tokens";
14201
- }
14202
- if (name.includes("gitlab")) {
14203
- return `https://${h}/-/user_settings/personal_access_tokens`;
14204
- }
14205
- if (name.includes("gitea") || name.includes("forgejo")) {
14206
- return `https://${h}/user/settings/applications`;
14207
- }
14208
- return null;
14209
- }
14210
- function connectError(result, host) {
14211
- switch (result.reason) {
14212
- case "auth":
14213
- return new Error("The Git server didn't accept that access token. Check the token (and username, if your server needs one) and try again.");
14214
- case "ssh-unsupported":
14215
- return new Error("Enter the server's web address (starting with https://), not an SSH address (git@…).");
14216
- case "unreachable":
14217
- return new Error(`Couldn't reach ${host}. Check the server address and your connection, then try again.`);
14218
- case "tls":
14219
- return new Error(result.message);
14220
- case "not-found":
14221
- return new Error("The server answered, but that repository wasn't found. Check the repository address and that the token has access to it.");
14222
- default:
14223
- return new Error(`Couldn't verify the connection to ${host}. Check the server address and try again.`);
14224
- }
14225
- }
14226
- async function connectGenericHost(input, options = {}) {
14227
- const host = normalizeForgeHost(input.host);
14228
- if (!host) {
14229
- throw new Error("Enter your Git server's web address (like https://git.example.com).");
14230
- }
14231
- const token = input.token?.trim();
14232
- if (!token) {
14233
- throw new Error("Paste an access token from your Git server to connect.");
14234
- }
14235
- const username = input.username?.trim() || undefined;
14236
- const candidate = {
14237
- host,
14238
- kind: "token",
14239
- token,
14240
- ...username ? { username } : {},
14241
- label: username ? `${host} — ${username}` : host,
14242
- createdAt: Date.now()
14243
- };
14244
- const probeUrl = input.repoUrl?.trim() || `https://${host}/`;
14245
- const result = await testRemoteAccess({
14246
- url: probeUrl,
14247
- credential: candidate,
14248
- ...options.httpClient ? { httpClient: options.httpClient } : {},
14249
- ...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}
14250
- });
14251
- if (!result.ok) {
14252
- const rootProbe = !input.repoUrl?.trim();
14253
- const inconclusiveButReachable = rootProbe && (result.reason === "not-found" || result.reason === "unknown");
14254
- if (!inconclusiveButReachable)
14255
- throw connectError(result, host);
14256
- }
14257
- return candidate;
14258
- }
14259
- // src/lib/remote-auth/diagnose.ts
14260
- function parseRemoteOrigin(url) {
14261
- const trimmed = url.trim();
14262
- if (!trimmed)
14263
- return { protocol: "none" };
14264
- const scp = /^[\w.-]+@([\w.][\w.-]*):/.exec(trimmed);
14265
- if (scp)
14266
- return { protocol: "ssh", host: credentialHostKey(trimmed) };
14267
- try {
14268
- const parsed = new URL(trimmed);
14269
- const host = credentialHostKey(trimmed);
14270
- if (/^https?:$/.test(parsed.protocol))
14271
- return { protocol: "https", host };
14272
- if (/^ssh:$/.test(parsed.protocol) || isSshRemoteUrl(trimmed)) {
14273
- return { protocol: "ssh", host };
14274
- }
14275
- return { protocol: "none" };
14276
- } catch {
14277
- return { protocol: "none" };
14278
- }
14279
- }
14280
- function forgeKindForHost(host) {
14281
- const name = host.toLowerCase().split(":")[0];
14282
- if (name === GITHUB_HOST || name.endsWith(`.${GITHUB_HOST}`))
14283
- return "github";
14284
- if (name === "bitbucket.org" || name.includes("bitbucket"))
14285
- return "bitbucket";
14286
- if (name === "dev.azure.com" || name.endsWith(".visualstudio.com"))
14287
- return "azure";
14288
- if (name.includes("gitlab"))
14289
- return "gitlab";
14290
- if (name.includes("forgejo"))
14291
- return "forgejo";
14292
- if (name.includes("gitea"))
14293
- return "gitea";
14294
- return "generic";
14295
- }
14296
- async function diagnoseProjectRemote(projectDir, options = {}) {
14297
- const classification = options.source ?? await detectProjectSource(projectDir);
14298
- const rawRemoteUrl = classification.type === "local-git-folder" ? classification.remoteUrl : undefined;
14299
- const branch = classification.type === "local-git-folder" ? classification.branch : undefined;
14300
- if (!rawRemoteUrl) {
14301
- return {
14302
- classification,
14303
- remoteProtocol: "none",
14304
- ...branch ? { branch } : {},
14305
- credentialPresent: false,
14306
- provider: null,
14307
- tokenSettingsUrl: null,
14308
- canSync: false,
14309
- guidance: "local-only"
14310
- };
14311
- }
14312
- const { protocol, host } = parseRemoteOrigin(rawRemoteUrl);
14313
- const urlExtraction = protocol === "https" ? extractUrlCredential(rawRemoteUrl) : null;
14314
- const remoteUrl = urlExtraction ? urlExtraction.cleanUrl : rawRemoteUrl;
14315
- const sanitizedClassification = classification.type === "local-git-folder" && classification.remoteUrl ? { ...classification, remoteUrl } : classification;
14316
- let credentialPresent = false;
14317
- if (host && options.tokenStore) {
14318
- try {
14319
- credentialPresent = await options.tokenStore.get(host) !== null;
14320
- } catch {
14321
- credentialPresent = false;
14322
- }
14323
- }
14324
- if (!credentialPresent && urlExtraction?.credential)
14325
- credentialPresent = true;
14326
- const provider = host && protocol !== "none" ? forgeKindForHost(host) : null;
14327
- const canSync = protocol === "https" && credentialPresent;
14328
- let guidance;
14329
- if (protocol === "ssh")
14330
- guidance = "ssh-use-own-tools";
14331
- else if (protocol !== "https")
14332
- guidance = "local-only";
14333
- else if (credentialPresent)
14334
- guidance = "ready-to-sync";
14335
- else if (provider === "github")
14336
- guidance = "connect-github-to-sync";
14337
- else
14338
- guidance = "https-connect-server";
14339
- return {
14340
- classification: sanitizedClassification,
14341
- remoteUrl,
14342
- ...host ? { remoteHost: host } : {},
14343
- remoteProtocol: protocol === "none" ? "none" : protocol,
14344
- ...branch ? { branch } : {},
14345
- credentialPresent,
14346
- provider,
14347
- tokenSettingsUrl: host ? knownForgeTokenUrl(host) : null,
14348
- canSync,
14349
- guidance
14350
- };
14351
- }
14352
- // src/lib/remote-auth/sync.ts
14353
- import git6 from "isomorphic-git";
14354
- import { randomUUID as randomUUID4 } from "node:crypto";
14355
-
14356
- // src/lib/remote-auth/converge-merge.ts
14357
- import { mkdir as mkdir14, unlink as unlink3, writeFile as writeFile13 } from "node:fs/promises";
14358
- import path25 from "node:path";
14359
- import git5 from "isomorphic-git";
14360
- import diff3Merge from "diff3";
14361
- function isMergeConflictError(e) {
14362
- return e?.code === "MergeConflictError";
14363
- }
14364
- function isCheckoutConflict(e) {
14365
- return e?.code === "CheckoutConflictError";
14366
- }
14367
- var CONVERGE_PREPARE_MESSAGE = "Getting your changes ready to combine with the online version";
14368
- var CONVERGE_RESTORE_MESSAGE = "Kept both versions of the files that can't be combined";
14369
- var CONVERGE_MERGE_MESSAGE = "Combined your changes with the online version";
14370
- var MARKER_UNSAFE_EXT = /\.svg$/i;
14371
- var OUR_LABEL = "your version";
14372
- var THEIR_LABEL = "online version";
14373
- function stringLooksBinary(s) {
14374
- return s.includes("\x00");
14375
- }
14376
- var LINEBREAKS = /^.*(\r?\n|$)/gm;
14377
- function mergeWithMarkers(baseContent, ourContent, theirContent) {
14378
- const result = diff3Merge(ourContent.match(LINEBREAKS) ?? [], baseContent.match(LINEBREAKS) ?? [], theirContent.match(LINEBREAKS) ?? []);
14379
- let merged = "";
14380
- for (const item of result) {
14381
- if (item.ok)
14382
- merged += item.ok.join("");
14383
- if (item.conflict) {
14384
- merged += `<<<<<<< ${OUR_LABEL}
14385
- `;
14386
- merged += item.conflict.a.join("");
14387
- merged += `=======
14388
- `;
14389
- merged += item.conflict.b.join("");
14390
- merged += `>>>>>>> ${THEIR_LABEL}
14391
- `;
14392
- }
14393
- }
14394
- return merged;
14395
- }
14396
- async function readBlob(dir, cache2, oid, filepath) {
14397
- return git5.readBlob({ fs: gitFs, dir, cache: cache2, oid, filepath });
14398
- }
14399
- async function writeTreeFile(dir, filepath, content) {
14400
- const abs = path25.join(dir, filepath);
14401
- await mkdir14(path25.dirname(abs), { recursive: true });
14402
- await writeFile13(abs, content);
14347
+ async function writeTreeFile(dir, filepath, content) {
14348
+ const abs = path25.join(dir, filepath);
14349
+ await mkdir14(path25.dirname(abs), { recursive: true });
14350
+ await writeFile14(abs, content);
14403
14351
  }
14404
14352
  async function bothAddedPaths(dir, cache2, ourTip, theirTip, allowUnrelated) {
14405
- const bases = await git5.findMergeBase({ fs: gitFs, dir, cache: cache2, oids: [ourTip, theirTip] });
14353
+ const bases = await git2.findMergeBase({ fs: gitFs, dir, cache: cache2, oids: [ourTip, theirTip] });
14406
14354
  const base = bases[0];
14407
14355
  if (!base && !allowUnrelated)
14408
14356
  return null;
14409
14357
  const out = [];
14410
- await git5.walk({
14358
+ await git2.walk({
14411
14359
  fs: gitFs,
14412
14360
  dir,
14413
14361
  cache: cache2,
14414
14362
  trees: [
14415
- git5.TREE({ ref: ourTip }),
14416
- git5.TREE({ ref: theirTip }),
14417
- ...base ? [git5.TREE({ ref: base })] : []
14363
+ git2.TREE({ ref: ourTip }),
14364
+ git2.TREE({ ref: theirTip }),
14365
+ ...base ? [git2.TREE({ ref: base })] : []
14418
14366
  ],
14419
14367
  map: async (filepath, entries) => {
14420
14368
  if (filepath === ".")
@@ -14445,14 +14393,14 @@ async function convergeMerge(params) {
14445
14393
  authorEmail: params.authorEmail,
14446
14394
  cache: cache2
14447
14395
  });
14448
- const ourTip = await git5.resolveRef({ fs: gitFs, dir, ref: branch });
14396
+ const ourTip = await git2.resolveRef({ fs: gitFs, dir, ref: branch });
14449
14397
  const combined = new Set;
14450
14398
  const keptBothFiles = [];
14451
14399
  let tipBeforeMerge = ourTip;
14452
14400
  const attempt = async () => {
14453
- tipBeforeMerge = await git5.resolveRef({ fs: gitFs, dir, ref: branch });
14401
+ tipBeforeMerge = await git2.resolveRef({ fs: gitFs, dir, ref: branch });
14454
14402
  step("merge", { local: tipBeforeMerge, fetched: theirs });
14455
- return git5.merge({
14403
+ return git2.merge({
14456
14404
  fs: gitFs,
14457
14405
  dir,
14458
14406
  cache: cache2,
@@ -14539,37 +14487,237 @@ async function convergeMerge(params) {
14539
14487
  }
14540
14488
  step("checkout");
14541
14489
  try {
14542
- await git5.checkout({ fs: gitFs, dir, cache: cache2, ref: branch, force: false });
14543
- } catch (e) {
14544
- if (!isCheckoutConflict(e))
14545
- throw e;
14546
- logger.warn("checkout", "checkout refused; restoring branch", errorLogData(e));
14547
- step("rollback", { local: tipBeforeMerge });
14548
- await git5.writeRef({
14490
+ await git2.checkout({ fs: gitFs, dir, cache: cache2, ref: branch, force: false });
14491
+ } catch (e) {
14492
+ if (!isCheckoutConflict(e))
14493
+ throw e;
14494
+ logger.warn("checkout", "checkout refused; restoring branch", errorLogData(e));
14495
+ step("rollback", { local: tipBeforeMerge });
14496
+ await git2.writeRef({
14497
+ fs: gitFs,
14498
+ dir,
14499
+ ref: await git2.expandRef({ fs: gitFs, dir, ref: branch }),
14500
+ value: tipBeforeMerge,
14501
+ force: true
14502
+ });
14503
+ step("checkout");
14504
+ throw e;
14505
+ }
14506
+ if (postRestore.length > 0) {
14507
+ for (const r of postRestore) {
14508
+ step("restore-conflict-file", { filepath: r.path });
14509
+ await writeTreeFile(dir, r.path, r.bytes);
14510
+ }
14511
+ step("snapshot-restored");
14512
+ if (await hasPendingChanges(dir))
14513
+ await snapshot(CONVERGE_RESTORE_MESSAGE);
14514
+ }
14515
+ step("merged-ref");
14516
+ return {
14517
+ oid: await git2.resolveRef({ fs: gitFs, dir, ref: branch }),
14518
+ combinedFiles: [...combined].sort(),
14519
+ keptBothFiles
14520
+ };
14521
+ }
14522
+
14523
+ // src/lib/remote-auth/transport.ts
14524
+ import git3 from "isomorphic-git";
14525
+ async function repoDirFor(projectDir) {
14526
+ const source = await detectProjectSource(projectDir);
14527
+ if (source.type === "local-git-folder")
14528
+ return gitScopeFor(source);
14529
+ return projectDir;
14530
+ }
14531
+ function isCredentialTransmissionSafe(url) {
14532
+ try {
14533
+ const u = new URL(url);
14534
+ if (u.protocol === "https:")
14535
+ return true;
14536
+ if (u.protocol === "http:") {
14537
+ const host = u.hostname.toLowerCase();
14538
+ return host === "127.0.0.1" || host === "[::1]" || host === "localhost";
14539
+ }
14540
+ return false;
14541
+ } catch {
14542
+ return false;
14543
+ }
14544
+ }
14545
+
14546
+ class InsecureTransportError extends Error {
14547
+ code = "InsecureTransport";
14548
+ constructor() {
14549
+ super("This online address isn't secure, so the saved connection wasn't sent — " + "connections are never sent over an insecure address. Switch the address " + "to a secure one (starting with https) to sync with a saved connection.");
14550
+ this.name = "InsecureTransportError";
14551
+ }
14552
+ }
14553
+ function isInsecureTransportError(e) {
14554
+ return e?.code === "InsecureTransport";
14555
+ }
14556
+ function onAuthFor(credential) {
14557
+ if (!credential)
14558
+ return {};
14559
+ return {
14560
+ onAuth: (url) => {
14561
+ if (!isCredentialTransmissionSafe(url))
14562
+ throw new InsecureTransportError;
14563
+ return {
14564
+ username: credential.kind === "github-oauth" ? "x-access-token" : credential.username || credential.token,
14565
+ password: credential.token
14566
+ };
14567
+ }
14568
+ };
14569
+ }
14570
+ async function resolveTransport(dir, options) {
14571
+ const origin = await syncRemoteFor(dir);
14572
+ if (!origin?.url)
14573
+ throw new Error(MSG_NO_REMOTE);
14574
+ const { cleanUrl, credential: urlCredential } = extractUrlCredential(origin.url);
14575
+ let parsed;
14576
+ try {
14577
+ parsed = new URL(cleanUrl);
14578
+ } catch {
14579
+ throw new Error(MSG_SSH_REMOTE);
14580
+ }
14581
+ if (!/^https?:$/.test(parsed.protocol))
14582
+ throw new Error(MSG_SSH_REMOTE);
14583
+ const host = credentialHostKey(cleanUrl);
14584
+ let credential = options.credential ?? undefined;
14585
+ if (!credential && options.tokenStore) {
14586
+ try {
14587
+ credential = await options.tokenStore.get(host) ?? undefined;
14588
+ } catch {
14589
+ credential = undefined;
14590
+ }
14591
+ }
14592
+ if (!credential && urlCredential)
14593
+ credential = urlCredential;
14594
+ return {
14595
+ remote: origin.remote,
14596
+ url: cleanUrl,
14597
+ host,
14598
+ ...credential ? { credential } : {}
14599
+ };
14600
+ }
14601
+ function classifyTransportFailure(e) {
14602
+ if (isInsecureTransportError(e))
14603
+ return "insecure_transport";
14604
+ const err = e;
14605
+ if (err?.code === "HttpError") {
14606
+ const status = err.data?.statusCode;
14607
+ if (status === 401 || status === 403 || status === 404)
14608
+ return "auth_required";
14609
+ }
14610
+ const msg = `${err?.message ?? String(e)} ${err?.data?.prettyDetails ?? ""}`;
14611
+ if (/\b401\b|\b403\b|\b404\b|unauthorized|authentication|not authorized|permission denied|forbidden|access denied|not allowed to push|pre-receive hook declined|hook declined/i.test(msg)) {
14612
+ return "auth_required";
14613
+ }
14614
+ if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|fetch failed|couldn't reach|socket hang ?up/i.test(msg)) {
14615
+ return "network_unavailable";
14616
+ }
14617
+ return null;
14618
+ }
14619
+ function failureOutcome(e, snapshotId) {
14620
+ const kind = classifyTransportFailure(e);
14621
+ const base = snapshotId ? { snapshotId } : {};
14622
+ if (kind === "auth_required")
14623
+ return { status: "auth", message: MSG_AUTH, ...base };
14624
+ if (kind === "network_unavailable")
14625
+ return { status: "offline", message: MSG_OFFLINE, ...base };
14626
+ if (kind === "insecure_transport") {
14627
+ return { status: "error", message: MSG_INSECURE_TRANSPORT, ...base };
14628
+ }
14629
+ return {
14630
+ status: "error",
14631
+ message: "Syncing didn't complete. Your work is saved on this computer — please try again.",
14632
+ ...base
14633
+ };
14634
+ }
14635
+ function setupErrorMessage(e) {
14636
+ if (e instanceof Error && (e.message === MSG_NO_REMOTE || e.message === MSG_SSH_REMOTE || e.message === MSG_NO_BRANCH)) {
14637
+ return e.message;
14638
+ }
14639
+ return null;
14640
+ }
14641
+ async function snapshotBeforeAction(args) {
14642
+ const { projectDir, dir, cache: cache2 } = args;
14643
+ const hasChanges = await hasPendingChanges(dir, cache2);
14644
+ const staleStaging = gitFs.existsSync(snapshotStagingMarkerPath(dir));
14645
+ if (!hasChanges && !staleStaging)
14646
+ return;
14647
+ const snap = await snapshotWorkingTreeUnlocked({
14648
+ projectDir,
14649
+ repoRoot: dir,
14650
+ message: args.message?.trim() || SYNC_SNAPSHOT_MESSAGE,
14651
+ authorName: args.authorName,
14652
+ authorEmail: args.authorEmail,
14653
+ cache: cache2
14654
+ });
14655
+ return snap.id;
14656
+ }
14657
+ async function currentBranchOrThrow(dir) {
14658
+ const branch = await git3.currentBranch({ fs: gitFs, dir });
14659
+ if (!branch)
14660
+ throw new Error(MSG_NO_BRANCH);
14661
+ return branch;
14662
+ }
14663
+ async function resolveRefOrNull(dir, ref) {
14664
+ try {
14665
+ return await git3.resolveRef({ fs: gitFs, dir, ref });
14666
+ } catch {
14667
+ return null;
14668
+ }
14669
+ }
14670
+ async function guardTrackingRef(dir, ref, cache2, fn) {
14671
+ const before = await resolveRefOrNull(dir, ref);
14672
+ try {
14673
+ return await fn();
14674
+ } catch (e) {
14675
+ try {
14676
+ const after = await resolveRefOrNull(dir, ref);
14677
+ if (after && after !== before) {
14678
+ const landed = await git3.readObject({ fs: gitFs, dir, oid: after, cache: cache2 }).then(() => true, () => false);
14679
+ if (!landed) {
14680
+ if (before)
14681
+ await git3.writeRef({ fs: gitFs, dir, ref, value: before, force: true });
14682
+ else
14683
+ await git3.deleteRef({ fs: gitFs, dir, ref });
14684
+ }
14685
+ }
14686
+ } catch {}
14687
+ throw e;
14688
+ }
14689
+ }
14690
+ async function fetchRemoteTip(dir, branch, transport, http3, cache2) {
14691
+ try {
14692
+ const trackingRef = `refs/remotes/${transport.remote}/${branch}`;
14693
+ const result = await guardTrackingRef(dir, trackingRef, cache2, () => git3.fetch({
14549
14694
  fs: gitFs,
14695
+ http: http3,
14550
14696
  dir,
14551
- ref: await git5.expandRef({ fs: gitFs, dir, ref: branch }),
14552
- value: tipBeforeMerge,
14553
- force: true
14554
- });
14555
- step("checkout");
14556
- throw e;
14557
- }
14558
- if (postRestore.length > 0) {
14559
- for (const r of postRestore) {
14560
- step("restore-conflict-file", { filepath: r.path });
14561
- await writeTreeFile(dir, r.path, r.bytes);
14697
+ cache: cache2,
14698
+ remote: transport.remote,
14699
+ ref: trackingRef,
14700
+ remoteRef: branch,
14701
+ singleBranch: true,
14702
+ tags: false,
14703
+ ...onAuthFor(transport.credential)
14704
+ }));
14705
+ return result.fetchHead ?? null;
14706
+ } catch (e) {
14707
+ const code = e?.code;
14708
+ if (code === "NoRefspecError")
14709
+ return null;
14710
+ if (code === "NotFoundError" || e?.data?.statusCode === 404) {
14711
+ const err = new Error("The online repository couldn't be accessed with the saved connection. Reconnect and try again.", { cause: e });
14712
+ err.code = "HttpError";
14713
+ err.data = { statusCode: 401 };
14714
+ throw err;
14562
14715
  }
14563
- step("snapshot-restored");
14564
- if (await hasPendingChanges(dir))
14565
- await snapshot(CONVERGE_RESTORE_MESSAGE);
14716
+ throw e;
14566
14717
  }
14567
- step("merged-ref");
14568
- return {
14569
- oid: await git5.resolveRef({ fs: gitFs, dir, ref: branch }),
14570
- combinedFiles: [...combined].sort(),
14571
- keptBothFiles
14572
- };
14718
+ }
14719
+ function short(oid) {
14720
+ return oid ? oid.slice(0, 8) : "none";
14573
14721
  }
14574
14722
  // src/lib/remote-auth/sync.ts
14575
14723
  function isPushRejected(e) {
@@ -14594,10 +14742,10 @@ function isDamagedObjectError(e) {
14594
14742
  }
14595
14743
  async function historyUnreadable(dir) {
14596
14744
  try {
14597
- const oid = await git6.resolveRef({ fs: gitFs, dir, ref: "HEAD" });
14598
- await git6.readCommit({ fs: gitFs, dir, oid });
14599
- await git6.listFiles({ fs: gitFs, dir });
14600
- await git6.listFiles({ fs: gitFs, dir, ref: "HEAD" });
14745
+ const oid = await git4.resolveRef({ fs: gitFs, dir, ref: "HEAD" });
14746
+ await git4.readCommit({ fs: gitFs, dir, oid });
14747
+ await git4.listFiles({ fs: gitFs, dir });
14748
+ await git4.listFiles({ fs: gitFs, dir, ref: "HEAD" });
14601
14749
  return false;
14602
14750
  } catch {
14603
14751
  return true;
@@ -14692,7 +14840,7 @@ async function syncProject(options) {
14692
14840
  step("fetch", { attempt: attempt + 1, branch, remote: transport.remote });
14693
14841
  const remoteTip = await fetchRemoteTip(dir, branch, transport, http3, cache2);
14694
14842
  step("local-ref", { fetched: remoteTip ?? "none" });
14695
- let localTip = await git6.resolveRef({ fs: gitFs, dir, ref: branch });
14843
+ let localTip = await git4.resolveRef({ fs: gitFs, dir, ref: branch });
14696
14844
  if (push || remoteTip !== null && remoteTip !== localTip) {
14697
14845
  step("snapshot-after-fetch");
14698
14846
  const lateSnapshot = await snapshotBeforeAction({
@@ -14705,7 +14853,7 @@ async function syncProject(options) {
14705
14853
  });
14706
14854
  if (lateSnapshot) {
14707
14855
  snapshotId = lateSnapshot;
14708
- localTip = await git6.resolveRef({ fs: gitFs, dir, ref: branch });
14856
+ localTip = await git4.resolveRef({ fs: gitFs, dir, ref: branch });
14709
14857
  }
14710
14858
  }
14711
14859
  logger.info("sync", `branch=${branch} local=${short(localTip)} fetched=${short(remoteTip)} snapshot=${snapshotId ? short(snapshotId) : "none"}`);
@@ -14739,8 +14887,8 @@ async function syncProject(options) {
14739
14887
  pulled = true;
14740
14888
  step("compare-trees");
14741
14889
  const [before, after] = await Promise.all([
14742
- git6.readCommit({ fs: gitFs, dir, cache: cache2, oid: localTip }),
14743
- git6.readCommit({ fs: gitFs, dir, cache: cache2, oid: tip })
14890
+ git4.readCommit({ fs: gitFs, dir, cache: cache2, oid: localTip }),
14891
+ git4.readCommit({ fs: gitFs, dir, cache: cache2, oid: tip })
14744
14892
  ]);
14745
14893
  filesChanged = filesChanged || before.commit.tree !== after.commit.tree;
14746
14894
  for (const f of converge.combinedFiles)
@@ -14770,7 +14918,7 @@ async function syncProject(options) {
14770
14918
  }
14771
14919
  step("push", { branch, local: tip, fetched: remoteTip ?? "none" });
14772
14920
  try {
14773
- await git6.push({
14921
+ await git4.push({
14774
14922
  fs: gitFs,
14775
14923
  http: http3,
14776
14924
  dir,
@@ -14812,50 +14960,562 @@ async function syncProject(options) {
14812
14960
  });
14813
14961
  };
14814
14962
  try {
14815
- return finish(await run2());
14816
- } catch (e) {
14817
- logger.warn(stage, `${stage} failed`, errorLogData(e));
14818
- return finish(failureOutcome(e));
14963
+ return finish(await run2());
14964
+ } catch (e) {
14965
+ logger.warn(stage, `${stage} failed`, errorLogData(e));
14966
+ return finish(failureOutcome(e));
14967
+ }
14968
+ }
14969
+ async function refreshRemoteCopies(options) {
14970
+ const http3 = options.httpClient ?? defaultGitHttp;
14971
+ try {
14972
+ const dir = await repoDirFor(options.projectDir);
14973
+ const transport = await resolveTransport(dir, {
14974
+ ...options.credential ? { credential: options.credential } : {},
14975
+ ...options.tokenStore ? { tokenStore: options.tokenStore } : {}
14976
+ });
14977
+ await git4.fetch({
14978
+ fs: gitFs,
14979
+ http: http3,
14980
+ dir,
14981
+ remote: transport.remote,
14982
+ singleBranch: false,
14983
+ prune: true,
14984
+ tags: false,
14985
+ ...onAuthFor(transport.credential)
14986
+ });
14987
+ return { refreshed: true };
14988
+ } catch (e) {
14989
+ const message = e?.message ?? "";
14990
+ if (message === MSG_NO_REMOTE || message === MSG_SSH_REMOTE) {
14991
+ return { refreshed: false, reason: "no-remote" };
14992
+ }
14993
+ const status = e?.data?.statusCode;
14994
+ if (status === 401 || status === 403 || status === 404) {
14995
+ return { refreshed: false, reason: "auth" };
14996
+ }
14997
+ return { refreshed: false, reason: "offline" };
14998
+ }
14999
+ }
15000
+
15001
+ // src/lib/remote-auth/online-backup.ts
15002
+ var API_BASE3 = "https://api.github.com";
15003
+ var REQUEST_TIMEOUT_MS4 = 15000;
15004
+ var MAX_NAME_LENGTH = 100;
15005
+ var MSG = {
15006
+ notConnected: "Connect your GitHub account first (Settings > Accounts), then set up online backup.",
15007
+ auth: "GitHub didn't accept the saved connection. Reconnect GitHub in Settings > Accounts and try again.",
15008
+ offline: "Gutterpress couldn't reach GitHub. Check your internet connection and try again. Nothing was changed.",
15009
+ noHistory: "Turn on version history for this book first. Online backup copies the versions Gutterpress keeps on this computer.",
15010
+ alreadyHasRemote: "This book already has an online copy.",
15011
+ noName: "Give the online copy a name.",
15012
+ badName: "A repository name can only use letters, numbers, hyphens (-), underscores (_) and dots (.), and be at most 100 characters.",
15013
+ notFound: "Gutterpress couldn't find that repository on GitHub, or your account can't see it. Choose another one.",
15014
+ noPermission: "Your GitHub account isn't allowed to add to that repository (it may be archived or read-only). Choose another one.",
15015
+ noCreatePermission: "GitHub wouldn't let Gutterpress create a repository with this connection. Create an empty repository on github.com yourself, then choose it from your existing repositories here.",
15016
+ notEmpty: "That repository already has files in it, and online backup needs an empty one. Choose an empty repository, or create a new one.",
15017
+ pushFailed: "Your book couldn't be copied to GitHub. Your writing is safe on this computer. Please try again."
15018
+ };
15019
+ var nameTaken = (name) => `You already have a repository named "${name}" on GitHub. Pick a different name, or choose that repository from your existing empty repositories.`;
15020
+
15021
+ class BackupFailure extends Error {
15022
+ reason;
15023
+ repository;
15024
+ constructor(reason, message, repository) {
15025
+ super(message);
15026
+ this.reason = reason;
15027
+ this.repository = repository;
15028
+ }
15029
+ }
15030
+ function isValidRepositoryName(name) {
15031
+ return name.length > 0 && name.length <= MAX_NAME_LENGTH && /^[A-Za-z0-9._-]+$/.test(name) && name !== "." && name !== "..";
15032
+ }
15033
+ function toRemoteRepository(r) {
15034
+ return {
15035
+ owner: r.owner.login,
15036
+ name: r.name,
15037
+ fullName: r.full_name,
15038
+ private: r.private,
15039
+ defaultBranch: r.default_branch,
15040
+ htmlUrl: r.html_url
15041
+ };
15042
+ }
15043
+ async function request(fetchImpl, method, path26, token, body) {
15044
+ return withFetchTimeout({ timeoutMs: REQUEST_TIMEOUT_MS4, offlineMessage: MSG.offline }, (signal) => fetchImpl(`${API_BASE3}${path26}`, {
15045
+ method,
15046
+ headers: {
15047
+ ...githubApiHeaders(token),
15048
+ ...body === undefined ? {} : { "Content-Type": "application/json" }
15049
+ },
15050
+ ...body === undefined ? {} : { body: JSON.stringify(body) },
15051
+ signal
15052
+ })).catch((e) => {
15053
+ throw new BackupFailure("offline", e instanceof Error ? e.message : MSG.offline);
15054
+ });
15055
+ }
15056
+ async function createRepository(fetchImpl, token, name, isPrivate) {
15057
+ const res = await request(fetchImpl, "POST", "/user/repos", token, {
15058
+ name,
15059
+ private: isPrivate,
15060
+ auto_init: false,
15061
+ description: "Online backup made with Gutterpress"
15062
+ });
15063
+ if (res.status === 201 || res.status === 200)
15064
+ return await res.json();
15065
+ if (res.status === 401)
15066
+ throw new BackupFailure("auth", MSG.auth);
15067
+ if (res.status === 403)
15068
+ throw new BackupFailure("no-permission", MSG.noCreatePermission);
15069
+ if (res.status === 422) {
15070
+ const body = await res.json().catch(() => ({}));
15071
+ const taken = (body.errors ?? []).some((e) => /already exists/i.test(e.message ?? ""));
15072
+ throw new BackupFailure("name-taken", taken ? nameTaken(name) : MSG.badName);
15073
+ }
15074
+ throw new BackupFailure("push-failed", `GitHub couldn't create the repository (error ${res.status}). Please try again.`);
15075
+ }
15076
+ async function loadEmptyRepository(fetchImpl, credential, owner, name) {
15077
+ const path26 = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
15078
+ const res = await request(fetchImpl, "GET", path26, credential.token);
15079
+ if (res.status === 401)
15080
+ throw new BackupFailure("auth", MSG.auth);
15081
+ if (res.status === 404)
15082
+ throw new BackupFailure("not-found", MSG.notFound);
15083
+ if (res.status === 403)
15084
+ throw new BackupFailure("no-permission", MSG.noPermission);
15085
+ if (!res.ok) {
15086
+ throw new BackupFailure("push-failed", `GitHub returned an unexpected error (error ${res.status}). Please try again.`);
15087
+ }
15088
+ const repo = await res.json();
15089
+ if (repo.archived || repo.permissions?.push === false) {
15090
+ throw new BackupFailure("no-permission", MSG.noPermission);
15091
+ }
15092
+ const branches = await listGitHubBranches(credential, owner, name, { fetchImpl }).catch((e) => {
15093
+ const msg = e instanceof Error ? e.message : "";
15094
+ throw new BackupFailure(/reconnect/i.test(msg) ? "auth" : /reach/i.test(msg) ? "offline" : "push-failed", msg || MSG.pushFailed);
15095
+ });
15096
+ if (branches.length > 0)
15097
+ throw new BackupFailure("not-empty", MSG.notEmpty, toRemoteRepository(repo));
15098
+ return repo;
15099
+ }
15100
+ async function run2(options) {
15101
+ const fetchImpl = options.fetchImpl ?? fetch;
15102
+ const credential = options.credential ?? await options.tokenStore?.get(GITHUB_HOST).catch(() => null) ?? undefined;
15103
+ if (!credential)
15104
+ throw new BackupFailure("not-connected", MSG.notConnected);
15105
+ const source = await detectProjectSource(options.projectDir);
15106
+ if (source.type !== "local-git-folder")
15107
+ throw new BackupFailure("no-history", MSG.noHistory);
15108
+ if (source.hasRemote)
15109
+ throw new BackupFailure("already-has-remote", MSG.alreadyHasRemote);
15110
+ const dir = await repoDirFor(options.projectDir);
15111
+ const head = await git5.resolveRef({ fs: gitFs, dir, ref: "HEAD" }).catch(() => null);
15112
+ if (!head)
15113
+ throw new BackupFailure("no-history", MSG.noHistory);
15114
+ const { target } = options;
15115
+ let repo;
15116
+ if (target.kind === "create") {
15117
+ const name = target.name.trim();
15118
+ if (!name)
15119
+ throw new BackupFailure("invalid-name", MSG.noName);
15120
+ if (!isValidRepositoryName(name))
15121
+ throw new BackupFailure("invalid-name", MSG.badName);
15122
+ repo = await createRepository(fetchImpl, credential.token, name, target.private ?? true);
15123
+ } else {
15124
+ repo = await loadEmptyRepository(fetchImpl, credential, target.owner, target.name);
15125
+ }
15126
+ const repository = toRemoteRepository(repo);
15127
+ if (!/^https?:\/\//.test(repo.clone_url)) {
15128
+ throw new BackupFailure("push-failed", MSG.pushFailed, repository);
15129
+ }
15130
+ await withRepoLock(dir, () => git5.addRemote({ fs: gitFs, dir, remote: "origin", url: repo.clone_url }));
15131
+ const sync = await syncProject({
15132
+ projectDir: options.projectDir,
15133
+ credential,
15134
+ ...options.authorName ? { authorName: options.authorName } : {},
15135
+ ...options.authorEmail ? { authorEmail: options.authorEmail } : {},
15136
+ ...options.httpClient ? { httpClient: options.httpClient } : {},
15137
+ ...options.logFile ? { logFile: options.logFile } : {}
15138
+ });
15139
+ if (sync.status === "synced" || sync.status === "up-to-date") {
15140
+ return { status: "connected", repository, sync };
15141
+ }
15142
+ await withRepoLock(dir, () => git5.deleteRemote({ fs: gitFs, dir, remote: "origin" })).catch(() => {});
15143
+ if (sync.status === "auth")
15144
+ throw new BackupFailure("auth", sync.message, repository);
15145
+ if (sync.status === "offline")
15146
+ throw new BackupFailure("offline", sync.message, repository);
15147
+ throw new BackupFailure("push-failed", sync.message || MSG.pushFailed, repository);
15148
+ }
15149
+ async function setUpOnlineBackup(options) {
15150
+ try {
15151
+ return await run2(options);
15152
+ } catch (e) {
15153
+ if (e instanceof BackupFailure) {
15154
+ return {
15155
+ status: "failed",
15156
+ reason: e.reason,
15157
+ message: e.message,
15158
+ ...e.repository ? { repository: e.repository } : {}
15159
+ };
15160
+ }
15161
+ throw e;
15162
+ }
15163
+ }
15164
+ // src/lib/remote-auth/clone.ts
15165
+ import { mkdir as mkdir15, readdir as readdir9, rm as rm6 } from "node:fs/promises";
15166
+ import path26 from "node:path";
15167
+ import git6 from "isomorphic-git";
15168
+ function sanitizeCloneFolderName(name) {
15169
+ return String(name ?? "").replace(/[\\/]/g, "-").replace(/^\.+/, "").trim();
15170
+ }
15171
+ async function assertCloneTarget(dir) {
15172
+ try {
15173
+ const entries = await readdir9(dir);
15174
+ if (entries.length > 0) {
15175
+ throw new Error("That folder already has files in it. Choose an empty folder for the book.");
15176
+ }
15177
+ } catch (e) {
15178
+ if (e.code === "ENOENT")
15179
+ return;
15180
+ throw e;
15181
+ }
15182
+ }
15183
+ function friendlyCloneError(e) {
15184
+ if (isInsecureTransportError(e)) {
15185
+ return new Error("That repository address isn't secure, so the saved connection wasn't sent — connections are never sent over an insecure address. Use a secure address (starting with https), or a local loopback address for a server on this computer.", { cause: e });
15186
+ }
15187
+ const msg = e instanceof Error ? e.message : String(e);
15188
+ if (/401|403|auth|credential/i.test(msg)) {
15189
+ return new Error("GitHub didn't accept the connection for this repository. Reconnect GitHub and try again.", { cause: e });
15190
+ }
15191
+ if (/404|not found/i.test(msg)) {
15192
+ return new Error("That repository couldn't be found. It may have been renamed or you may no longer have access.", { cause: e });
15193
+ }
15194
+ if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network|fetch failed/i.test(msg)) {
15195
+ return new Error(OFFLINE_MESSAGE, { cause: e });
15196
+ }
15197
+ if (e instanceof Error && /already has files/i.test(e.message)) {
15198
+ return e;
15199
+ }
15200
+ return new Error("The book couldn't be downloaded from GitHub. Please try again.", { cause: e });
15201
+ }
15202
+ async function cloneRepository(options) {
15203
+ const { dir, onProgress, tokenStore } = options;
15204
+ if (!path26.isAbsolute(dir)) {
15205
+ throw new Error("cloneRepository requires an absolute destination path.");
15206
+ }
15207
+ let parsed;
15208
+ try {
15209
+ parsed = new URL(options.url);
15210
+ } catch {
15211
+ throw new Error("That repository address is not a valid web URL.");
15212
+ }
15213
+ if (!/^https?:$/.test(parsed.protocol)) {
15214
+ throw new Error("Only HTTPS repository addresses are supported. SSH addresses (git@…) can't be used here.");
15215
+ }
15216
+ const { cleanUrl, credential: urlCredential } = extractUrlCredential(options.url);
15217
+ const credential = options.credential ?? urlCredential;
15218
+ if (urlCredential && tokenStore && !await tokenStore.get(urlCredential.host)) {
15219
+ await tokenStore.set(urlCredential.host, urlCredential);
15220
+ }
15221
+ return withRepoLock(dir, async () => {
15222
+ await assertCloneTarget(dir);
15223
+ const dirExistedBefore = gitFs.existsSync(dir);
15224
+ await mkdir15(dir, { recursive: true });
15225
+ try {
15226
+ await git6.clone({
15227
+ fs: gitFs,
15228
+ http: options.httpClient ?? defaultGitHttp,
15229
+ dir,
15230
+ url: cleanUrl,
15231
+ singleBranch: true,
15232
+ ...options.branch ? { ref: options.branch } : {},
15233
+ ...options.depth ? { depth: options.depth } : {},
15234
+ ...onAuthFor(credential),
15235
+ ...onProgress ? {
15236
+ onProgress: (p) => onProgress({
15237
+ phase: p.phase,
15238
+ loaded: p.loaded,
15239
+ ...p.total ? { total: p.total } : {}
15240
+ })
15241
+ } : {}
15242
+ });
15243
+ } catch (e) {
15244
+ const friendly = friendlyCloneError(e);
15245
+ if (!dirExistedBefore) {
15246
+ try {
15247
+ await rm6(dir, { recursive: true, force: true });
15248
+ } catch {
15249
+ throw new Error(`${friendly.message} A partial download was left at ${dir} — delete that folder before trying again.`, { cause: e });
15250
+ }
15251
+ }
15252
+ throw friendly;
15253
+ }
15254
+ let branch;
15255
+ try {
15256
+ branch = await git6.currentBranch({ fs: gitFs, dir }) ?? undefined;
15257
+ } catch {
15258
+ branch = undefined;
15259
+ }
15260
+ return { projectDir: dir, ...branch ? { branch } : {} };
15261
+ });
15262
+ }
15263
+ // src/lib/remote-auth/test-access.ts
15264
+ import git7 from "isomorphic-git";
15265
+ import httpNode2 from "isomorphic-git/http/node";
15266
+ function isSshRemoteUrl(url) {
15267
+ const trimmed = url.trim();
15268
+ if (/^ssh:\/\//i.test(trimmed))
15269
+ return true;
15270
+ return /^[\w.-]+@[\w.][\w.-]*:/.test(trimmed) || /^[\w.-]+@\[/.test(trimmed);
15271
+ }
15272
+ var FAILURE_MESSAGES = {
15273
+ auth: "The Git server didn't accept the saved connection. Reconnect to this server with a current access token and try again.",
15274
+ "not-found": "The server answered, but no repository was found at that address. It may have been moved, renamed, or you may no longer have access.",
15275
+ unreachable: "Couldn't reach the Git server. Check your internet connection (and VPN, if this is a private server), then try again.",
15276
+ "ssh-unsupported": "This book's online address uses SSH (git@…), which gutterpress can't check or sync with. Everything on this computer still works — sync with your usual Git tool.",
15277
+ "insecure-transport": "This address isn't secure, so the saved connection wasn't sent — connections are never sent over an insecure address. Switch the address to a secure one (starting with https), or to a local loopback address for a server on this computer, to use a saved connection.",
15278
+ tls: "The server's security certificate couldn't be verified. If this is a private server with its own certificate, ask its administrator about trusting it (NODE_EXTRA_CA_CERTS).",
15279
+ unknown: "The connection test failed unexpectedly. The server may not be a Git server, or it may be temporarily unavailable."
15280
+ };
15281
+ function failure(reason) {
15282
+ return { ok: false, reason, message: FAILURE_MESSAGES[reason] };
15283
+ }
15284
+ function classifyRemoteAccessError(e) {
15285
+ if (isInsecureTransportError(e))
15286
+ return failure("insecure-transport");
15287
+ const msg = e instanceof Error ? e.message : String(e);
15288
+ if (/401|403|auth|credential|denied|cancel/i.test(msg))
15289
+ return failure("auth");
15290
+ if (/404|not found/i.test(msg))
15291
+ return failure("not-found");
15292
+ if (/certificate|self.signed|unable_to_verify|cert_|tls|ssl/i.test(msg) && !/fetch failed/i.test(msg)) {
15293
+ return failure("tls");
15294
+ }
15295
+ if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|network|fetch failed|timed out/i.test(msg)) {
15296
+ return failure("unreachable");
15297
+ }
15298
+ return failure("unknown");
15299
+ }
15300
+ async function testRemoteAccess(options) {
15301
+ if (isSshRemoteUrl(options.url))
15302
+ return failure("ssh-unsupported");
15303
+ let parsed;
15304
+ try {
15305
+ parsed = new URL(options.url);
15306
+ } catch {
15307
+ return failure("unknown");
15308
+ }
15309
+ if (!/^https?:$/.test(parsed.protocol))
15310
+ return failure("ssh-unsupported");
15311
+ const { cleanUrl, credential: urlCredential } = extractUrlCredential(options.url);
15312
+ const credential = options.credential ?? urlCredential;
15313
+ const timeoutMs = options.timeoutMs ?? 15000;
15314
+ let timer;
15315
+ const timeout = new Promise((resolve9) => {
15316
+ timer = setTimeout(() => resolve9(failure("unreachable")), timeoutMs);
15317
+ });
15318
+ const probe = (async () => {
15319
+ try {
15320
+ const refs = await git7.listServerRefs({
15321
+ http: options.httpClient ?? httpNode2,
15322
+ url: cleanUrl,
15323
+ protocolVersion: 1,
15324
+ symrefs: true,
15325
+ ...onAuthFor(credential),
15326
+ ...credential ? { onAuthFailure: () => ({ cancel: true }) } : {}
15327
+ });
15328
+ const head = refs.find((r) => r.ref === "HEAD" && r.target);
15329
+ const defaultBranch = head?.target?.replace(/^refs\/heads\//, "");
15330
+ return {
15331
+ ok: true,
15332
+ refCount: refs.filter((r) => r.ref !== "HEAD").length,
15333
+ ...defaultBranch ? { defaultBranch } : {}
15334
+ };
15335
+ } catch (e) {
15336
+ return classifyRemoteAccessError(e);
15337
+ }
15338
+ })();
15339
+ try {
15340
+ return await Promise.race([probe, timeout]);
15341
+ } finally {
15342
+ clearTimeout(timer);
14819
15343
  }
14820
15344
  }
14821
- async function refreshRemoteCopies(options) {
14822
- const http3 = options.httpClient ?? defaultGitHttp;
15345
+ // src/lib/remote-auth/generic-auth.ts
15346
+ function normalizeForgeHost(input) {
15347
+ return credentialHostKey(input);
15348
+ }
15349
+ function knownForgeTokenUrl(host) {
15350
+ const h = normalizeForgeHost(host);
15351
+ if (!h)
15352
+ return null;
15353
+ const name = h.split(":")[0];
15354
+ if (name === GITHUB_HOST || name.endsWith(`.${GITHUB_HOST}`))
15355
+ return null;
15356
+ if (name === "bitbucket.org") {
15357
+ return "https://bitbucket.org/account/settings/app-passwords/";
15358
+ }
15359
+ if (name === "dev.azure.com" || name.endsWith(".visualstudio.com")) {
15360
+ return "https://dev.azure.com/_usersSettings/tokens";
15361
+ }
15362
+ if (name.includes("gitlab")) {
15363
+ return `https://${h}/-/user_settings/personal_access_tokens`;
15364
+ }
15365
+ if (name.includes("gitea") || name.includes("forgejo")) {
15366
+ return `https://${h}/user/settings/applications`;
15367
+ }
15368
+ return null;
15369
+ }
15370
+ function connectError(result, host) {
15371
+ switch (result.reason) {
15372
+ case "auth":
15373
+ return new Error("The Git server didn't accept that access token. Check the token (and username, if your server needs one) and try again.");
15374
+ case "ssh-unsupported":
15375
+ return new Error("Enter the server's web address (starting with https://), not an SSH address (git@…).");
15376
+ case "unreachable":
15377
+ return new Error(`Couldn't reach ${host}. Check the server address and your connection, then try again.`);
15378
+ case "tls":
15379
+ return new Error(result.message);
15380
+ case "not-found":
15381
+ return new Error("The server answered, but that repository wasn't found. Check the repository address and that the token has access to it.");
15382
+ default:
15383
+ return new Error(`Couldn't verify the connection to ${host}. Check the server address and try again.`);
15384
+ }
15385
+ }
15386
+ async function connectGenericHost(input, options = {}) {
15387
+ const host = normalizeForgeHost(input.host);
15388
+ if (!host) {
15389
+ throw new Error("Enter your Git server's web address (like https://git.example.com).");
15390
+ }
15391
+ const token = input.token?.trim();
15392
+ if (!token) {
15393
+ throw new Error("Paste an access token from your Git server to connect.");
15394
+ }
15395
+ const username = input.username?.trim() || undefined;
15396
+ const candidate = {
15397
+ host,
15398
+ kind: "token",
15399
+ token,
15400
+ ...username ? { username } : {},
15401
+ label: username ? `${host} — ${username}` : host,
15402
+ createdAt: Date.now()
15403
+ };
15404
+ const probeUrl = input.repoUrl?.trim() || `https://${host}/`;
15405
+ const result = await testRemoteAccess({
15406
+ url: probeUrl,
15407
+ credential: candidate,
15408
+ ...options.httpClient ? { httpClient: options.httpClient } : {},
15409
+ ...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}
15410
+ });
15411
+ if (!result.ok) {
15412
+ const rootProbe = !input.repoUrl?.trim();
15413
+ const inconclusiveButReachable = rootProbe && (result.reason === "not-found" || result.reason === "unknown");
15414
+ if (!inconclusiveButReachable)
15415
+ throw connectError(result, host);
15416
+ }
15417
+ return candidate;
15418
+ }
15419
+ // src/lib/remote-auth/diagnose.ts
15420
+ function parseRemoteOrigin(url) {
15421
+ const trimmed = url.trim();
15422
+ if (!trimmed)
15423
+ return { protocol: "none" };
15424
+ const scp = /^[\w.-]+@([\w.][\w.-]*):/.exec(trimmed);
15425
+ if (scp)
15426
+ return { protocol: "ssh", host: credentialHostKey(trimmed) };
14823
15427
  try {
14824
- const dir = await repoDirFor(options.projectDir);
14825
- const transport = await resolveTransport(dir, {
14826
- ...options.credential ? { credential: options.credential } : {},
14827
- ...options.tokenStore ? { tokenStore: options.tokenStore } : {}
14828
- });
14829
- await git6.fetch({
14830
- fs: gitFs,
14831
- http: http3,
14832
- dir,
14833
- remote: transport.remote,
14834
- singleBranch: false,
14835
- prune: true,
14836
- tags: false,
14837
- ...onAuthFor(transport.credential)
14838
- });
14839
- return { refreshed: true };
14840
- } catch (e) {
14841
- const message = e?.message ?? "";
14842
- if (message === MSG_NO_REMOTE || message === MSG_SSH_REMOTE) {
14843
- return { refreshed: false, reason: "no-remote" };
15428
+ const parsed = new URL(trimmed);
15429
+ const host = credentialHostKey(trimmed);
15430
+ if (/^https?:$/.test(parsed.protocol))
15431
+ return { protocol: "https", host };
15432
+ if (/^ssh:$/.test(parsed.protocol) || isSshRemoteUrl(trimmed)) {
15433
+ return { protocol: "ssh", host };
14844
15434
  }
14845
- const status = e?.data?.statusCode;
14846
- if (status === 401 || status === 403 || status === 404) {
14847
- return { refreshed: false, reason: "auth" };
15435
+ return { protocol: "none" };
15436
+ } catch {
15437
+ return { protocol: "none" };
15438
+ }
15439
+ }
15440
+ function forgeKindForHost(host) {
15441
+ const name = host.toLowerCase().split(":")[0];
15442
+ if (name === GITHUB_HOST || name.endsWith(`.${GITHUB_HOST}`))
15443
+ return "github";
15444
+ if (name === "bitbucket.org" || name.includes("bitbucket"))
15445
+ return "bitbucket";
15446
+ if (name === "dev.azure.com" || name.endsWith(".visualstudio.com"))
15447
+ return "azure";
15448
+ if (name.includes("gitlab"))
15449
+ return "gitlab";
15450
+ if (name.includes("forgejo"))
15451
+ return "forgejo";
15452
+ if (name.includes("gitea"))
15453
+ return "gitea";
15454
+ return "generic";
15455
+ }
15456
+ async function diagnoseProjectRemote(projectDir, options = {}) {
15457
+ const classification = options.source ?? await detectProjectSource(projectDir);
15458
+ const rawRemoteUrl = classification.type === "local-git-folder" ? classification.remoteUrl : undefined;
15459
+ const branch = classification.type === "local-git-folder" ? classification.branch : undefined;
15460
+ if (!rawRemoteUrl) {
15461
+ return {
15462
+ classification,
15463
+ remoteProtocol: "none",
15464
+ ...branch ? { branch } : {},
15465
+ credentialPresent: false,
15466
+ provider: null,
15467
+ tokenSettingsUrl: null,
15468
+ canSync: false,
15469
+ guidance: "local-only"
15470
+ };
15471
+ }
15472
+ const { protocol, host } = parseRemoteOrigin(rawRemoteUrl);
15473
+ const urlExtraction = protocol === "https" ? extractUrlCredential(rawRemoteUrl) : null;
15474
+ const remoteUrl = urlExtraction ? urlExtraction.cleanUrl : rawRemoteUrl;
15475
+ const sanitizedClassification = classification.type === "local-git-folder" && classification.remoteUrl ? { ...classification, remoteUrl } : classification;
15476
+ let credentialPresent = false;
15477
+ if (host && options.tokenStore) {
15478
+ try {
15479
+ credentialPresent = await options.tokenStore.get(host) !== null;
15480
+ } catch {
15481
+ credentialPresent = false;
14848
15482
  }
14849
- return { refreshed: false, reason: "offline" };
14850
15483
  }
15484
+ if (!credentialPresent && urlExtraction?.credential)
15485
+ credentialPresent = true;
15486
+ const provider = host && protocol !== "none" ? forgeKindForHost(host) : null;
15487
+ const canSync = protocol === "https" && credentialPresent;
15488
+ let guidance;
15489
+ if (protocol === "ssh")
15490
+ guidance = "ssh-use-own-tools";
15491
+ else if (protocol !== "https")
15492
+ guidance = "local-only";
15493
+ else if (credentialPresent)
15494
+ guidance = "ready-to-sync";
15495
+ else if (provider === "github")
15496
+ guidance = "connect-github-to-sync";
15497
+ else
15498
+ guidance = "https-connect-server";
15499
+ return {
15500
+ classification: sanitizedClassification,
15501
+ remoteUrl,
15502
+ ...host ? { remoteHost: host } : {},
15503
+ remoteProtocol: protocol === "none" ? "none" : protocol,
15504
+ ...branch ? { branch } : {},
15505
+ credentialPresent,
15506
+ provider,
15507
+ tokenSettingsUrl: host ? knownForgeTokenUrl(host) : null,
15508
+ canSync,
15509
+ guidance
15510
+ };
14851
15511
  }
14852
15512
  // src/lib/remote-auth/repair.ts
14853
- import git7 from "isomorphic-git";
14854
- import { cp as cp5, mkdir as mkdir15, rename as rename5, rm as rm7 } from "node:fs/promises";
14855
- import path26 from "node:path";
15513
+ import git8 from "isomorphic-git";
15514
+ import { cp as cp5, mkdir as mkdir16, rename as rename5, rm as rm7 } from "node:fs/promises";
15515
+ import path27 from "node:path";
14856
15516
  var REPAIR_SNAPSHOT_MESSAGE = "Repaired online backup";
14857
15517
  async function moveDir(from, to) {
14858
- await mkdir15(path26.dirname(to), { recursive: true });
15518
+ await mkdir16(path27.dirname(to), { recursive: true });
14859
15519
  try {
14860
15520
  await rename5(from, to);
14861
15521
  } catch (e) {
@@ -14868,14 +15528,14 @@ async function moveDir(from, to) {
14868
15528
  async function repairOnlineBackup(options) {
14869
15529
  const logger = resolveLogger(options.logFile, "repair");
14870
15530
  const dir = await repoDirFor(options.projectDir);
14871
- const oldGit = path26.join(dir, ".git");
14872
- const movedGitTo = path26.join(options.backupDir, "git");
14873
- const freshDir = path26.join(options.backupDir, "fresh-clone");
15531
+ const oldGit = path27.join(dir, ".git");
15532
+ const movedGitTo = path27.join(options.backupDir, "git");
15533
+ const freshDir = path27.join(options.backupDir, "fresh-clone");
14874
15534
  const { restoredFiles, credential } = await withRepoLock(dir, async () => {
14875
15535
  const transport = await resolveTransport(dir, options);
14876
15536
  let branch;
14877
15537
  try {
14878
- branch = await git7.currentBranch({ fs: gitFs, dir }) ?? undefined;
15538
+ branch = await git8.currentBranch({ fs: gitFs, dir }) ?? undefined;
14879
15539
  } catch {
14880
15540
  branch = undefined;
14881
15541
  }
@@ -14891,7 +15551,7 @@ async function repairOnlineBackup(options) {
14891
15551
  logger.info("swap", "moving old history aside", { to: movedGitTo });
14892
15552
  await moveDir(oldGit, movedGitTo);
14893
15553
  try {
14894
- await moveDir(path26.join(freshDir, ".git"), oldGit);
15554
+ await moveDir(path27.join(freshDir, ".git"), oldGit);
14895
15555
  } catch (e) {
14896
15556
  await rm7(oldGit, { recursive: true, force: true });
14897
15557
  await moveDir(movedGitTo, oldGit);
@@ -14901,10 +15561,10 @@ async function repairOnlineBackup(options) {
14901
15561
  const ref = fresh.branch ?? "HEAD";
14902
15562
  let missing = [];
14903
15563
  try {
14904
- const tracked = await git7.listFiles({ fs: gitFs, dir, ref });
14905
- missing = tracked.filter((f) => !gitFs.existsSync(path26.join(dir, f)));
15564
+ const tracked = await git8.listFiles({ fs: gitFs, dir, ref });
15565
+ missing = tracked.filter((f) => !gitFs.existsSync(path27.join(dir, f)));
14906
15566
  if (missing.length > 0) {
14907
- await git7.checkout({ fs: gitFs, dir, ref, filepaths: missing, force: true });
15567
+ await git8.checkout({ fs: gitFs, dir, ref, filepaths: missing, force: true });
14908
15568
  logger.info("restore", "restored files missing on this computer", { files: missing });
14909
15569
  }
14910
15570
  } catch (e) {
@@ -14931,21 +15591,21 @@ async function repairOnlineBackup(options) {
14931
15591
  return { outcome, movedGitTo, restoredFiles };
14932
15592
  }
14933
15593
  // src/lib/remote-auth/scorched-earth.ts
14934
- import git8 from "isomorphic-git";
14935
- import { cp as cp6, mkdir as mkdir16, readdir as readdir10, rm as rm8 } from "node:fs/promises";
14936
- import path27 from "node:path";
15594
+ import git9 from "isomorphic-git";
15595
+ import { cp as cp6, mkdir as mkdir17, readdir as readdir10, rm as rm8 } from "node:fs/promises";
15596
+ import path28 from "node:path";
14937
15597
  async function emptyDir(dir) {
14938
15598
  for (const entry of await readdir10(dir)) {
14939
- await rm8(path27.join(dir, entry), { recursive: true, force: true });
15599
+ await rm8(path28.join(dir, entry), { recursive: true, force: true });
14940
15600
  }
14941
15601
  }
14942
15602
  async function copyFilesBack(from, to) {
14943
- const skip = path27.join(from, ".git");
15603
+ const skip = path28.join(from, ".git");
14944
15604
  for (const entry of await readdir10(from)) {
14945
- const src = path27.join(from, entry);
15605
+ const src = path28.join(from, entry);
14946
15606
  if (src === skip)
14947
15607
  continue;
14948
- await cp6(src, path27.join(to, entry), { recursive: true, force: true });
15608
+ await cp6(src, path28.join(to, entry), { recursive: true, force: true });
14949
15609
  }
14950
15610
  }
14951
15611
  async function scorchedEarth(options) {
@@ -14957,12 +15617,12 @@ async function scorchedEarth(options) {
14957
15617
  const transport2 = await resolveTransport(dir, options);
14958
15618
  let branch2;
14959
15619
  try {
14960
- branch2 = await git8.currentBranch({ fs: gitFs, dir }) ?? undefined;
15620
+ branch2 = await git9.currentBranch({ fs: gitFs, dir }) ?? undefined;
14961
15621
  } catch {
14962
15622
  branch2 = undefined;
14963
15623
  }
14964
15624
  logger.info("backup", "copying folder", { to: backupDir });
14965
- await mkdir16(backupDir, { recursive: true });
15625
+ await mkdir17(backupDir, { recursive: true });
14966
15626
  await cp6(dir, backupDir, { recursive: true });
14967
15627
  logger.info("delete", "emptying folder", { dir });
14968
15628
  await emptyDir(dir);
@@ -14995,7 +15655,7 @@ async function scorchedEarth(options) {
14995
15655
  }
14996
15656
  // src/lib/publish/run-publish.ts
14997
15657
  import { readdir as readdir12, stat as stat12 } from "node:fs/promises";
14998
- import path32 from "node:path";
15658
+ import path33 from "node:path";
14999
15659
 
15000
15660
  // src/lib/publish/command-runner.ts
15001
15661
  var CAPTURE_LIMIT = 64 * 1024;
@@ -15074,8 +15734,8 @@ async function resolveSwaCommand(req) {
15074
15734
  const explicit = env.SWA_CLI_PATH?.trim();
15075
15735
  if (explicit)
15076
15736
  return explicit;
15077
- const run2 = req.deps.runCommand ?? defaultCommandRunner;
15078
- return await commandExists("swa", run2, req.deps.env) ? "swa" : null;
15737
+ const run3 = req.deps.runCommand ?? defaultCommandRunner;
15738
+ return await commandExists("swa", run3, req.deps.env) ? "swa" : null;
15079
15739
  }
15080
15740
  var SWA_INSTALL_HINT = "Install the Azure SWA CLI first: `npm install -g @azure/static-web-apps-cli` " + "(needs Node.js), or set SWA_CLI_PATH to the swa binary.";
15081
15741
  var azureSwaProvider = {
@@ -15121,9 +15781,9 @@ var azureSwaProvider = {
15121
15781
  throw new Error(SWA_INSTALL_HINT);
15122
15782
  const cfg = req.config;
15123
15783
  const deployEnv = cfg.env?.trim() || "production";
15124
- const run2 = req.deps.runCommand ?? defaultCommandRunner;
15784
+ const run3 = req.deps.runCommand ?? defaultCommandRunner;
15125
15785
  req.deps.onProgress?.(`Deploying ${req.artifact.path} to Azure SWA (${deployEnv})…`);
15126
- const result = await run2(swa, ["deploy", req.artifact.path, "--env", deployEnv], {
15786
+ const result = await run3(swa, ["deploy", req.artifact.path, "--env", deployEnv], {
15127
15787
  env: { SWA_CLI_DEPLOYMENT_TOKEN: resolved.credential.token },
15128
15788
  onOutput: req.deps.onProgress
15129
15789
  });
@@ -15144,25 +15804,25 @@ ${tail}` : ""}`);
15144
15804
  };
15145
15805
 
15146
15806
  // src/lib/publish/providers/guided.ts
15147
- import { copyFile as copyFile2, mkdir as mkdir17, writeFile as writeFile14 } from "node:fs/promises";
15148
- import path28 from "node:path";
15807
+ import { copyFile as copyFile2, mkdir as mkdir18, writeFile as writeFile15 } from "node:fs/promises";
15808
+ import path29 from "node:path";
15149
15809
  async function stageGuidedPackage(req, providerId, listingLines) {
15150
- const packageDir = path28.join(path28.dirname(req.artifact.path), "publish", providerId);
15151
- await mkdir17(packageDir, { recursive: true });
15152
- await copyFile2(req.artifact.path, path28.join(packageDir, path28.basename(req.artifact.path)));
15810
+ const packageDir = path29.join(path29.dirname(req.artifact.path), "publish", providerId);
15811
+ await mkdir18(packageDir, { recursive: true });
15812
+ await copyFile2(req.artifact.path, path29.join(packageDir, path29.basename(req.artifact.path)));
15153
15813
  const { title, authors } = req.project;
15154
15814
  const sheet = [
15155
15815
  `# ${title || "Untitled"}`,
15156
15816
  "",
15157
15817
  `- **Authors:** ${authors.length ? authors.join(", ") : "(not set)"}`,
15158
- `- **File:** ${path28.basename(req.artifact.path)}`,
15818
+ `- **File:** ${path29.basename(req.artifact.path)}`,
15159
15819
  ...listingLines,
15160
15820
  "",
15161
15821
  "Copy these details into the platform's listing form when uploading.",
15162
15822
  ""
15163
15823
  ].join(`
15164
15824
  `);
15165
- await writeFile14(path28.join(packageDir, "LISTING.md"), sheet, "utf8");
15825
+ await writeFile15(path29.join(packageDir, "LISTING.md"), sheet, "utf8");
15166
15826
  return packageDir;
15167
15827
  }
15168
15828
 
@@ -15225,9 +15885,9 @@ var drivethrurpgProvider = {
15225
15885
  };
15226
15886
 
15227
15887
  // src/lib/publish/providers/gdrive.ts
15228
- import { mkdtemp as mkdtemp3, readFile as readFile33, readdir as readdir11, rm as rm9, stat as stat9, writeFile as writeFile15 } from "node:fs/promises";
15888
+ import { mkdtemp as mkdtemp3, readFile as readFile34, readdir as readdir11, rm as rm9, stat as stat9, writeFile as writeFile16 } from "node:fs/promises";
15229
15889
  import { tmpdir as tmpdir3 } from "node:os";
15230
- import path29 from "node:path";
15890
+ import path30 from "node:path";
15231
15891
  import { Zip, ZipDeflate } from "fflate";
15232
15892
 
15233
15893
  // src/lib/publish/google-drive.ts
@@ -15612,34 +16272,34 @@ function zipEntriesNonBlocking(entries, opts) {
15612
16272
  async function collectZipEntries(root, dir, out, onWarn) {
15613
16273
  const entries = await readdir11(dir, { withFileTypes: true });
15614
16274
  for (const entry of entries) {
15615
- const full = path29.join(dir, entry.name);
16275
+ const full = path30.join(dir, entry.name);
15616
16276
  if (entry.isDirectory()) {
15617
16277
  await collectZipEntries(root, full, out, onWarn);
15618
16278
  } else if (entry.isFile()) {
15619
- const rel = path29.relative(root, full).split(path29.sep).join("/");
15620
- out[rel] = await readFile33(full);
16279
+ const rel = path30.relative(root, full).split(path30.sep).join("/");
16280
+ out[rel] = await readFile34(full);
15621
16281
  } else if (entry.isSymbolicLink()) {
15622
16282
  const resolved = await stat9(full).catch(() => null);
15623
16283
  if (resolved?.isFile()) {
15624
- const rel = path29.relative(root, full).split(path29.sep).join("/");
15625
- out[rel] = await readFile33(full);
16284
+ const rel = path30.relative(root, full).split(path30.sep).join("/");
16285
+ out[rel] = await readFile34(full);
15626
16286
  } else if (resolved?.isDirectory()) {
15627
16287
  await collectZipEntries(root, full, out, onWarn);
15628
16288
  } else {
15629
- onWarn?.(`Skipped "${path29.relative(root, full)}" in the website export — it's a broken symlink.`);
16289
+ onWarn?.(`Skipped "${path30.relative(root, full)}" in the website export — it's a broken symlink.`);
15630
16290
  }
15631
16291
  } else {
15632
- onWarn?.(`Skipped "${path29.relative(root, full)}" in the website export — not a file, folder, or symlink.`);
16292
+ onWarn?.(`Skipped "${path30.relative(root, full)}" in the website export — not a file, folder, or symlink.`);
15633
16293
  }
15634
16294
  }
15635
16295
  }
15636
- async function zipHtmlExport(exportDir, title, onWarn, writeArchiveImpl = writeFile15) {
16296
+ async function zipHtmlExport(exportDir, title, onWarn, writeArchiveImpl = writeFile16) {
15637
16297
  const entries = {};
15638
16298
  await collectZipEntries(exportDir, exportDir, entries, onWarn);
15639
16299
  const fileName = `${bookSlug(title)}-website.zip`;
15640
- const tmpRoot = await mkdtemp3(path29.join(tmpdir3(), "gutterpress-gdrive-"));
16300
+ const tmpRoot = await mkdtemp3(path30.join(tmpdir3(), "gutterpress-gdrive-"));
15641
16301
  try {
15642
- const filePath = path29.join(tmpRoot, fileName);
16302
+ const filePath = path30.join(tmpRoot, fileName);
15643
16303
  const zipped = await zipEntriesNonBlocking(entries, { level: 6 });
15644
16304
  await writeArchiveImpl(filePath, zipped);
15645
16305
  return {
@@ -15687,7 +16347,7 @@ var gdriveProvider = {
15687
16347
  const isHtml = req.artifact.format === "html";
15688
16348
  const source = isHtml ? await zipHtmlExport(req.artifact.path, req.project.title, (msg) => req.deps.onProgress?.(msg)) : {
15689
16349
  filePath: req.artifact.path,
15690
- fileName: path29.basename(req.artifact.path),
16350
+ fileName: path30.basename(req.artifact.path),
15691
16351
  mimeType: "application/pdf",
15692
16352
  cleanup: async () => {}
15693
16353
  };
@@ -15737,8 +16397,8 @@ var gdriveProvider = {
15737
16397
  };
15738
16398
 
15739
16399
  // src/lib/publish/butler.ts
15740
- import { chmod as chmod2, mkdir as mkdir18, stat as stat10, writeFile as writeFile16 } from "node:fs/promises";
15741
- import path30 from "node:path";
16400
+ import { chmod as chmod2, mkdir as mkdir19, stat as stat10, writeFile as writeFile17 } from "node:fs/promises";
16401
+ import path31 from "node:path";
15742
16402
  import { unzipSync as unzipSync2 } from "fflate";
15743
16403
  var BUTLER_DOWNLOAD_TIMEOUT_MS = 300000;
15744
16404
  function butlerBrothChannel(platform2 = process.platform, arch2 = process.arch) {
@@ -15775,8 +16435,8 @@ async function ensureButler(deps) {
15775
16435
  }
15776
16436
  if (await commandExists("butler", runCommand, deps.env))
15777
16437
  return "butler";
15778
- const cacheDir = path30.join(deps.configDir ?? defaultConfigDir(), "tools", "butler");
15779
- const cached = path30.join(cacheDir, butlerBinaryName());
16438
+ const cacheDir = path31.join(deps.configDir ?? defaultConfigDir(), "tools", "butler");
16439
+ const cached = path31.join(cacheDir, butlerBinaryName());
15780
16440
  if (await fileExists2(cached))
15781
16441
  return cached;
15782
16442
  const channel = butlerBrothChannel();
@@ -15798,14 +16458,14 @@ async function ensureButler(deps) {
15798
16458
  return new Uint8Array(await response.arrayBuffer());
15799
16459
  });
15800
16460
  const files = unzipSync2(archive);
15801
- await mkdir18(cacheDir, { recursive: true });
16461
+ await mkdir19(cacheDir, { recursive: true });
15802
16462
  let foundBinary = false;
15803
16463
  for (const [name, data] of Object.entries(files)) {
15804
16464
  if (name.endsWith("/"))
15805
16465
  continue;
15806
- const target = path30.join(cacheDir, path30.basename(name));
15807
- await writeFile16(target, data);
15808
- if (path30.basename(name) === butlerBinaryName()) {
16466
+ const target = path31.join(cacheDir, path31.basename(name));
16467
+ await writeFile17(target, data);
16468
+ if (path31.basename(name) === butlerBinaryName()) {
15809
16469
  await chmod2(target, 493).catch(() => {});
15810
16470
  foundBinary = true;
15811
16471
  }
@@ -15926,9 +16586,9 @@ var itchProvider = {
15926
16586
  throw new Error("No itch.io API key found. Connect itch.io (or set BUTLER_API_KEY) first.");
15927
16587
  }
15928
16588
  const butler = await ensureButler(req.deps);
15929
- const run2 = req.deps.runCommand ?? defaultCommandRunner;
16589
+ const run3 = req.deps.runCommand ?? defaultCommandRunner;
15930
16590
  req.deps.onProgress?.(`Uploading to ${target}:${channel} with butler…`);
15931
- const result = await run2(butler, ["push", req.artifact.path, `${target}:${channel}`], {
16591
+ const result = await run3(butler, ["push", req.artifact.path, `${target}:${channel}`], {
15932
16592
  env: { BUTLER_API_KEY: resolved.credential.token },
15933
16593
  onOutput: req.deps.onProgress
15934
16594
  });
@@ -16001,8 +16661,8 @@ var kdpProvider = {
16001
16661
  };
16002
16662
 
16003
16663
  // src/lib/publish/providers/local-folder.ts
16004
- import { cp as cp7, mkdir as mkdir19, stat as stat11 } from "node:fs/promises";
16005
- import path31 from "node:path";
16664
+ import { cp as cp7, mkdir as mkdir20, stat as stat11 } from "node:fs/promises";
16665
+ import path32 from "node:path";
16006
16666
  var LOCAL_PUBLISH_DEFAULT_DIR = "dist";
16007
16667
  var info7 = {
16008
16668
  id: "local",
@@ -16018,7 +16678,7 @@ var info7 = {
16018
16678
  };
16019
16679
  function localPublishDir(req) {
16020
16680
  const dir = typeof req.config.dir === "string" && req.config.dir.trim() ? req.config.dir.trim() : LOCAL_PUBLISH_DEFAULT_DIR;
16021
- return path31.resolve(req.project.projectDir, dir);
16681
+ return path32.resolve(req.project.projectDir, dir);
16022
16682
  }
16023
16683
  var localFolderProvider = {
16024
16684
  info: info7,
@@ -16039,9 +16699,9 @@ var localFolderProvider = {
16039
16699
  },
16040
16700
  async upload(req) {
16041
16701
  const dir = localPublishDir(req);
16042
- await mkdir19(dir, { recursive: true });
16043
- const dest = req.artifact.format === "html" ? dir : path31.join(dir, path31.basename(req.artifact.path));
16044
- if (path31.resolve(dest) !== path31.resolve(req.artifact.path)) {
16702
+ await mkdir20(dir, { recursive: true });
16703
+ const dest = req.artifact.format === "html" ? dir : path32.join(dir, path32.basename(req.artifact.path));
16704
+ if (path32.resolve(dest) !== path32.resolve(req.artifact.path)) {
16045
16705
  await cp7(req.artifact.path, dest, { recursive: true });
16046
16706
  }
16047
16707
  return { kind: "published", detail: `Saved to ${dest}` };
@@ -16303,9 +16963,9 @@ async function resolvePublishRequest(options, deps) {
16303
16963
  const effectiveDeps = effectiveAccount ? { ...deps, credentialAccount: effectiveAccount } : deps;
16304
16964
  const outDir = resolveOutputDir(manifestDir, config.title);
16305
16965
  const effectiveFormat = resolvePublishFormat(provider.info, providerConfig);
16306
- const defaultArtifact = effectiveFormat === "pdf" ? path32.join(outDir, artifactName(config.title, "pdf")) : outDir;
16966
+ const defaultArtifact = effectiveFormat === "pdf" ? path33.join(outDir, artifactName(config.title, "pdf")) : outDir;
16307
16967
  const artifact = {
16308
- path: options.artifactPath ? path32.resolve(options.projectDir, options.artifactPath) : defaultArtifact,
16968
+ path: options.artifactPath ? path33.resolve(options.projectDir, options.artifactPath) : defaultArtifact,
16309
16969
  format: effectiveFormat
16310
16970
  };
16311
16971
  return {
@@ -16369,7 +17029,7 @@ async function artifactIssues(artifact) {
16369
17029
  var BUILD_FINGERPRINT_FILENAME = "build-fingerprint.json";
16370
17030
  async function htmlDirIssues(dir) {
16371
17031
  const issues = [];
16372
- const isThere = async (rel) => stat12(path32.join(dir, rel)).then(() => true, () => false);
17032
+ const isThere = async (rel) => stat12(path33.join(dir, rel)).then(() => true, () => false);
16373
17033
  if (!await isThere(BOOK_HTML)) {
16374
17034
  issues.push({
16375
17035
  severity: "error",
@@ -16488,17 +17148,17 @@ async function disconnectPublishCredential(key, deps, options = {}) {
16488
17148
  }
16489
17149
  }
16490
17150
  // src/lib/publish/selections.ts
16491
- import { chmod as chmod3, mkdir as mkdir20, readFile as readFile34, writeFile as writeFile17 } from "node:fs/promises";
16492
- import path33 from "node:path";
17151
+ import { chmod as chmod3, mkdir as mkdir21, readFile as readFile35, writeFile as writeFile18 } from "node:fs/promises";
17152
+ import path34 from "node:path";
16493
17153
  class PublishSelectionsStore {
16494
17154
  filePath;
16495
17155
  queue = Promise.resolve();
16496
17156
  constructor(filePath) {
16497
- this.filePath = filePath ?? path33.join(defaultConfigDir(), "publish-selections.json");
17157
+ this.filePath = filePath ?? path34.join(defaultConfigDir(), "publish-selections.json");
16498
17158
  }
16499
17159
  async read() {
16500
17160
  try {
16501
- const raw = await readFile34(this.filePath, "utf8");
17161
+ const raw = await readFile35(this.filePath, "utf8");
16502
17162
  const parsed = JSON.parse(raw);
16503
17163
  if (parsed && typeof parsed === "object") {
16504
17164
  return {
@@ -16511,19 +17171,19 @@ class PublishSelectionsStore {
16511
17171
  return { version: 1, global: {}, projects: {} };
16512
17172
  }
16513
17173
  async write(data) {
16514
- await mkdir20(path33.dirname(this.filePath), { recursive: true });
16515
- await writeFile17(this.filePath, JSON.stringify(data, null, 2), {
17174
+ await mkdir21(path34.dirname(this.filePath), { recursive: true });
17175
+ await writeFile18(this.filePath, JSON.stringify(data, null, 2), {
16516
17176
  encoding: "utf8",
16517
17177
  mode: 384
16518
17178
  });
16519
17179
  await chmod3(this.filePath, 384);
16520
17180
  }
16521
17181
  enqueue(fn) {
16522
- const run2 = this.queue.then(fn, fn);
16523
- this.queue = run2.catch(() => {
17182
+ const run3 = this.queue.then(fn, fn);
17183
+ this.queue = run3.catch(() => {
16524
17184
  return;
16525
17185
  });
16526
- return run2;
17186
+ return run3;
16527
17187
  }
16528
17188
  resolve(providerId, projectDir) {
16529
17189
  return this.enqueue(async () => {
@@ -16614,4 +17274,4 @@ async function setPublishProviderConfig(projectDir, providerId, values) {
16614
17274
  await writeManifestDoc(file, doc);
16615
17275
  return settingsFromDoc(doc);
16616
17276
  }
16617
- export { BUNDLED_EXTENSIONS, isBundledExtension, isPathSpecifier, parseExtensionSpecifier, PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, hasProjectManifest, loadManifest, loadManifestWithPath, resolveConfig, SYNC_SNAPSHOT_MESSAGE, log, resolveActiveStyles, listProjectStyles, EXTENSION_MANIFEST_FILENAME, readExtensionMeta, extensionStyleList, extensionEntry, extensionCarries, pathEscapesFolder, assertExtensionContained, resolveExtension, DEFAULT_NPM_REGISTRY, npmRegistryUrl, ensureProjectGitignore, EXTENSIONS_DIR, RECOMMENDED_EXTENSIONS, BUILT_IN_STYLE_SET_IDS, sameExtension, listProjectExtensions, describeExtension, setExtensionEnabled, reorderExtensions, removeExtension, addExtension, listNpmVersions, checkExtensionUpdates, updateExtensions, listBuiltInStyleSets, addBuiltInStyleSet, validateProjectExtensions, readExtensionCss, restorePinnedExtensions, restoreFailureMessage, getChecks, getCheckById, resolveCheckSelectors, formatReport, ruleRemoteUrls, ruleRiskyProps, ruleSyntax, checkCss, inspectImage, runChecks, checkToolAvailability, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, defaultConfigDir, FileTokenStore, extractUrlCredential, getSystemDiagnostics, slugifyProjectName, escapeYamlScalar, scaffoldProject, adoptFolder, EXTENSION_KINDS, RESERVED_PREFIX, resolveExtensionPrefix, scaffoldExtension, BUILT_IN_TEMPLATE_IDS, listBuiltInTemplates, saveProjectAsTemplate, listCustomTemplates, importTemplateFromFolder, SNIPPETS_DIR, extractVariables, substituteVariables, listSnippets, saveSnippet, deleteSnippet, listMergedSnippets, listMarkerComponents, importExtensionFromFile, importExtensionFromUrl, MAX_SEARCH_BYTES, SEARCH_KEYWORDS, searchNpmExtensions, setManifestFields, readManifestFields, setActiveStyles, AUTO_SNAPSHOT_DEFAULT_MINUTES, AUTO_SYNC_MIN_MINUTES, AUTO_SYNC_MAX_MINUTES, AUTO_SYNC_DEFAULT_MINUTES, AUTO_SYNC_PUSH_INTERVAL_MINUTES, autoSnapshotDelayMs, autoSyncDelayMs, isGitInternalPath, resolveGitHubClientId, GITHUB_HOST, GitHubAuthProvider, GDRIVE_HOST, resolveGoogleClientId, resolveGoogleClientSecret, GOOGLE_NOT_CONFIGURED_MESSAGE, requireGoogleClientCredentials, pkceChallengeFromVerifier, GoogleAuthProvider, revokeGoogleCredential, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listGitHubRepositories, listRepoBooks, listGitHubBranches, sanitizeCloneFolderName, cloneRepository, isSshRemoteUrl, testRemoteAccess, knownForgeTokenUrl, connectGenericHost, parseRemoteOrigin, forgeKindForHost, diagnoseProjectRemote, syncProject, refreshRemoteCopies, REPAIR_SNAPSHOT_MESSAGE, repairOnlineBackup, scorchedEarth, listPublishProviders, publishProviderFor, resolvePublishFormat, resolvePublishRequest, runPublish, connectPublishProvider, disconnectPublishCredential, PublishSelectionsStore, readPublishSettings, setPublishProviderConfig };
17277
+ export { BUNDLED_EXTENSIONS, isBundledExtension, isPathSpecifier, parseExtensionSpecifier, PRESET_IDS, PRESETS, TARGETS, TARGET_IDS, publishTargetFor, MANIFEST_FILENAMES, hasProjectManifest, loadManifest, loadManifestWithPath, resolveConfig, SYNC_SNAPSHOT_MESSAGE, log, resolveActiveStyles, listProjectStyles, EXTENSION_MANIFEST_FILENAME, readExtensionMeta, extensionStyleList, extensionEntry, extensionCarries, pathEscapesFolder, assertExtensionContained, resolveExtension, DEFAULT_NPM_REGISTRY, npmRegistryUrl, ensureProjectGitignore, EXTENSIONS_DIR, RECOMMENDED_EXTENSIONS, BUILT_IN_STYLE_SET_IDS, sameExtension, listProjectExtensions, describeExtension, setExtensionEnabled, reorderExtensions, removeExtension, addExtension, listNpmVersions, checkExtensionUpdates, updateExtensions, listBuiltInStyleSets, addBuiltInStyleSet, validateProjectExtensions, readExtensionCss, restorePinnedExtensions, restoreFailureMessage, getChecks, getCheckById, resolveCheckSelectors, formatReport, ruleRemoteUrls, ruleRiskyProps, ruleSyntax, checkCss, inspectImage, runChecks, checkToolAvailability, reportMissingTools, executeValidation, executeAndReport, splitOutPath, runBuild, openPath, startPreviewServer, defaultConfigDir, FileTokenStore, extractUrlCredential, getSystemDiagnostics, readPageSetup, setPageSetup, slugifyProjectName, escapeYamlScalar, scaffoldProject, adoptFolder, EXTENSION_KINDS, RESERVED_PREFIX, resolveExtensionPrefix, scaffoldExtension, BUILT_IN_TEMPLATE_IDS, listBuiltInTemplates, saveProjectAsTemplate, listCustomTemplates, importTemplateFromFolder, SNIPPETS_DIR, extractVariables, substituteVariables, listSnippets, saveSnippet, deleteSnippet, listMergedSnippets, listMarkerComponents, importExtensionFromFile, importExtensionFromUrl, MAX_SEARCH_BYTES, SEARCH_KEYWORDS, searchNpmExtensions, setManifestFields, readManifestFields, setActiveStyles, AUTO_SNAPSHOT_DEFAULT_MINUTES, AUTO_SYNC_MIN_MINUTES, AUTO_SYNC_MAX_MINUTES, AUTO_SYNC_DEFAULT_MINUTES, AUTO_SYNC_PUSH_INTERVAL_MINUTES, autoSnapshotDelayMs, autoSyncDelayMs, isGitInternalPath, resolveGitHubClientId, GITHUB_HOST, GitHubAuthProvider, GDRIVE_HOST, resolveGoogleClientId, resolveGoogleClientSecret, GOOGLE_NOT_CONFIGURED_MESSAGE, requireGoogleClientCredentials, pkceChallengeFromVerifier, GoogleAuthProvider, revokeGoogleCredential, publishCredentialKey, publishConnectionStatus, listPublishAccounts, connectGoogleDrive, listGitHubRepositories, listRepoBooks, listGitHubBranches, syncProject, refreshRemoteCopies, isValidRepositoryName, setUpOnlineBackup, sanitizeCloneFolderName, cloneRepository, isSshRemoteUrl, testRemoteAccess, knownForgeTokenUrl, connectGenericHost, parseRemoteOrigin, forgeKindForHost, diagnoseProjectRemote, REPAIR_SNAPSHOT_MESSAGE, repairOnlineBackup, scorchedEarth, listPublishProviders, publishProviderFor, resolvePublishFormat, resolvePublishRequest, runPublish, connectPublishProvider, disconnectPublishCredential, PublishSelectionsStore, readPublishSettings, setPublishProviderConfig };