gutterpress 0.10.9-beta.1 → 0.10.9

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.
@@ -13,12 +13,12 @@ import {
13
13
  stampCreator,
14
14
  stripAnnotations,
15
15
  warn
16
- } from "./cli-fwh6rkev.js";
16
+ } from "./cli-ypfvnhag.js";
17
17
  import {
18
18
  DEBOUNCE,
19
19
  UsageError,
20
20
  resolvePort
21
- } from "./cli-0tgbgwnj.js";
21
+ } from "./cli-rh05byck.js";
22
22
  import {
23
23
  MARGIN_BOX_IGNORED_PROPERTIES,
24
24
  RENDER_TIMEOUT_MS,
@@ -1247,26 +1247,54 @@ function targetsPageWrapper(selector) {
1247
1247
  return false;
1248
1248
  return !/\bgp-(bleed|pin)\b/.test(selector);
1249
1249
  }
1250
+ var cssWideResetKeywords = new Set(["initial", "unset", "revert", "revert-layer"]);
1251
+ var inertValues = {
1252
+ "z-index": new Set(["auto"]),
1253
+ opacity: new Set(["1", "100%"]),
1254
+ "mix-blend-mode": new Set(["normal"]),
1255
+ "background-blend-mode": new Set(["normal"]),
1256
+ filter: new Set(["none"]),
1257
+ "backdrop-filter": new Set(["none"]),
1258
+ transform: new Set(["none"]),
1259
+ rotate: new Set(["none"]),
1260
+ translate: new Set(["none"]),
1261
+ scale: new Set(["none"]),
1262
+ perspective: new Set(["none"]),
1263
+ "box-shadow": new Set(["none"]),
1264
+ outline: new Set(["none"]),
1265
+ "outline-style": new Set(["none"]),
1266
+ "will-change": new Set(["auto", "none"]),
1267
+ "clip-path": new Set(["none"]),
1268
+ transition: new Set(["none"]),
1269
+ animation: new Set(["none"]),
1270
+ "animation-name": new Set(["none"]),
1271
+ overflow: new Set(["visible"]),
1272
+ "overflow-x": new Set(["visible"]),
1273
+ "overflow-y": new Set(["visible"])
1274
+ };
1275
+ function isInertValue(prop, value) {
1276
+ const v = value.trim().toLowerCase();
1277
+ return cssWideResetKeywords.has(v) || (inertValues[prop]?.has(v) ?? false);
1278
+ }
1250
1279
  function createsStackingContext(decl) {
1251
1280
  const p = decl.prop.toLowerCase();
1252
1281
  const v = decl.value.trim().toLowerCase();
1282
+ if (isInertValue(p, v))
1283
+ return false;
1253
1284
  switch (p) {
1254
- case "z-index":
1255
- return v !== "auto" && v !== "initial" && v !== "unset";
1256
1285
  case "isolation":
1257
1286
  return v === "isolate";
1287
+ case "contain":
1288
+ return /\b(paint|layout|strict|content)\b/.test(v);
1289
+ case "z-index":
1258
1290
  case "opacity":
1259
- return v !== "1" && v !== "100%" && v !== "initial" && v !== "unset";
1260
1291
  case "mix-blend-mode":
1261
- return v !== "normal" && v !== "initial" && v !== "unset";
1262
1292
  case "filter":
1263
1293
  case "backdrop-filter":
1264
1294
  case "transform":
1265
1295
  case "perspective":
1266
1296
  case "will-change":
1267
- return v !== "none" && v !== "initial" && v !== "unset";
1268
- case "contain":
1269
- return /\b(paint|layout|strict|content)\b/.test(v);
1297
+ return true;
1270
1298
  default:
1271
1299
  return false;
1272
1300
  }
@@ -1276,7 +1304,7 @@ function clipsDescendants(decl) {
1276
1304
  if (p !== "overflow" && p !== "overflow-x" && p !== "overflow-y")
1277
1305
  return false;
1278
1306
  const v = decl.value.trim().toLowerCase();
1279
- return v !== "" && !/^(visible|initial|unset|revert)$/.test(v);
1307
+ return v !== "" && !isInertValue(p, v);
1280
1308
  }
1281
1309
  function nodeLoc(node) {
1282
1310
  return {
@@ -1317,6 +1345,8 @@ function checkCss(css, from) {
1317
1345
  root.walkAtRules((at) => reportRemoteUrls(at.params || "", at));
1318
1346
  root.walkDecls((decl) => {
1319
1347
  const prop = decl.prop.toLowerCase();
1348
+ if (isInertValue(prop, decl.value))
1349
+ return;
1320
1350
  if (marginBoxIgnoredProperties.has(prop) && isInPageMarginBox(decl)) {
1321
1351
  warnings.push({
1322
1352
  rule: ruleRiskyProps,
@@ -4169,7 +4199,8 @@ async function runLint(opts = {}) {
4169
4199
  linted++;
4170
4200
  const warnings = checkCss(css, file);
4171
4201
  const errors = warnings.filter((w) => w.severity === "error");
4172
- riskyCount += warnings.filter((w) => w.rule === ruleRiskyProps).length;
4202
+ const risky = warnings.filter((w) => w.rule === ruleRiskyProps);
4203
+ riskyCount += risky.length;
4173
4204
  if (errors.length > 0) {
4174
4205
  log.error(` ${file}`);
4175
4206
  for (const w of errors) {
@@ -4177,6 +4208,12 @@ async function runLint(opts = {}) {
4177
4208
  }
4178
4209
  errorCount += errors.length;
4179
4210
  }
4211
+ if (risky.length > 0) {
4212
+ log.warn(` ${file}`);
4213
+ for (const w of risky) {
4214
+ log.warn(` ${w.line}:${w.column} ${w.message} (${w.rule})`);
4215
+ }
4216
+ }
4180
4217
  }
4181
4218
  if (errorCount > 0) {
4182
4219
  log.error("CSS lint errors found");
@@ -4184,7 +4221,6 @@ async function runLint(opts = {}) {
4184
4221
  }
4185
4222
  if (riskyCount > 0) {
4186
4223
  log.warn(`${riskyCount} risky print properties found (may cause rasterization)`);
4187
- log.warn("The validator will check for actual rasterized pages after PDF generation.");
4188
4224
  } else {
4189
4225
  log.success("CSS lint passed");
4190
4226
  }
@@ -5752,7 +5788,6 @@ registerCheck(check19);
5752
5788
 
5753
5789
  // src/checks/source/local-refs.ts
5754
5790
  import { existsSync as existsSync8 } from "node:fs";
5755
- import { readFile as readFile13 } from "node:fs/promises";
5756
5791
  import { dirname as dirname3, resolve as resolve6 } from "node:path";
5757
5792
 
5758
5793
  // src/lib/asset-inline.ts
@@ -6060,6 +6095,7 @@ async function inlineShapeUrls(html, baseDir) {
6060
6095
  }
6061
6096
 
6062
6097
  // src/checks/source/local-ref-parser.ts
6098
+ import { readFile as readFile13 } from "node:fs/promises";
6063
6099
  function newlineOffsets(content) {
6064
6100
  const offsets = [];
6065
6101
  for (let i = 0;i < content.length; i++)
@@ -6104,7 +6140,7 @@ function createRenderedLocalRefCollector(customPlugins) {
6104
6140
  if (!kind)
6105
6141
  continue;
6106
6142
  const ref = child.attrGet(kind === "image" ? "src" : "href");
6107
- if (!ref)
6143
+ if (ref === null)
6108
6144
  continue;
6109
6145
  const offset = tokenOffsets.get(child);
6110
6146
  refs.push({
@@ -6117,6 +6153,26 @@ function createRenderedLocalRefCollector(customPlugins) {
6117
6153
  return refs;
6118
6154
  };
6119
6155
  }
6156
+ async function* renderedLocalRefs(ctx, checkId, results) {
6157
+ const files = (ctx.markdownFiles ?? []).slice().sort();
6158
+ if (files.length === 0)
6159
+ return;
6160
+ const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
6161
+ results.push(inspectionFailed(checkId, `Plugin "${ref}" could not be loaded, so local references it defines were not checked: ${error2.message}`));
6162
+ });
6163
+ const collectRenderedLocalRefs = createRenderedLocalRefCollector(plugins);
6164
+ for (const file of files) {
6165
+ let refs;
6166
+ try {
6167
+ refs = collectRenderedLocalRefs(await readFile13(file, "utf8"));
6168
+ } catch {
6169
+ results.push(inspectionFailed(checkId, `Could not read source file: ${file}`, { file }));
6170
+ continue;
6171
+ }
6172
+ for (const ref of refs)
6173
+ yield { ...ref, file };
6174
+ }
6175
+ }
6120
6176
 
6121
6177
  // src/checks/source/local-refs.ts
6122
6178
  var check20 = {
@@ -6126,45 +6182,29 @@ var check20 = {
6126
6182
  category: "source",
6127
6183
  phase: "pre-build",
6128
6184
  async run(ctx) {
6129
- const files = (ctx.markdownFiles ?? []).slice().sort();
6130
- if (files.length === 0)
6131
- return [];
6132
6185
  const results = [];
6133
- const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
6134
- results.push(inspectionFailed(check20.id, `Plugin "${ref}" could not be loaded, so local references it defines were not checked: ${error2.message}`));
6135
- });
6136
- const collectRenderedLocalRefs = createRenderedLocalRefCollector(plugins);
6137
- for (const file of files) {
6138
- try {
6139
- const content = await readFile13(file, "utf8");
6140
- for (const { ref, kind, line } of collectRenderedLocalRefs(content)) {
6141
- if (isNonFilesystemRef(ref))
6142
- continue;
6143
- const escape = kind === "image" ? proseImageRefError(ref, ctx.inputDir) : null;
6144
- if (escape) {
6145
- results.push(finding(check20.id, {
6146
- severity: "error",
6147
- message: escape,
6148
- file,
6149
- line
6150
- }));
6151
- continue;
6152
- }
6153
- if (localRefExists(ref, kind, file, ctx.inputDir))
6154
- continue;
6155
- results.push(finding(check20.id, {
6156
- severity: kind === "image" ? "warning" : "error",
6157
- code: kind === "image" ? "missing-image-placeholder" : undefined,
6158
- message: kind === "image" ? `Local image not found; build will substitute a magenta placeholder: ${ref}` : `Local reference not found: ${ref}`,
6159
- file,
6160
- line
6161
- }));
6162
- }
6163
- } catch {
6164
- results.push(inspectionFailed(check20.id, `Could not read source file: ${file}`, {
6165
- file
6186
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check20.id, results)) {
6187
+ if (isNonFilesystemRef(ref))
6188
+ continue;
6189
+ const escape = kind === "image" ? proseImageRefError(ref, ctx.inputDir) : null;
6190
+ if (escape) {
6191
+ results.push(finding(check20.id, {
6192
+ severity: "error",
6193
+ message: escape,
6194
+ file,
6195
+ line
6166
6196
  }));
6197
+ continue;
6167
6198
  }
6199
+ if (localRefExists(ref, kind, file, ctx.inputDir))
6200
+ continue;
6201
+ results.push(finding(check20.id, {
6202
+ severity: kind === "image" ? "warning" : "error",
6203
+ code: kind === "image" ? "missing-image-placeholder" : undefined,
6204
+ message: kind === "image" ? `Local image not found; build will substitute a magenta placeholder: ${ref}` : `Local reference not found: ${ref}`,
6205
+ file,
6206
+ line
6207
+ }));
6168
6208
  }
6169
6209
  return results;
6170
6210
  }
@@ -6185,71 +6225,360 @@ function localRefExists(ref, kind, sourceFile, projectRoot) {
6185
6225
  }
6186
6226
  registerCheck(check20);
6187
6227
 
6188
- // src/checks/source/accessibility-alt-text.ts
6189
- import { readFile as readFile14 } from "node:fs/promises";
6190
- var check21 = {
6191
- id: "source.accessibility.alt-text",
6192
- name: "Image Alt Text",
6193
- description: "Checks markdown images include non-empty alt text",
6194
- category: "source",
6195
- phase: "pre-build",
6196
- async run(ctx) {
6197
- const files = (ctx.markdownFiles ?? []).slice().sort();
6198
- if (files.length === 0)
6199
- return [];
6200
- const results = [];
6201
- for (const file of files) {
6202
- try {
6203
- const content = await readFile14(file, "utf8");
6204
- const lines = content.split(`
6205
- `);
6206
- let inFence = false;
6207
- for (let i = 0;i < lines.length; i++) {
6208
- const line = lines[i] ?? "";
6209
- if (/^\s*(```|~~~)/.test(line)) {
6210
- inFence = !inFence;
6228
+ // src/lib/build-staging.ts
6229
+ import path5 from "node:path";
6230
+ import os from "node:os";
6231
+ import fsp from "node:fs/promises";
6232
+
6233
+ // src/lib/missing-asset-placeholder.ts
6234
+ import { createHash as createHash4 } from "node:crypto";
6235
+ import { deflateSync } from "node:zlib";
6236
+ function placeholderOutputPath(missingOutputPath) {
6237
+ const hash = createHash4("sha256").update(missingOutputPath).digest("hex").slice(0, 16);
6238
+ return `assets/gutterpress-missing/${hash}.png`;
6239
+ }
6240
+ function decodeHtmlAttribute(value) {
6241
+ return value.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
6242
+ }
6243
+ function rewriteMissingImageReferences(html, replacements) {
6244
+ if (replacements.size === 0)
6245
+ return html;
6246
+ const replacementFor = (raw) => replacements.get(raw) ?? replacements.get(decodeHtmlAttribute(raw));
6247
+ const cssUrl = /url\(\s*(?:"([^"]*)"|'([^']*)'|&quot;((?:(?!&quot;).)*)&quot;|([^'"()\s]*))\s*\)/giy;
6248
+ const rewriteCssUrls = (css) => {
6249
+ let out = "";
6250
+ let copiedThrough = 0;
6251
+ let index = 0;
6252
+ while (index < css.length) {
6253
+ if (css.startsWith("/*", index)) {
6254
+ const end = css.indexOf("*/", index + 2);
6255
+ index = end < 0 ? css.length : end + 2;
6256
+ continue;
6257
+ }
6258
+ const char = css[index];
6259
+ if (char === '"' || char === "'") {
6260
+ const quote = char;
6261
+ index++;
6262
+ while (index < css.length) {
6263
+ if (css[index] === "\\") {
6264
+ index += 2;
6211
6265
  continue;
6212
6266
  }
6213
- if (inFence)
6267
+ const current = css[index++];
6268
+ if (current === quote)
6269
+ break;
6270
+ }
6271
+ continue;
6272
+ }
6273
+ const previous = index > 0 ? css[index - 1] : "";
6274
+ if (!/[a-z0-9_-]/i.test(previous) && css.slice(index, index + 3).toLowerCase() === "url") {
6275
+ cssUrl.lastIndex = index;
6276
+ const match = cssUrl.exec(css);
6277
+ if (match) {
6278
+ const raw = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
6279
+ const replacement = replacementFor(raw);
6280
+ if (replacement) {
6281
+ out += css.slice(copiedThrough, index);
6282
+ out += match[3] !== undefined ? `url(&quot;${replacement}&quot;)` : `url("${replacement}")`;
6283
+ index = cssUrl.lastIndex;
6284
+ copiedThrough = index;
6214
6285
  continue;
6215
- for (const alt of extractImageAlts(line)) {
6216
- if (alt.trim())
6217
- continue;
6218
- results.push({
6219
- checkId: check21.id,
6220
- severity: "warning",
6221
- message: "Image is missing alt text",
6222
- file,
6223
- line: i + 1
6224
- });
6225
6286
  }
6226
6287
  }
6227
- } catch {}
6288
+ }
6289
+ index++;
6228
6290
  }
6229
- return results;
6291
+ return copiedThrough === 0 ? css : out + css.slice(copiedThrough);
6292
+ };
6293
+ const rewriteTag = (tag) => {
6294
+ let out = tag;
6295
+ if (/^<img\b/i.test(tag)) {
6296
+ out = out.replace(/(\s+src\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
6297
+ const raw = double ?? single ?? bare ?? "";
6298
+ const replacement = replacementFor(raw);
6299
+ if (!replacement)
6300
+ return whole;
6301
+ if (double !== undefined)
6302
+ return `${prefix}"${replacement}"`;
6303
+ if (single !== undefined)
6304
+ return `${prefix}'${replacement}'`;
6305
+ return `${prefix}${replacement}`;
6306
+ });
6307
+ }
6308
+ if (/^<(?:img|source)\b/i.test(tag)) {
6309
+ out = out.replace(/(\s+srcset\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
6310
+ const value = double ?? single ?? bare ?? "";
6311
+ let rewritten = "";
6312
+ let copiedThrough = 0;
6313
+ let changed = false;
6314
+ for (const candidate of parseSrcsetUrlCandidates(value)) {
6315
+ const replacement = replacementFor(candidate.url);
6316
+ if (!replacement)
6317
+ continue;
6318
+ rewritten += value.slice(copiedThrough, candidate.start) + replacement;
6319
+ copiedThrough = candidate.end;
6320
+ changed = true;
6321
+ }
6322
+ if (!changed)
6323
+ return whole;
6324
+ rewritten += value.slice(copiedThrough);
6325
+ if (double !== undefined)
6326
+ return `${prefix}"${rewritten}"`;
6327
+ if (single !== undefined)
6328
+ return `${prefix}'${rewritten}'`;
6329
+ return `${prefix}${rewritten}`;
6330
+ });
6331
+ }
6332
+ out = out.replace(/(\s+style\s*=\s*)(["'])(.*?)\2/gi, (whole, prefix, quote, value) => {
6333
+ const rewritten = rewriteCssUrls(value);
6334
+ return rewritten === value ? whole : `${prefix}${quote}${rewritten}${quote}`;
6335
+ });
6336
+ return out;
6337
+ };
6338
+ return rewriteActiveHtml(html, rewriteTag, (region, element) => {
6339
+ if (element !== "style")
6340
+ return region;
6341
+ const style = /^(<style\b[^>]*>)([\s\S]*)(<\/style\s*>)$/i.exec(region);
6342
+ return style ? `${rewriteTag(style[1])}${rewriteCssUrls(style[2])}${style[3]}` : region;
6343
+ });
6344
+ }
6345
+ function rewriteActiveHtml(html, rewriteTag, rewriteProtected = (region) => region) {
6346
+ const rewriteTags = (active) => active.replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g, rewriteTag);
6347
+ const protectedRegion = /<!--[\s\S]*?-->|<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
6348
+ let out = "";
6349
+ let last = 0;
6350
+ for (const match of html.matchAll(protectedRegion)) {
6351
+ out += rewriteTags(html.slice(last, match.index));
6352
+ out += rewriteProtected(match[0], match[1]?.toLowerCase());
6353
+ last = (match.index ?? 0) + match[0].length;
6230
6354
  }
6231
- };
6232
- function extractImageAlts(line) {
6233
- const alts = [];
6234
- const inlinePattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
6235
- const referencePattern = /!\[([^\]]*)\]\[[^\]]*\]/g;
6236
- for (const match of line.matchAll(inlinePattern)) {
6237
- alts.push(match[1] ?? "");
6355
+ return out + rewriteTags(html.slice(last));
6356
+ }
6357
+ function crc32(buf) {
6358
+ let c = ~0;
6359
+ for (let i = 0;i < buf.length; i++) {
6360
+ c ^= buf[i];
6361
+ for (let k = 0;k < 8; k++)
6362
+ c = c >>> 1 ^ 3988292384 & -(c & 1);
6238
6363
  }
6239
- for (const match of line.matchAll(referencePattern)) {
6240
- alts.push(match[1] ?? "");
6364
+ return ~c >>> 0;
6365
+ }
6366
+ function chunk(type, data) {
6367
+ const out = new Uint8Array(data.length + 12);
6368
+ const view = new DataView(out.buffer);
6369
+ view.setUint32(0, data.length);
6370
+ for (let i = 0;i < 4; i++)
6371
+ out[4 + i] = type.charCodeAt(i);
6372
+ out.set(data, 8);
6373
+ view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
6374
+ return out;
6375
+ }
6376
+ function placeholderPng(width = 640, height = 480, cell = 32) {
6377
+ const A = [217, 70, 239];
6378
+ const B = [26, 26, 26];
6379
+ const raw = new Uint8Array(height * (1 + width * 3));
6380
+ let p = 0;
6381
+ for (let y = 0;y < height; y++) {
6382
+ raw[p++] = 0;
6383
+ for (let x = 0;x < width; x++) {
6384
+ const c = ((x / cell | 0) + (y / cell | 0)) % 2 === 0 ? A : B;
6385
+ raw[p++] = c[0];
6386
+ raw[p++] = c[1];
6387
+ raw[p++] = c[2];
6388
+ }
6241
6389
  }
6242
- return alts;
6390
+ const ihdr = new Uint8Array(13);
6391
+ const hv = new DataView(ihdr.buffer);
6392
+ hv.setUint32(0, width);
6393
+ hv.setUint32(4, height);
6394
+ ihdr[8] = 8;
6395
+ ihdr[9] = 2;
6396
+ const parts = [
6397
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
6398
+ chunk("IHDR", ihdr),
6399
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
6400
+ chunk("IEND", new Uint8Array(0))
6401
+ ];
6402
+ const total = parts.reduce((n, x) => n + x.length, 0);
6403
+ const png = new Uint8Array(total);
6404
+ let o = 0;
6405
+ for (const part of parts) {
6406
+ png.set(part, o);
6407
+ o += part.length;
6408
+ }
6409
+ return png;
6243
6410
  }
6244
- registerCheck(check21);
6245
6411
 
6246
- // src/checks/source/accessibility-heading-order.ts
6247
- import { readFile as readFile15 } from "node:fs/promises";
6248
- var check22 = {
6249
- id: "source.accessibility.heading-order",
6250
- name: "Heading Order",
6251
- description: "Checks markdown heading levels do not jump by more than one",
6252
- category: "source",
6412
+ // src/lib/build-staging.ts
6413
+ async function shipViewerHtml(htmlFile, outDir) {
6414
+ await fsp.mkdir(path5.join(outDir, "engine"), { recursive: true });
6415
+ await fsp.copyFile(await getAssetPath("engine/gutterpress-viewer.js"), path5.join(outDir, "engine/gutterpress-viewer.js"));
6416
+ const tag = ` <script src="engine/gutterpress-viewer.js"></script>
6417
+ `;
6418
+ const html = await fsp.readFile(htmlFile, "utf-8");
6419
+ await fsp.writeFile(htmlFile, /<\/head>/i.test(html) ? html.replace(/<\/head>/i, tag + "</head>") : tag + html, "utf-8");
6420
+ }
6421
+ async function stageBookAssets(options) {
6422
+ const { renderDir, outDir, htmlFile, imageRefs, cssAssets, onPlan, dropRelativeLinks } = options;
6423
+ const { copies: imageCopies, errors, destinations } = await planImageCopies(renderDir, imageRefs);
6424
+ const copies = [...cssAssets, ...imageCopies];
6425
+ onPlan?.({ unresolved: errors, copyCount: copies.length });
6426
+ const missingPlaceholders = copies.length ? await copyReferencedAssets(copies, outDir) : new Map;
6427
+ const rendered = await fsp.readFile(htmlFile, "utf8");
6428
+ let staged = rendered;
6429
+ if (missingPlaceholders.size > 0) {
6430
+ const rewrites = new Map(missingPlaceholders);
6431
+ for (const [ref, dest] of destinations) {
6432
+ const placeholder = missingPlaceholders.get(dest);
6433
+ if (placeholder)
6434
+ rewrites.set(ref, placeholder);
6435
+ }
6436
+ staged = rewriteMissingImageReferences(staged, rewrites);
6437
+ }
6438
+ if (dropRelativeLinks)
6439
+ staged = dropRelativeLinkHrefs(staged);
6440
+ if (staged.includes("--gp-shape:"))
6441
+ staged = await inlineShapeUrls(staged, outDir);
6442
+ if (staged !== rendered)
6443
+ await fsp.writeFile(htmlFile, staged, "utf8");
6444
+ return { missing: [...missingPlaceholders.keys()].sort() };
6445
+ }
6446
+ async function copyReferencedAssets(copies, outDir) {
6447
+ const dirs = new Set(copies.map((c) => path5.dirname(path5.resolve(outDir, c.to))));
6448
+ await Promise.all([...dirs].map((d) => fsp.mkdir(d, { recursive: true })));
6449
+ const missing = new Map;
6450
+ await Promise.all(copies.map(async (c) => {
6451
+ const dest = path5.resolve(outDir, c.to);
6452
+ try {
6453
+ await fsp.copyFile(c.from, dest);
6454
+ } catch (err) {
6455
+ if (err?.code === "ENOENT") {
6456
+ const placeholder = placeholderOutputPath(c.to);
6457
+ const placeholderDest = path5.resolve(outDir, placeholder);
6458
+ await fsp.mkdir(path5.dirname(placeholderDest), { recursive: true });
6459
+ await fsp.writeFile(placeholderDest, placeholderPng());
6460
+ missing.set(c.to, placeholder);
6461
+ return;
6462
+ }
6463
+ throw new BuildError(`Could not copy asset ${c.from} → ${c.to}: ` + (err instanceof Error ? err.message : String(err)), 1);
6464
+ }
6465
+ }));
6466
+ return missing;
6467
+ }
6468
+ function isPrintResolvableHref(href) {
6469
+ const value = href.trim();
6470
+ if (value.startsWith("#"))
6471
+ return true;
6472
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
6473
+ return scheme !== undefined && scheme !== "file";
6474
+ }
6475
+ function dropRelativeLinkHrefs(html) {
6476
+ const hrefAttr = /\s+href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
6477
+ return rewriteActiveHtml(html, (tag) => {
6478
+ if (!/^<a(?=[\s/>])/i.test(tag))
6479
+ return tag;
6480
+ const m = hrefAttr.exec(tag);
6481
+ if (!m)
6482
+ return tag;
6483
+ const href = decodeHtmlAttribute(m[1] ?? m[2] ?? m[3] ?? "");
6484
+ return isPrintResolvableHref(href) ? tag : tag.replace(hrefAttr, "");
6485
+ });
6486
+ }
6487
+ async function createStageRoot() {
6488
+ return fsp.mkdtemp(path5.join(os.tmpdir(), "gutterpress-stage-"));
6489
+ }
6490
+
6491
+ // src/checks/source/dangling-links.ts
6492
+ var check21 = {
6493
+ id: "source.links.dangling",
6494
+ name: "Dangling Links",
6495
+ description: "Flags relative links the printed book cannot open (their href is dropped at print time)",
6496
+ category: "source",
6497
+ phase: "pre-build",
6498
+ async run(ctx) {
6499
+ const results = [];
6500
+ for await (const { ref, kind, line, file } of renderedLocalRefs(ctx, check21.id, results)) {
6501
+ if (kind !== "link" || isPrintResolvableHref(ref))
6502
+ continue;
6503
+ results.push(finding(check21.id, {
6504
+ severity: "warning",
6505
+ code: "dangling-link",
6506
+ data: { ref },
6507
+ message: `Link "${ref}" cannot be opened from the printed book: a PDF has no files ` + `beside it and no address of its own, so a relative link (a chapter file of ` + `this same book included) leads nowhere. The link text stays but its href is ` + `dropped at print time. Link to a heading with #anchor, use an absolute URL, ` + `or write it as plain text.`,
6508
+ file,
6509
+ line
6510
+ }));
6511
+ }
6512
+ return results;
6513
+ }
6514
+ };
6515
+ registerCheck(check21);
6516
+
6517
+ // src/checks/source/accessibility-alt-text.ts
6518
+ import { readFile as readFile14 } from "node:fs/promises";
6519
+ var check22 = {
6520
+ id: "source.accessibility.alt-text",
6521
+ name: "Image Alt Text",
6522
+ description: "Checks markdown images include non-empty alt text",
6523
+ category: "source",
6524
+ phase: "pre-build",
6525
+ async run(ctx) {
6526
+ const files = (ctx.markdownFiles ?? []).slice().sort();
6527
+ if (files.length === 0)
6528
+ return [];
6529
+ const results = [];
6530
+ for (const file of files) {
6531
+ try {
6532
+ const content = await readFile14(file, "utf8");
6533
+ const lines = content.split(`
6534
+ `);
6535
+ let inFence = false;
6536
+ for (let i = 0;i < lines.length; i++) {
6537
+ const line = lines[i] ?? "";
6538
+ if (/^\s*(```|~~~)/.test(line)) {
6539
+ inFence = !inFence;
6540
+ continue;
6541
+ }
6542
+ if (inFence)
6543
+ continue;
6544
+ for (const alt of extractImageAlts(line)) {
6545
+ if (alt.trim())
6546
+ continue;
6547
+ results.push({
6548
+ checkId: check22.id,
6549
+ severity: "warning",
6550
+ message: "Image is missing alt text",
6551
+ file,
6552
+ line: i + 1
6553
+ });
6554
+ }
6555
+ }
6556
+ } catch {}
6557
+ }
6558
+ return results;
6559
+ }
6560
+ };
6561
+ function extractImageAlts(line) {
6562
+ const alts = [];
6563
+ const inlinePattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
6564
+ const referencePattern = /!\[([^\]]*)\]\[[^\]]*\]/g;
6565
+ for (const match of line.matchAll(inlinePattern)) {
6566
+ alts.push(match[1] ?? "");
6567
+ }
6568
+ for (const match of line.matchAll(referencePattern)) {
6569
+ alts.push(match[1] ?? "");
6570
+ }
6571
+ return alts;
6572
+ }
6573
+ registerCheck(check22);
6574
+
6575
+ // src/checks/source/accessibility-heading-order.ts
6576
+ import { readFile as readFile15 } from "node:fs/promises";
6577
+ var check23 = {
6578
+ id: "source.accessibility.heading-order",
6579
+ name: "Heading Order",
6580
+ description: "Checks markdown heading levels do not jump by more than one",
6581
+ category: "source",
6253
6582
  phase: "pre-build",
6254
6583
  async run(ctx) {
6255
6584
  const files = (ctx.markdownFiles ?? []).slice().sort();
@@ -6277,7 +6606,7 @@ var check22 = {
6277
6606
  const level = match[1].length;
6278
6607
  if (prevLevel != null && level > prevLevel + 1) {
6279
6608
  results.push({
6280
- checkId: check22.id,
6609
+ checkId: check23.id,
6281
6610
  severity: "warning",
6282
6611
  message: `Heading level jump from h${prevLevel} to h${level}`,
6283
6612
  file,
@@ -6291,11 +6620,11 @@ var check22 = {
6291
6620
  return results;
6292
6621
  }
6293
6622
  };
6294
- registerCheck(check22);
6623
+ registerCheck(check23);
6295
6624
 
6296
6625
  // src/checks/source/layout-markers.ts
6297
6626
  import { readFile as readFile16 } from "node:fs/promises";
6298
- var check23 = {
6627
+ var check24 = {
6299
6628
  id: "source.markdown.layout-markers",
6300
6629
  name: "Layout Markers",
6301
6630
  description: "Reports @page/@section/@chapter marker arguments Gutterpress could not understand, plus any unknown gp-* class",
@@ -6307,7 +6636,7 @@ var check23 = {
6307
6636
  return [];
6308
6637
  const results = [];
6309
6638
  const plugins = await loadPlugins(ctx.config.extensions, ctx.inputDir, (ref, error2) => {
6310
- results.push(inspectionFailed(check23.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
6639
+ results.push(inspectionFailed(check24.id, `Plugin "${ref}" could not be loaded, so markers it defines were not checked: ${error2.message}`));
6311
6640
  });
6312
6641
  const md = createMarkdownRenderer(plugins);
6313
6642
  for (const file of files) {
@@ -6316,7 +6645,7 @@ var check23 = {
6316
6645
  const env = {};
6317
6646
  md.render(content, env);
6318
6647
  for (const w of env.layoutWarnings ?? []) {
6319
- results.push(finding(check23.id, {
6648
+ results.push(finding(check24.id, {
6320
6649
  severity: "warning",
6321
6650
  message: w.message,
6322
6651
  file,
@@ -6325,17 +6654,17 @@ var check23 = {
6325
6654
  }));
6326
6655
  }
6327
6656
  } catch (error2) {
6328
- results.push(inspectionFailed(check23.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
6657
+ results.push(inspectionFailed(check24.id, `Could not check layout markers in ${file}: ${error2 instanceof Error ? error2.message : String(error2)}`, { file }));
6329
6658
  }
6330
6659
  }
6331
6660
  return results;
6332
6661
  }
6333
6662
  };
6334
- registerCheck(check23);
6663
+ registerCheck(check24);
6335
6664
 
6336
6665
  // src/checks/source/merge-markers.ts
6337
6666
  import { readFile as readFile17 } from "node:fs/promises";
6338
- import path5 from "node:path";
6667
+ import path6 from "node:path";
6339
6668
 
6340
6669
  // src/checks/asset/extensions.ts
6341
6670
  var RASTER_INSPECTABLE_EXTS = [
@@ -6364,7 +6693,7 @@ var CLOSE_SENTINEL = ">>>>>>> online version";
6364
6693
  function withoutOnlineTag(relPath) {
6365
6694
  return relPath.replace(/\.online(?=\.[^./]*$|$)/, "");
6366
6695
  }
6367
- var check24 = {
6696
+ var check25 = {
6368
6697
  id: "source.sync.merge-markers",
6369
6698
  name: "Combined Versions",
6370
6699
  description: "Finds passages and files still holding two versions (yours and the online copy) after a sync",
@@ -6378,7 +6707,7 @@ var check24 = {
6378
6707
  try {
6379
6708
  content = await readFile17(file, "utf8");
6380
6709
  } catch {
6381
- results.push(inspectionFailed(check24.id, `Could not read source file: ${file}`, { file }));
6710
+ results.push(inspectionFailed(check25.id, `Could not read source file: ${file}`, { file }));
6382
6711
  continue;
6383
6712
  }
6384
6713
  if (!content.includes(OPEN_SENTINEL) && !content.includes(CLOSE_SENTINEL))
@@ -6390,7 +6719,7 @@ var check24 = {
6390
6719
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
6391
6720
  if (line === OPEN_SENTINEL) {
6392
6721
  inBlock = true;
6393
- results.push(finding(check24.id, {
6722
+ results.push(finding(check25.id, {
6394
6723
  severity: "error",
6395
6724
  code: "two-versions-passage",
6396
6725
  message: "This passage has two versions (yours and the online copy) — keep what you want, then delete the marker lines.",
@@ -6399,7 +6728,7 @@ var check24 = {
6399
6728
  }));
6400
6729
  } else if (line === CLOSE_SENTINEL) {
6401
6730
  if (!inBlock) {
6402
- results.push(finding(check24.id, {
6731
+ results.push(finding(check25.id, {
6403
6732
  severity: "error",
6404
6733
  code: "leftover-version-marker",
6405
6734
  message: "This marker line is left over from combining two versions — delete it.",
@@ -6419,8 +6748,8 @@ var check24 = {
6419
6748
  ignore: [...ASSET_SCAN_IGNORE_GLOBS]
6420
6749
  });
6421
6750
  for (const sibling of siblings.sort()) {
6422
- const rel = path5.relative(ctx.inputDir, sibling).split(path5.sep).join("/");
6423
- results.push(finding(check24.id, {
6751
+ const rel = path6.relative(ctx.inputDir, sibling).split(path6.sep).join("/");
6752
+ results.push(finding(check25.id, {
6424
6753
  severity: "warning",
6425
6754
  code: "kept-both-versions",
6426
6755
  message: `Two versions of ${withoutOnlineTag(rel)} are in your project — keep the one you want, then delete the other.`,
@@ -6428,12 +6757,12 @@ var check24 = {
6428
6757
  }));
6429
6758
  }
6430
6759
  } catch (error2) {
6431
- results.push(inspectionFailed(check24.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
6760
+ results.push(inspectionFailed(check25.id, `Could not scan for kept-both files: ${error2 instanceof Error ? error2.message : String(error2)}`));
6432
6761
  }
6433
6762
  return results;
6434
6763
  }
6435
6764
  };
6436
- registerCheck(check24);
6765
+ registerCheck(check25);
6437
6766
 
6438
6767
  // src/checks/asset/image-file-size.ts
6439
6768
  import { stat as stat3 } from "node:fs/promises";
@@ -6442,24 +6771,24 @@ import { stat as stat3 } from "node:fs/promises";
6442
6771
  import { stat as stat2, readFile as readFile18 } from "node:fs/promises";
6443
6772
  var DEFAULT_DPI = 72;
6444
6773
  var cache = new Map;
6445
- async function inspectImage(path6) {
6774
+ async function inspectImage(path7) {
6446
6775
  let st;
6447
6776
  try {
6448
- st = await stat2(path6);
6777
+ st = await stat2(path7);
6449
6778
  } catch {
6450
6779
  return null;
6451
6780
  }
6452
- const hit = cache.get(path6);
6781
+ const hit = cache.get(path7);
6453
6782
  if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
6454
6783
  return hit.info;
6455
6784
  let info2 = null;
6456
6785
  try {
6457
- const buf = await readFile18(path6);
6786
+ const buf = await readFile18(path7);
6458
6787
  info2 = parseImage(buf);
6459
6788
  } catch {
6460
6789
  info2 = null;
6461
6790
  }
6462
- cache.set(path6, { mtimeMs: st.mtimeMs, size: st.size, info: info2 });
6791
+ cache.set(path7, { mtimeMs: st.mtimeMs, size: st.size, info: info2 });
6463
6792
  return info2;
6464
6793
  }
6465
6794
  function parseImage(b) {
@@ -6659,7 +6988,7 @@ async function collectImageFiles(dirs, exts, ignore = ASSET_SCAN_IGNORE_GLOBS) {
6659
6988
  }
6660
6989
 
6661
6990
  // src/checks/asset/image-file-size.ts
6662
- var check25 = {
6991
+ var check26 = {
6663
6992
  id: "asset.image.file-size",
6664
6993
  name: "Image File Size",
6665
6994
  description: "Checks that image files do not exceed the maximum size limit",
@@ -6680,14 +7009,14 @@ var check25 = {
6680
7009
  if (info2.size > maxSize) {
6681
7010
  const sizeMb = (info2.size / 1e6).toFixed(1);
6682
7011
  const maxMb = (maxSize / 1e6).toFixed(1);
6683
- results.push(finding(check25.id, {
7012
+ results.push(finding(check26.id, {
6684
7013
  severity: "warning",
6685
7014
  message: `Image file too large: ${sizeMb}MB (max ${maxMb}MB)`,
6686
7015
  file
6687
7016
  }));
6688
7017
  }
6689
7018
  } catch {
6690
- results.push(inspectionFailed(check25.id, `Could not stat image file: ${file}`, {
7019
+ results.push(inspectionFailed(check26.id, `Could not stat image file: ${file}`, {
6691
7020
  file
6692
7021
  }));
6693
7022
  }
@@ -6695,10 +7024,10 @@ var check25 = {
6695
7024
  return results;
6696
7025
  }
6697
7026
  };
6698
- registerCheck(check25);
7027
+ registerCheck(check26);
6699
7028
 
6700
7029
  // src/checks/asset/image-resolution.ts
6701
- var check26 = {
7030
+ var check27 = {
6702
7031
  id: "asset.image.resolution",
6703
7032
  name: "Image Resolution",
6704
7033
  description: "Checks source image DPI from embedded density metadata",
@@ -6720,7 +7049,7 @@ var check26 = {
6720
7049
  const { xDpi, yDpi } = info2;
6721
7050
  if (xDpi > 0 && yDpi > 0 && (xDpi < minDpi || yDpi < minDpi)) {
6722
7051
  results.push({
6723
- checkId: check26.id,
7052
+ checkId: check27.id,
6724
7053
  severity: "warning",
6725
7054
  message: `Image resolution too low: ${xDpi}x${yDpi} DPI (minimum ${minDpi} DPI)`,
6726
7055
  file
@@ -6730,7 +7059,7 @@ var check26 = {
6730
7059
  return results;
6731
7060
  }
6732
7061
  };
6733
- registerCheck(check26);
7062
+ registerCheck(check27);
6734
7063
 
6735
7064
  // src/checks/asset/image-color-space.ts
6736
7065
  var LABEL = {
@@ -6738,7 +7067,7 @@ var LABEL = {
6738
7067
  gray: "Gray",
6739
7068
  cmyk: "CMYK"
6740
7069
  };
6741
- var check27 = {
7070
+ var check28 = {
6742
7071
  id: "asset.image.color-space",
6743
7072
  name: "Image Color Space",
6744
7073
  description: "Validates image color spaces against allowed list",
@@ -6759,7 +7088,7 @@ var check27 = {
6759
7088
  const cs = info2?.colorSpace;
6760
7089
  if (cs && !allowedLower.has(cs)) {
6761
7090
  results.push({
6762
- checkId: check27.id,
7091
+ checkId: check28.id,
6763
7092
  severity: "warning",
6764
7093
  message: `Image uses ${LABEL[cs] ?? cs} color space (allowed: ${allowed.join(", ")})`,
6765
7094
  file
@@ -6769,10 +7098,10 @@ var check27 = {
6769
7098
  return results;
6770
7099
  }
6771
7100
  };
6772
- registerCheck(check27);
7101
+ registerCheck(check28);
6773
7102
 
6774
7103
  // src/checks/asset/image-alpha.ts
6775
- var check28 = {
7104
+ var check29 = {
6776
7105
  id: "asset.image.alpha-channel",
6777
7106
  name: "Image Alpha Channel",
6778
7107
  description: "Checks for alpha channels in PNG/TIFF images",
@@ -6790,7 +7119,7 @@ var check28 = {
6790
7119
  const info2 = await inspectImage(file);
6791
7120
  if (info2?.hasAlpha) {
6792
7121
  results.push({
6793
- checkId: check28.id,
7122
+ checkId: check29.id,
6794
7123
  severity: "warning",
6795
7124
  message: "Image contains alpha channel, which may cause print issues",
6796
7125
  file
@@ -6800,10 +7129,10 @@ var check28 = {
6800
7129
  return results;
6801
7130
  }
6802
7131
  };
6803
- registerCheck(check28);
7132
+ registerCheck(check29);
6804
7133
 
6805
7134
  // src/checks/asset/image-tac.ts
6806
- var check29 = {
7135
+ var check30 = {
6807
7136
  id: "asset.image.tac-raster",
6808
7137
  name: "Image TAC (Raster)",
6809
7138
  description: "Rasterizes and checks TAC per image using Ghostscript",
@@ -6822,7 +7151,7 @@ var check29 = {
6822
7151
  const ghostscript = await resolveGhostscript();
6823
7152
  if (!ghostscript) {
6824
7153
  return [
6825
- inspectionFailed(check29.id, "Could not inspect image ink coverage: Ghostscript executable not found")
7154
+ inspectionFailed(check30.id, "Could not inspect image ink coverage: Ghostscript executable not found")
6826
7155
  ];
6827
7156
  }
6828
7157
  for (const file of files) {
@@ -6840,7 +7169,7 @@ var check29 = {
6840
7169
  if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
6841
7170
  const tac = (nums[0] + nums[1] + nums[2] + nums[3]) * 100;
6842
7171
  if (tac > maxTac) {
6843
- results.push(finding(check29.id, {
7172
+ results.push(finding(check30.id, {
6844
7173
  severity: "warning",
6845
7174
  message: `Image TAC exceeds limit: ${tac.toFixed(1)}% (max ${maxTac}%)`,
6846
7175
  file,
@@ -6852,16 +7181,16 @@ var check29 = {
6852
7181
  }
6853
7182
  }
6854
7183
  } catch {
6855
- results.push(inspectionFailed(check29.id, `Could not inspect image ink coverage: ${file}`, { file }));
7184
+ results.push(inspectionFailed(check30.id, `Could not inspect image ink coverage: ${file}`, { file }));
6856
7185
  }
6857
7186
  }
6858
7187
  return results;
6859
7188
  }
6860
7189
  };
6861
- registerCheck(check29);
7190
+ registerCheck(check30);
6862
7191
 
6863
7192
  // src/checks/asset/approved-fonts.ts
6864
- var check30 = {
7193
+ var check31 = {
6865
7194
  id: "asset.font.approved-files",
6866
7195
  name: "Approved Font Files",
6867
7196
  description: "Checks font files against the approved file patterns",
@@ -6900,7 +7229,7 @@ var check30 = {
6900
7229
  for (const font of allFonts) {
6901
7230
  if (!approvedFonts.has(font)) {
6902
7231
  results.push({
6903
- checkId: check30.id,
7232
+ checkId: check31.id,
6904
7233
  severity: "warning",
6905
7234
  message: "Font file not in approved list",
6906
7235
  file: font
@@ -6910,7 +7239,7 @@ var check30 = {
6910
7239
  return results;
6911
7240
  }
6912
7241
  };
6913
- registerCheck(check30);
7242
+ registerCheck(check31);
6914
7243
 
6915
7244
  // src/checks/asset/font-license.ts
6916
7245
  import { existsSync as existsSync9 } from "node:fs";
@@ -6925,7 +7254,7 @@ var LICENSE_NAMES = [
6925
7254
  "OFL-1.1.txt",
6926
7255
  "COPYING"
6927
7256
  ];
6928
- var check31 = {
7257
+ var check32 = {
6929
7258
  id: "asset.font.license",
6930
7259
  name: "Font License",
6931
7260
  description: "Checks for license files in font directories",
@@ -6952,7 +7281,7 @@ var check31 = {
6952
7281
  const hasLicense = LICENSE_NAMES.some((name) => existsSync9(resolve7(fontDir, name)));
6953
7282
  if (!hasLicense) {
6954
7283
  results.push({
6955
- checkId: check31.id,
7284
+ checkId: check32.id,
6956
7285
  severity: "warning",
6957
7286
  message: `No font license file found in directory`,
6958
7287
  file: fontDir
@@ -6962,10 +7291,10 @@ var check31 = {
6962
7291
  return results;
6963
7292
  }
6964
7293
  };
6965
- registerCheck(check31);
7294
+ registerCheck(check32);
6966
7295
 
6967
7296
  // src/checks/heuristic/text-density.ts
6968
- var check32 = {
7297
+ var check33 = {
6969
7298
  id: "heuristic.whitespace.text-density",
6970
7299
  name: "Text Density",
6971
7300
  description: "Checks characters-per-page ratio",
@@ -6994,7 +7323,7 @@ var check32 = {
6994
7323
  });
6995
7324
  if (lowPages.length > 0) {
6996
7325
  results.push({
6997
- checkId: check32.id,
7326
+ checkId: check33.id,
6998
7327
  severity: "info",
6999
7328
  message: `Low text density on pages: ${lowPages.join(", ")} (below ${range.min} chars)`,
7000
7329
  file: ctx.pdfPath
@@ -7002,7 +7331,7 @@ var check32 = {
7002
7331
  }
7003
7332
  if (highPages.length > 0) {
7004
7333
  results.push({
7005
- checkId: check32.id,
7334
+ checkId: check33.id,
7006
7335
  severity: "info",
7007
7336
  message: `High text density on pages: ${highPages.join(", ")} (above ${range.max} chars)`,
7008
7337
  file: ctx.pdfPath
@@ -7011,11 +7340,11 @@ var check32 = {
7011
7340
  return results;
7012
7341
  }
7013
7342
  };
7014
- registerCheck(check32);
7343
+ registerCheck(check33);
7015
7344
 
7016
7345
  // src/checks/heuristic/section-density.ts
7017
7346
  import { readFile as readFile19 } from "node:fs/promises";
7018
- var check33 = {
7347
+ var check34 = {
7019
7348
  id: "heuristic.chunking.section-density",
7020
7349
  name: "Section Density",
7021
7350
  description: "Checks heading/paragraph/callout density from source Markdown",
@@ -7041,7 +7370,7 @@ var check33 = {
7041
7370
  const line = lines[i];
7042
7371
  if (/^#{1,6}\s/.test(line)) {
7043
7372
  if (paragraphCount > maxParas) {
7044
- results.push(finding(check33.id, {
7373
+ results.push(finding(check34.id, {
7045
7374
  severity: "info",
7046
7375
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
7047
7376
  file,
@@ -7061,7 +7390,7 @@ var check33 = {
7061
7390
  }
7062
7391
  }
7063
7392
  if (paragraphCount > maxParas) {
7064
- results.push(finding(check33.id, {
7393
+ results.push(finding(check34.id, {
7065
7394
  severity: "info",
7066
7395
  message: `Section has ${paragraphCount} paragraphs (max recommended: ${maxParas})`,
7067
7396
  file,
@@ -7069,7 +7398,7 @@ var check33 = {
7069
7398
  }));
7070
7399
  }
7071
7400
  } catch {
7072
- results.push(inspectionFailed(check33.id, `Could not read source file: ${file}`, {
7401
+ results.push(inspectionFailed(check34.id, `Could not read source file: ${file}`, {
7073
7402
  file
7074
7403
  }));
7075
7404
  }
@@ -7077,10 +7406,10 @@ var check33 = {
7077
7406
  return results;
7078
7407
  }
7079
7408
  };
7080
- registerCheck(check33);
7409
+ registerCheck(check34);
7081
7410
 
7082
7411
  // src/checks/heuristic/layer-count.ts
7083
- var check34 = {
7412
+ var check35 = {
7084
7413
  id: "heuristic.decoration.layer-count",
7085
7414
  name: "Layer Count",
7086
7415
  description: "Counts image objects per page",
@@ -7105,7 +7434,7 @@ var check34 = {
7105
7434
  heavyPages.sort((a, b) => a - b);
7106
7435
  return [
7107
7436
  {
7108
- checkId: check34.id,
7437
+ checkId: check35.id,
7109
7438
  severity: "info",
7110
7439
  message: `Pages with many image layers (>${maxLayers}): ${heavyPages.join(", ")}`,
7111
7440
  file: ctx.pdfPath
@@ -7115,10 +7444,10 @@ var check34 = {
7115
7444
  return [];
7116
7445
  }
7117
7446
  };
7118
- registerCheck(check34);
7447
+ registerCheck(check35);
7119
7448
 
7120
7449
  // src/checks/heuristic/placement-variance.ts
7121
- var check35 = {
7450
+ var check36 = {
7122
7451
  id: "heuristic.layout.placement-variance",
7123
7452
  name: "Placement Variance",
7124
7453
  description: "Analyzes text baseline coordinates for layout consistency",
@@ -7136,7 +7465,7 @@ var check35 = {
7136
7465
  const uniqueX = new Set(positions.map((p) => Math.round(p.x)));
7137
7466
  return [
7138
7467
  {
7139
- checkId: check35.id,
7468
+ checkId: check36.id,
7140
7469
  severity: "info",
7141
7470
  message: `Layout analysis: ${uniqueX.size} unique horizontal text positions across ${positions.length} text blocks.`,
7142
7471
  file: ctx.pdfPath
@@ -7144,21 +7473,21 @@ var check35 = {
7144
7473
  ];
7145
7474
  }
7146
7475
  };
7147
- registerCheck(check35);
7476
+ registerCheck(check36);
7148
7477
 
7149
7478
  // src/checks/tool-check.ts
7150
7479
  async function checkToolAvailability(config, opts = {}) {
7151
7480
  const { checks: checks2 } = selectChecks(opts, config);
7152
7481
  const toolToChecks = new Map;
7153
- for (const check36 of checks2) {
7154
- if (!check36.requiredTools?.length)
7482
+ for (const check37 of checks2) {
7483
+ if (!check37.requiredTools?.length)
7155
7484
  continue;
7156
- for (const tool of check36.requiredTools) {
7485
+ for (const tool of check37.requiredTools) {
7157
7486
  const existing = toolToChecks.get(tool);
7158
7487
  if (existing) {
7159
- existing.push(check36.id);
7488
+ existing.push(check37.id);
7160
7489
  } else {
7161
- toolToChecks.set(tool, [check36.id]);
7490
+ toolToChecks.set(tool, [check37.id]);
7162
7491
  }
7163
7492
  }
7164
7493
  }
@@ -7174,11 +7503,11 @@ async function checkToolAvailability(config, opts = {}) {
7174
7503
  const missing = results.filter((r) => !r.found).map((r) => r.tool);
7175
7504
  const missingSet = new Set(missing);
7176
7505
  const skippedChecks = [];
7177
- for (const check36 of checks2) {
7178
- if (!check36.requiredTools?.length)
7506
+ for (const check37 of checks2) {
7507
+ if (!check37.requiredTools?.length)
7179
7508
  continue;
7180
- if (check36.requiredTools.some((t) => missingSet.has(t))) {
7181
- skippedChecks.push(check36.id);
7509
+ if (check37.requiredTools.some((t) => missingSet.has(t))) {
7510
+ skippedChecks.push(check37.id);
7182
7511
  }
7183
7512
  }
7184
7513
  return { available, missing, skippedChecks, toolToChecks };
@@ -7200,7 +7529,7 @@ import { existsSync as existsSync10 } from "node:fs";
7200
7529
  import { dirname as dirname4, join as join3, resolve as resolve8 } from "node:path";
7201
7530
 
7202
7531
  // src/lib/output-paths.ts
7203
- import path6 from "node:path";
7532
+ import path7 from "node:path";
7204
7533
 
7205
7534
  // src/lib/slug.ts
7206
7535
  function slugify(name, fallback = "") {
@@ -7218,7 +7547,7 @@ function bookSlug(title) {
7218
7547
  return slugify(title ?? "", "book");
7219
7548
  }
7220
7549
  function resolveOutputDir(manifestDir, title) {
7221
- return path6.resolve(manifestDir, DIST_DIRNAME, bookSlug(title));
7550
+ return path7.resolve(manifestDir, DIST_DIRNAME, bookSlug(title));
7222
7551
  }
7223
7552
  function artifactName(title, format) {
7224
7553
  return `${bookSlug(title)}-${format}.pdf`;
@@ -7473,25 +7802,25 @@ async function runChecks(ctx, opts = {}) {
7473
7802
  }
7474
7803
  const release = retainPdfCache();
7475
7804
  try {
7476
- for (const check36 of checks2) {
7805
+ for (const check37 of checks2) {
7477
7806
  try {
7478
- const results = await check36.run(ctx);
7479
- const severityOverride = getCheckSeverityOverride(check36.id, ctx.config);
7807
+ const results = await check37.run(ctx);
7808
+ const severityOverride = getCheckSeverityOverride(check37.id, ctx.config);
7480
7809
  if (severityOverride) {
7481
7810
  for (const r of results) {
7482
7811
  r.severity = severityOverride;
7483
7812
  }
7484
7813
  }
7485
7814
  if (results.length === 0) {
7486
- passed.push(check36.id);
7815
+ passed.push(check37.id);
7487
7816
  } else {
7488
7817
  allResults.push(...results);
7489
7818
  }
7490
7819
  } catch (err) {
7491
7820
  allResults.push({
7492
- checkId: check36.id,
7821
+ checkId: check37.id,
7493
7822
  severity: "error",
7494
- message: `Check "${check36.id}" threw: ${err instanceof Error ? err.message : String(err)}`
7823
+ message: `Check "${check37.id}" threw: ${err instanceof Error ? err.message : String(err)}`
7495
7824
  });
7496
7825
  }
7497
7826
  }
@@ -7862,8 +8191,8 @@ import { randomBytes } from "node:crypto";
7862
8191
  // src/lib/build-fingerprint.ts
7863
8192
  import * as fs from "node:fs";
7864
8193
  import { mkdir as mkdir3, readFile as readFile21, writeFile as writeFile3 } from "node:fs/promises";
7865
- import { createHash as createHash4 } from "node:crypto";
7866
- import path7 from "node:path";
8194
+ import { createHash as createHash5 } from "node:crypto";
8195
+ import path8 from "node:path";
7867
8196
  import git from "isomorphic-git";
7868
8197
  var FINGERPRINT_FILENAME = "build-fingerprint.json";
7869
8198
  var VERSION_TIMEOUT_MS = 4000;
@@ -7912,7 +8241,7 @@ async function getGitRevision(sourceDir) {
7912
8241
  const candidateDirs = [sourceDir, process.cwd()].filter((v) => Boolean(v));
7913
8242
  const seen = new Set;
7914
8243
  for (const dir of candidateDirs) {
7915
- const abs = path7.resolve(dir);
8244
+ const abs = path8.resolve(dir);
7916
8245
  if (seen.has(abs))
7917
8246
  continue;
7918
8247
  seen.add(abs);
@@ -7951,7 +8280,7 @@ async function getEngineBundleHash() {
7951
8280
  readFile21(viewerPath),
7952
8281
  readFile21(agentPath)
7953
8282
  ]);
7954
- const hash = createHash4("sha256");
8283
+ const hash = createHash5("sha256");
7955
8284
  hash.update(viewerBytes);
7956
8285
  hash.update(agentBytes);
7957
8286
  return hash.digest("hex").slice(0, 12);
@@ -7997,8 +8326,8 @@ function sanitizeArgs(args) {
7997
8326
  return out;
7998
8327
  }
7999
8328
  async function writeBuildFingerprint(input) {
8000
- const outputDir = path7.resolve(input.outputDir);
8001
- const outPath = path7.join(outputDir, FINGERPRINT_FILENAME);
8329
+ const outputDir = path8.resolve(input.outputDir);
8330
+ const outPath = path8.join(outputDir, FINGERPRINT_FILENAME);
8002
8331
  const [tools, sourceRevision] = await Promise.all([
8003
8332
  getToolVersions(),
8004
8333
  getGitRevision(input.sourceDir)
@@ -8014,7 +8343,7 @@ async function writeBuildFingerprint(input) {
8014
8343
  iccPath: input.pdfx.iccPath,
8015
8344
  stripAnnotations: input.pdfx.stripAnnotations
8016
8345
  },
8017
- outputDir: input.recordedOutputDir ? path7.resolve(input.recordedOutputDir) : outputDir
8346
+ outputDir: input.recordedOutputDir ? path8.resolve(input.recordedOutputDir) : outputDir
8018
8347
  },
8019
8348
  sourceRevision,
8020
8349
  tools
@@ -8089,245 +8418,6 @@ function computeGates(format, opts, config) {
8089
8418
  return { lint: lint2, preValidate, postValidate };
8090
8419
  }
8091
8420
 
8092
- // src/lib/build-staging.ts
8093
- import path8 from "node:path";
8094
- import os from "node:os";
8095
- import fsp from "node:fs/promises";
8096
-
8097
- // src/lib/missing-asset-placeholder.ts
8098
- import { createHash as createHash5 } from "node:crypto";
8099
- import { deflateSync } from "node:zlib";
8100
- function placeholderOutputPath(missingOutputPath) {
8101
- const hash = createHash5("sha256").update(missingOutputPath).digest("hex").slice(0, 16);
8102
- return `assets/gutterpress-missing/${hash}.png`;
8103
- }
8104
- function decodeHtmlAttribute(value) {
8105
- return value.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
8106
- }
8107
- function rewriteMissingImageReferences(html, replacements) {
8108
- if (replacements.size === 0)
8109
- return html;
8110
- const replacementFor = (raw) => replacements.get(raw) ?? replacements.get(decodeHtmlAttribute(raw));
8111
- const cssUrl = /url\(\s*(?:"([^"]*)"|'([^']*)'|&quot;((?:(?!&quot;).)*)&quot;|([^'"()\s]*))\s*\)/giy;
8112
- const rewriteCssUrls = (css) => {
8113
- let out2 = "";
8114
- let copiedThrough = 0;
8115
- let index = 0;
8116
- while (index < css.length) {
8117
- if (css.startsWith("/*", index)) {
8118
- const end = css.indexOf("*/", index + 2);
8119
- index = end < 0 ? css.length : end + 2;
8120
- continue;
8121
- }
8122
- const char = css[index];
8123
- if (char === '"' || char === "'") {
8124
- const quote = char;
8125
- index++;
8126
- while (index < css.length) {
8127
- if (css[index] === "\\") {
8128
- index += 2;
8129
- continue;
8130
- }
8131
- const current = css[index++];
8132
- if (current === quote)
8133
- break;
8134
- }
8135
- continue;
8136
- }
8137
- const previous = index > 0 ? css[index - 1] : "";
8138
- if (!/[a-z0-9_-]/i.test(previous) && css.slice(index, index + 3).toLowerCase() === "url") {
8139
- cssUrl.lastIndex = index;
8140
- const match = cssUrl.exec(css);
8141
- if (match) {
8142
- const raw = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
8143
- const replacement = replacementFor(raw);
8144
- if (replacement) {
8145
- out2 += css.slice(copiedThrough, index);
8146
- out2 += match[3] !== undefined ? `url(&quot;${replacement}&quot;)` : `url("${replacement}")`;
8147
- index = cssUrl.lastIndex;
8148
- copiedThrough = index;
8149
- continue;
8150
- }
8151
- }
8152
- }
8153
- index++;
8154
- }
8155
- return copiedThrough === 0 ? css : out2 + css.slice(copiedThrough);
8156
- };
8157
- const rewriteTag = (tag) => {
8158
- let out2 = tag;
8159
- if (/^<img\b/i.test(tag)) {
8160
- out2 = out2.replace(/(\s+src\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
8161
- const raw = double ?? single ?? bare ?? "";
8162
- const replacement = replacementFor(raw);
8163
- if (!replacement)
8164
- return whole;
8165
- if (double !== undefined)
8166
- return `${prefix}"${replacement}"`;
8167
- if (single !== undefined)
8168
- return `${prefix}'${replacement}'`;
8169
- return `${prefix}${replacement}`;
8170
- });
8171
- }
8172
- if (/^<(?:img|source)\b/i.test(tag)) {
8173
- out2 = out2.replace(/(\s+srcset\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (whole, prefix, double, single, bare) => {
8174
- const value = double ?? single ?? bare ?? "";
8175
- let rewritten = "";
8176
- let copiedThrough = 0;
8177
- let changed = false;
8178
- for (const candidate of parseSrcsetUrlCandidates(value)) {
8179
- const replacement = replacementFor(candidate.url);
8180
- if (!replacement)
8181
- continue;
8182
- rewritten += value.slice(copiedThrough, candidate.start) + replacement;
8183
- copiedThrough = candidate.end;
8184
- changed = true;
8185
- }
8186
- if (!changed)
8187
- return whole;
8188
- rewritten += value.slice(copiedThrough);
8189
- if (double !== undefined)
8190
- return `${prefix}"${rewritten}"`;
8191
- if (single !== undefined)
8192
- return `${prefix}'${rewritten}'`;
8193
- return `${prefix}${rewritten}`;
8194
- });
8195
- }
8196
- out2 = out2.replace(/(\s+style\s*=\s*)(["'])(.*?)\2/gi, (whole, prefix, quote, value) => {
8197
- const rewritten = rewriteCssUrls(value);
8198
- return rewritten === value ? whole : `${prefix}${quote}${rewritten}${quote}`;
8199
- });
8200
- return out2;
8201
- };
8202
- const rewriteActiveHtml = (active) => active.replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g, (tag) => rewriteTag(tag));
8203
- const protectedRegion = /<!--[\s\S]*?-->|<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
8204
- let out = "";
8205
- let last = 0;
8206
- for (const match of html.matchAll(protectedRegion)) {
8207
- out += rewriteActiveHtml(html.slice(last, match.index));
8208
- if (match[1]?.toLowerCase() === "style") {
8209
- const style = /^(<style\b[^>]*>)([\s\S]*)(<\/style\s*>)$/i.exec(match[0]);
8210
- out += style ? `${rewriteTag(style[1])}${rewriteCssUrls(style[2])}${style[3]}` : match[0];
8211
- } else {
8212
- out += match[0];
8213
- }
8214
- last = (match.index ?? 0) + match[0].length;
8215
- }
8216
- out += rewriteActiveHtml(html.slice(last));
8217
- return out;
8218
- }
8219
- function crc32(buf) {
8220
- let c = ~0;
8221
- for (let i = 0;i < buf.length; i++) {
8222
- c ^= buf[i];
8223
- for (let k = 0;k < 8; k++)
8224
- c = c >>> 1 ^ 3988292384 & -(c & 1);
8225
- }
8226
- return ~c >>> 0;
8227
- }
8228
- function chunk(type, data) {
8229
- const out = new Uint8Array(data.length + 12);
8230
- const view = new DataView(out.buffer);
8231
- view.setUint32(0, data.length);
8232
- for (let i = 0;i < 4; i++)
8233
- out[4 + i] = type.charCodeAt(i);
8234
- out.set(data, 8);
8235
- view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
8236
- return out;
8237
- }
8238
- function placeholderPng(width = 640, height = 480, cell = 32) {
8239
- const A = [217, 70, 239];
8240
- const B = [26, 26, 26];
8241
- const raw = new Uint8Array(height * (1 + width * 3));
8242
- let p = 0;
8243
- for (let y = 0;y < height; y++) {
8244
- raw[p++] = 0;
8245
- for (let x = 0;x < width; x++) {
8246
- const c = ((x / cell | 0) + (y / cell | 0)) % 2 === 0 ? A : B;
8247
- raw[p++] = c[0];
8248
- raw[p++] = c[1];
8249
- raw[p++] = c[2];
8250
- }
8251
- }
8252
- const ihdr = new Uint8Array(13);
8253
- const hv = new DataView(ihdr.buffer);
8254
- hv.setUint32(0, width);
8255
- hv.setUint32(4, height);
8256
- ihdr[8] = 8;
8257
- ihdr[9] = 2;
8258
- const parts = [
8259
- new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
8260
- chunk("IHDR", ihdr),
8261
- chunk("IDAT", new Uint8Array(deflateSync(raw))),
8262
- chunk("IEND", new Uint8Array(0))
8263
- ];
8264
- const total = parts.reduce((n, x) => n + x.length, 0);
8265
- const png = new Uint8Array(total);
8266
- let o = 0;
8267
- for (const part of parts) {
8268
- png.set(part, o);
8269
- o += part.length;
8270
- }
8271
- return png;
8272
- }
8273
-
8274
- // src/lib/build-staging.ts
8275
- async function shipViewerHtml(htmlFile, outDir) {
8276
- await fsp.mkdir(path8.join(outDir, "engine"), { recursive: true });
8277
- await fsp.copyFile(await getAssetPath("engine/gutterpress-viewer.js"), path8.join(outDir, "engine/gutterpress-viewer.js"));
8278
- const tag = ` <script src="engine/gutterpress-viewer.js"></script>
8279
- `;
8280
- const html = await fsp.readFile(htmlFile, "utf-8");
8281
- await fsp.writeFile(htmlFile, /<\/head>/i.test(html) ? html.replace(/<\/head>/i, tag + "</head>") : tag + html, "utf-8");
8282
- }
8283
- async function stageBookAssets(options) {
8284
- const { renderDir, outDir, htmlFile, imageRefs, cssAssets, onPlan } = options;
8285
- const { copies: imageCopies, errors, destinations } = await planImageCopies(renderDir, imageRefs);
8286
- const copies = [...cssAssets, ...imageCopies];
8287
- onPlan?.({ unresolved: errors, copyCount: copies.length });
8288
- const missingPlaceholders = copies.length ? await copyReferencedAssets(copies, outDir) : new Map;
8289
- let staged = await fsp.readFile(htmlFile, "utf8");
8290
- if (missingPlaceholders.size > 0) {
8291
- const rewrites = new Map(missingPlaceholders);
8292
- for (const [ref, dest] of destinations) {
8293
- const placeholder = missingPlaceholders.get(dest);
8294
- if (placeholder)
8295
- rewrites.set(ref, placeholder);
8296
- }
8297
- staged = rewriteMissingImageReferences(staged, rewrites);
8298
- await fsp.writeFile(htmlFile, staged, "utf8");
8299
- }
8300
- if (staged.includes("--gp-shape:")) {
8301
- await fsp.writeFile(htmlFile, await inlineShapeUrls(staged, outDir), "utf8");
8302
- }
8303
- return { missing: [...missingPlaceholders.keys()].sort() };
8304
- }
8305
- async function copyReferencedAssets(copies, outDir) {
8306
- const dirs = new Set(copies.map((c) => path8.dirname(path8.resolve(outDir, c.to))));
8307
- await Promise.all([...dirs].map((d) => fsp.mkdir(d, { recursive: true })));
8308
- const missing = new Map;
8309
- await Promise.all(copies.map(async (c) => {
8310
- const dest = path8.resolve(outDir, c.to);
8311
- try {
8312
- await fsp.copyFile(c.from, dest);
8313
- } catch (err) {
8314
- if (err?.code === "ENOENT") {
8315
- const placeholder = placeholderOutputPath(c.to);
8316
- const placeholderDest = path8.resolve(outDir, placeholder);
8317
- await fsp.mkdir(path8.dirname(placeholderDest), { recursive: true });
8318
- await fsp.writeFile(placeholderDest, placeholderPng());
8319
- missing.set(c.to, placeholder);
8320
- return;
8321
- }
8322
- throw new BuildError(`Could not copy asset ${c.from} → ${c.to}: ` + (err instanceof Error ? err.message : String(err)), 1);
8323
- }
8324
- }));
8325
- return missing;
8326
- }
8327
- async function createStageRoot() {
8328
- return fsp.mkdtemp(path8.join(os.tmpdir(), "gutterpress-stage-"));
8329
- }
8330
-
8331
8421
  // src/lib/build-runner.ts
8332
8422
  function splitOutPath(outArg, format) {
8333
8423
  if (typeof outArg !== "string" || outArg.length === 0) {
@@ -8423,7 +8513,7 @@ async function runQualityGates(ctx) {
8423
8513
  }
8424
8514
  }
8425
8515
  async function renderBook(ctx) {
8426
- const { config, gates, renderDir, workDir, opts } = ctx;
8516
+ const { config, gates, renderDir, workDir, opts, format } = ctx;
8427
8517
  if (config.source.files && config.source.files.length > 0) {
8428
8518
  log.info(`Using specified files (${config.source.files.length} total)`);
8429
8519
  } else {
@@ -8461,6 +8551,7 @@ async function renderBook(ctx) {
8461
8551
  htmlFile,
8462
8552
  imageRefs,
8463
8553
  cssAssets,
8554
+ dropRelativeLinks: format !== "html",
8464
8555
  onPlan: ({ unresolved, copyCount }) => {
8465
8556
  if (unresolved.length > 0) {
8466
8557
  throw new BuildError(`Cannot resolve ${unresolved.length} image reference(s):