@heroiclands/package-build 24.0.2 → 24.0.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.
@@ -68,6 +68,19 @@ export const COMMON_MARK = "•";
68
68
  /** The word-entry span class whose Foundry rendering is a native TOC heading. */
69
69
  export const DICTIONARY_ENTRY_CLASS = "dictionary-entry";
70
70
 
71
+ /**
72
+ * The class on every link an entry writes to another note, so a site can set
73
+ * the link as plain text where its target is not a page a reader can reach.
74
+ */
75
+ export const LEXICAL_LINK_CLASS = "lexical-ref";
76
+
77
+ /** Wikilinks in a gloss, marked with {@link LEXICAL_LINK_CLASS}. */
78
+ const markLinks = (text) =>
79
+ String(text ?? "").replace(
80
+ /\[\[[^\]]*\]\](?!\{)/g,
81
+ (link) => `${link}{.${LEXICAL_LINK_CLASS}}`,
82
+ );
83
+
71
84
  /**
72
85
  * Render a dictionary entry's marked headword as an inline native heading in
73
86
  * Foundry. Its paragraph becomes a div, so the heading has a valid container.
@@ -327,7 +340,7 @@ function render(
327
340
  const text = label(address);
328
341
  const ref = parseAddress(address, addressContext ?? {}, { declared: true });
329
342
  if (ref.reason || !linkable(ref)) return text;
330
- return `[[${renderAddress(ref)}|${String(text).replace(/\|/g, "/")}]]`;
343
+ return `[[${renderAddress(ref)}|${String(text).replace(/\|/g, "/")}]]{.${LEXICAL_LINK_CLASS}}`;
331
344
  };
332
345
  const tag = (language, text) => {
333
346
  if (!text) return "";
@@ -367,7 +380,7 @@ function render(
367
380
  const { address, anchor } = splitAnchor(row.word);
368
381
  const ref = readCanonicalKey(address);
369
382
  if (ref && anchor && linkable(ref))
370
- headword = `**[[${renderAddress(ref)}#${anchor}|${String(row.name).replace(/\|/g, "/")}]]**`;
383
+ headword = `**[[${renderAddress(ref)}#${anchor}|${String(row.name).replace(/\|/g, "/")}]]{.${LEXICAL_LINK_CLASS}}**`;
371
384
  }
372
385
  parts.push(headword);
373
386
  if (row.pronunciation) parts.push(`/${row.pronunciation}/`);
@@ -401,7 +414,7 @@ function render(
401
414
  const bodies = Array.isArray(sense?.bodies) ? sense.bodies : [];
402
415
  const by =
403
416
  bodies.length && !sense.omitBody ? `(_${bodies.map(label).join(", ")}_) ` : "";
404
- return `${by}${sentence(sense?.text)}`;
417
+ return `${by}${markLinks(sentence(sense?.text))}`;
405
418
  };
406
419
  if (senses.length === 1) parts.push(senseText(senses[0]));
407
420
  else senses.forEach((sense, i) => parts.push(`**${i + 1}** ${senseText(sense)}`));
@@ -38,12 +38,6 @@
38
38
  * local pack's output — the resolution logic needs no knowledge of where an
39
39
  * item came from.
40
40
  *
41
- * `assetArchive: true` is the sibling flag, for a dependency whose bytes are
42
- * wanted and whose items are not: an asset-replacement source such as
43
- * `thalornaaltart` ships no Item packs at all. It unpacks the same release
44
- * archive without extracting anything from it, so a relationship declaring
45
- * both flags on one entry is fetched once rather than twice.
46
- *
47
41
  * **The network is never touched by a compile.** Fetching is its own command
48
42
  * (`package-build deps fetch`), and a compile whose cache is cold fails saying
49
43
  * so. A build that silently downloads is not reproducible, fails strangely
@@ -78,22 +72,17 @@ import {
78
72
  const STAMP = ".complete";
79
73
 
80
74
  /**
81
- * Every declared relationship carrying a given flag, in declaration order.
82
- *
83
- * Shared by {@link itemCatalogRelationships} and
84
- * {@link assetArchiveRelationships}: both read the same shape out of the same
85
- * walk, filtered on a different key.
75
+ * Every declared relationship that opted into supplying an item catalogue.
86
76
  *
87
77
  * @param {object} config - The resolved build configuration.
88
- * @param {"itemCatalog"|"assetArchive"} flag - Which boolean to filter on.
89
78
  * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
90
- * The opted-in relationships.
79
+ * The opted-in relationships, in declaration order.
91
80
  */
92
- function flaggedRelationships(config, flag) {
81
+ export function itemCatalogRelationships(config) {
93
82
  const out = [];
94
83
  for (const [kind, entries] of Object.entries(config.relationships ?? {})) {
95
84
  for (const rel of entries ?? []) {
96
- if (rel[flag]) {
85
+ if (rel.itemCatalog) {
97
86
  out.push({
98
87
  id: rel.id,
99
88
  manifest: rel.manifest,
@@ -106,29 +95,6 @@ function flaggedRelationships(config, flag) {
106
95
  return out;
107
96
  }
108
97
 
109
- /**
110
- * Every declared relationship that opted into supplying an item catalogue.
111
- *
112
- * @param {object} config - The resolved build configuration.
113
- * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
114
- * The opted-in relationships, in declaration order.
115
- */
116
- export function itemCatalogRelationships(config) {
117
- return flaggedRelationships(config, "itemCatalog");
118
- }
119
-
120
- /**
121
- * Every declared relationship that opted into supplying its release archive
122
- * for asset bytes alone, with no item catalogue built from it.
123
- *
124
- * @param {object} config - The resolved build configuration.
125
- * @returns {Array<{id: string, manifest: string, kind: string, verified: string|undefined}>}
126
- * The opted-in relationships, in declaration order.
127
- */
128
- export function assetArchiveRelationships(config) {
129
- return flaggedRelationships(config, "assetArchive");
130
- }
131
-
132
98
  /**
133
99
  * The cache directory for one dependency at one version.
134
100
  *
@@ -401,21 +367,14 @@ export function pinnedManifestUrl(url, verified) {
401
367
  }
402
368
 
403
369
  /**
404
- * Resolve a dependency's manifest, pinned to `compatibility.verified` the way
405
- * both fetch paths require.
406
- *
407
- * Shared by {@link fetchCatalog} and {@link fetchAssetArchive}: both read the
408
- * same manifest before deciding what to unpack from it.
370
+ * Resolve a dependency's manifest, pinned to `compatibility.verified`.
409
371
  *
410
372
  * @param {{id: string, manifest: string, verified?: string}} rel - The
411
373
  * declared relationship.
412
- * @param {string} subject - What floats when no version is verified —
413
- * "catalogue" or "archive" — named in the warning a reader would otherwise
414
- * get no word for.
415
374
  * @returns {Promise<{manifest: object, version: string}>} The manifest and
416
375
  * its resolved version.
417
376
  */
418
- async function resolveManifest(rel, subject) {
377
+ async function resolveManifest(rel) {
419
378
  const { url, pinned } = pinnedManifestUrl(rel.manifest, rel.verified);
420
379
  const manifest = await fetchManifest(url);
421
380
  const version = manifest.version;
@@ -432,7 +391,7 @@ async function resolveManifest(rel, subject) {
432
391
  }
433
392
  if (!rel.verified) {
434
393
  log.warn(
435
- `${rel.id}: no \`compatibility.verified\`, so its ${subject} floats ` +
394
+ `${rel.id}: no \`compatibility.verified\`, so its catalogue floats ` +
436
395
  `with whatever ${url} currently serves`,
437
396
  );
438
397
  }
@@ -449,7 +408,7 @@ async function resolveManifest(rel, subject) {
449
408
  * @returns {Promise<string>} The dependency's cache directory.
450
409
  */
451
410
  export async function fetchCatalog(config, rel) {
452
- const { manifest, version } = await resolveManifest(rel, "catalogue");
411
+ const { manifest, version } = await resolveManifest(rel);
453
412
  const dir = catalogDir(config, rel.id, version);
454
413
  if (isComplete(dir)) {
455
414
  log.info(`${rel.id}@${version}: already cached`);
@@ -475,61 +434,6 @@ export async function fetchCatalog(config, rel) {
475
434
  return dir;
476
435
  }
477
436
 
478
- /**
479
- * Whether an asset-archive-only cache is present and complete.
480
- *
481
- * No item catalogue is built on this path, so completeness never asks for
482
- * `item-packs.json` — an asset-only dependency, such as `thalornaaltart`,
483
- * ships none.
484
- *
485
- * @param {string} dir - The dependency's cache directory.
486
- * @returns {boolean} True when the archive was fetched to completion.
487
- */
488
- const isArchiveComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
489
-
490
- /**
491
- * Fetch one dependency's release archive for its asset bytes alone, building
492
- * no item catalogue from it.
493
- *
494
- * Idempotent: a complete cache for the resolved version is left alone. Where a
495
- * relationship also declares `itemCatalog: true`, {@link fetchCatalog} already
496
- * unpacked this same archive into this same cache directory, and
497
- * {@link fetchAllCatalogs} does not call this function for it — the two never
498
- * download the same bytes twice.
499
- *
500
- * @param {object} config - The resolved build configuration.
501
- * @param {{id: string, manifest: string, verified?: string}} rel - The
502
- * declared relationship.
503
- * @returns {Promise<string>} The dependency's cache directory.
504
- */
505
- export async function fetchAssetArchive(config, rel) {
506
- const { manifest, version } = await resolveManifest(rel, "archive");
507
- const dir = catalogDir(config, rel.id, version);
508
- if (isArchiveComplete(dir)) {
509
- log.info(`${rel.id}@${version}: already cached`);
510
- return dir;
511
- }
512
-
513
- const download = manifest.download;
514
- if (!download) {
515
- throw new Error(`${rel.id}@${version}: its manifest declares no \`download\``);
516
- }
517
-
518
- // Rebuild from empty: a previous run may have died partway, and a stale
519
- // half-tree is worse than no tree.
520
- fs.rmSync(dir, { recursive: true, force: true });
521
- const raw = path.join(dir, "package");
522
- fs.mkdirSync(raw, { recursive: true });
523
-
524
- log.info(`${rel.id}@${version}: downloading ${download}`);
525
- await downloadAndUnzip(download, raw);
526
-
527
- cacheSchemaArtifact(raw, dir);
528
- // Last, so a fetch that died partway is never mistaken for a complete one.
529
- fs.writeFileSync(path.join(dir, STAMP), `${version}\n`);
530
- return dir;
531
- }
532
-
533
437
  /**
534
438
  * Locate a declared pack inside an unpacked archive.
535
439
  *
@@ -811,33 +715,18 @@ export async function fetchAllMetadata(config) {
811
715
  /**
812
716
  * Fetch every opted-in dependency. The `deps fetch` command.
813
717
  *
814
- * A relationship declaring both `itemCatalog: true` and `assetArchive: true`
815
- * is fetched once: {@link fetchCatalog} already unpacks the archive on its
816
- * way to extracting the item catalogue, so this skips the archive-only fetch
817
- * for any id {@link fetchCatalog} already handled.
818
718
  *
819
719
  * @param {object} config - The resolved build configuration.
820
720
  * @returns {Promise<number>} How many dependencies were fetched.
821
721
  */
822
722
  export async function fetchAllCatalogs(config) {
823
723
  const itemRels = itemCatalogRelationships(config);
824
- const archiveRels = assetArchiveRelationships(config);
825
- if (!itemRels.length && !archiveRels.length) {
826
- log.info(
827
- "No relationship declares `itemCatalog: true` or `assetArchive: true`; " +
828
- "nothing to fetch.",
829
- );
724
+ if (!itemRels.length) {
725
+ log.info("No relationship declares `itemCatalog: true`; nothing to fetch.");
830
726
  return 0;
831
727
  }
832
728
  for (const rel of itemRels) await fetchCatalog(config, rel);
833
- const alreadyFetched = new Set(itemRels.map((rel) => rel.id));
834
- let archiveFetched = 0;
835
- for (const rel of archiveRels) {
836
- if (alreadyFetched.has(rel.id)) continue;
837
- await fetchAssetArchive(config, rel);
838
- archiveFetched++;
839
- }
840
- return itemRels.length + archiveFetched;
729
+ return itemRels.length;
841
730
  }
842
731
 
843
732
  /**
@@ -253,7 +253,7 @@ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
253
253
  joining: { position: "BIGINT", first: LIST, second: LIST },
254
254
  joins: { position: "BIGINT", letters: LIST },
255
255
  marks: { position: "BIGINT", letters: LIST },
256
- skeleton: { position: "BIGINT", second: LIST },
256
+ skeleton: { position: "BIGINT", second: LIST, third: LIST },
257
257
  frames: {
258
258
  position: "BIGINT",
259
259
  class: LIST,
@@ -911,14 +911,14 @@ export function markRows(records) {
911
911
  }
912
912
 
913
913
  /**
914
- * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` a list.
914
+ * The `skeleton` rows: one per `data.sounds.skeleton` entry, `second` and `third` lists.
915
915
  *
916
916
  * @param {Iterable<Record<string, any>>} records - Index records.
917
917
  * @returns {object[]}
918
918
  */
919
919
  export function skeletonRows(records) {
920
920
  return listRows(records, (data) => soundsOf(data).skeleton, SKELETON_KEYS, {
921
- lists: ["second"],
921
+ lists: ["second", "third"],
922
922
  });
923
923
  }
924
924
 
@@ -152,7 +152,7 @@ export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
152
152
  export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
153
153
 
154
154
  /** What a written mark says of its letter. */
155
- export const MARK_KINDS = Object.freeze(["stress", "length"]);
155
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart"]);
156
156
 
157
157
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
158
158
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
@@ -831,6 +831,12 @@ export const SKELETON_FIELDS = Object.freeze([
831
831
  required: true,
832
832
  describe: "The second consonants of a root the row holds for; `any` for every other.",
833
833
  }),
834
+ Object.freeze({
835
+ name: "third",
836
+ ...SOUNDS_OR_ANY,
837
+ describe:
838
+ "The third consonants of a root the row holds for; every third consonant where absent.",
839
+ }),
834
840
  Object.freeze({
835
841
  name: "between",
836
842
  kind: "string",
@@ -124,15 +124,7 @@ import {
124
124
  pdfTextWidth,
125
125
  resamplePdfImages,
126
126
  } from "./pdf-images.mjs";
127
- import {
128
- ASSETS_SEGMENT,
129
- assetPathnameKey,
130
- pathnameProblem,
131
- resolvePathname,
132
- } from "./pathnames.mjs";
133
- import { ASSET_SYSTEM, assetTypeOfRoot, isAssetShortcode } from "./asset-types.mjs";
134
- import { canonicalKey } from "./address.mjs";
135
- import { isComplete as isForeignCacheComplete, newestVersionDir } from "./metadata-index.mjs";
127
+ import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
136
128
  import {
137
129
  createParser,
138
130
  labelFor,
@@ -147,7 +139,6 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
147
139
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
148
140
  import { drawMapsFrom } from "./derived-sections.mjs";
149
141
  import { generatedDrawing } from "./generated-sections.mjs";
150
- import { resolveAssetReplacement } from "./asset-replacement.mjs";
151
142
  import { typstScriptFontPath } from "./script-font-delivery.mjs";
152
143
 
153
144
  /**
@@ -166,39 +157,18 @@ import { typstScriptFontPath } from "./script-font-delivery.mjs";
166
157
  * a build from touching a path outside its own output.
167
158
  *
168
159
  * A file **this** package ships stages directly. A pathname naming another
169
- * package's file stages instead from a declared replacement's cached archive,
170
- * when `opts.resolveReplacement` — the pure function
171
- * {@link module:engine/asset-replacement.resolveAssetReplacement} —
172
- * answers it; `opts.foreignIndex` is the merged index that answer is read
173
- * against. A pathname naming a package no replacement carries it for, or a
174
- * URL, names something no build here can open — a build reaches no network —
175
- * and the caller reports it as a picture the book will not carry.
160
+ * package's file, or a URL, names something no build here can open — a build
161
+ * reaches no network — and the caller reports it as a picture the book will
162
+ * not carry.
176
163
  *
177
164
  * @param {string} src - The pathname, as authored.
178
165
  * @param {object} config - The resolved configuration.
179
- * @param {object} [opts]
180
- * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
181
- * dependency's own records, keyed by canonical address — the index a
182
- * replacement's record is read out of.
183
- * @param {Function} [opts.resolveReplacement] - {@link
184
- * module:engine/asset-replacement.resolveAssetReplacement}, or an
185
- * equivalent a caller injects for a test. Omitted, a foreign pathname this
186
- * package does not own stays declined exactly as it always has.
187
166
  * @returns {{from: string, to: string}|null} The file, and where under the
188
167
  * output directory it is staged.
189
- * @throws {Error} When a declared replacement answers the address but its
190
- * archive was never fetched.
191
168
  */
192
- export function stagedImagePath(src, config, opts = {}) {
169
+ export function stagedImagePath(src, config) {
193
170
  const forms = resolvePathname(src, config);
194
171
  if (!forms || forms.state !== "package") return null;
195
- // A declared replacement answers before the package's own file does, which
196
- // is the whole of what declaring one buys: the address a note writes is
197
- // this package's own, and the picture staged for it is the replacement's.
198
- // A miss falls through, so a package declaring none stages exactly what it
199
- // ships.
200
- const replaced = opts.resolveReplacement ? stagedReplacementPath(forms, config, opts) : null;
201
- if (replaced) return replaced;
202
172
  if (forms.own) {
203
173
  return {
204
174
  from: path.resolve(config.rootDir, forms.local),
@@ -208,126 +178,6 @@ export function stagedImagePath(src, config, opts = {}) {
208
178
  return null;
209
179
  }
210
180
 
211
- /**
212
- * Every declared relationship that opted a dependency in as this package's
213
- * asset replacement, in declaration order.
214
- *
215
- * The same shape {@link module:engine/foreign-catalog.itemCatalogRelationships}
216
- * returns, read directly rather than through that module: this file stays a
217
- * leaf over the configuration it is handed, and the flag is as small to read
218
- * back as it was to validate.
219
- *
220
- * @param {object} config - The resolved build configuration.
221
- * @returns {Array<{id: string, package: string}>} The relationships, each
222
- * with the package segment its own content index publishes under.
223
- */
224
- function assetReplacementRelationships(config) {
225
- const out = [];
226
- for (const entries of Object.values(config.relationships ?? {})) {
227
- for (const rel of entries ?? []) {
228
- if (rel.assetReplacement) {
229
- out.push({ id: rel.id, package: rel.contentPackage ?? rel.id });
230
- }
231
- }
232
- }
233
- return out;
234
- }
235
-
236
- /**
237
- * The address a pathname this package does not own is read back as, for
238
- * asking whether a declared replacement answers it instead of the package
239
- * the pathname names.
240
- *
241
- * {@link module:engine/asset-replacement.resolveAssetReplacement} only ever
242
- * matches an address whose package segment is the compiling package's own,
243
- * so the pathname's own package is set aside and its suffix — the one piece
244
- * every surface derives from, and the piece a replacement publishes under
245
- * its own package instead — is read back as this package's type and
246
- * shortcode.
247
- *
248
- * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
249
- * resolved.
250
- * @param {object} config - The resolved build configuration.
251
- * @returns {string|null} The canonical address, or `null` when the suffix is
252
- * not shaped like an asset's.
253
- */
254
- function localAssetAddress(forms, config) {
255
- const segments = String(forms.suffix ?? "").split("/");
256
- const assetType = assetTypeOfRoot(segments[0]);
257
- if (!assetType) return null;
258
- const base = segments[segments.length - 1];
259
- const ext = path.extname(base);
260
- const shortcode = (ext ? base.slice(0, -ext.length) : base).toLowerCase();
261
- if (!isAssetShortcode(shortcode)) return null;
262
- return canonicalKey(config.contentPackage, ASSET_SYSTEM, assetType.type, shortcode);
263
- }
264
-
265
- /**
266
- * The cache directory one relationship's archive was fetched into, found the
267
- * way {@link module:engine/foreign-catalog.foreignItemCatalogDirs} finds an
268
- * item catalogue's: the newest `.complete`-stamped `<id>@<version>`
269
- * directory under `config.paths.foreignCache`.
270
- *
271
- * @param {object} config - The resolved build configuration.
272
- * @param {string} id - The relationship's dependency id.
273
- * @returns {string|null} The directory, or `null` when nothing is cached.
274
- */
275
- function cachedReplacementDir(config, id) {
276
- const root = config.paths.foreignCache;
277
- if (!fs.existsSync(root)) return null;
278
- const cached = fs
279
- .readdirSync(root)
280
- .filter((name) => name.startsWith(`${id}@`))
281
- .map((name) => path.join(root, name))
282
- .filter(isForeignCacheComplete);
283
- return cached.length ? newestVersionDir(cached) : null;
284
- }
285
-
286
- /**
287
- * The file a declared replacement stages in place of a pathname this package
288
- * does not own, or `null` when no replacement answers it.
289
- *
290
- * @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
291
- * resolved, with `forms.own` already `false`.
292
- * @param {object} config - The resolved build configuration.
293
- * @param {object} opts
294
- * @param {Map<string, object>} [opts.foreignIndex] - Every fetched
295
- * dependency's own records, keyed by canonical address.
296
- * @param {Function} opts.resolveReplacement - {@link
297
- * module:engine/asset-replacement.resolveAssetReplacement}.
298
- * @returns {{from: string, to: string}|null} The file, staged at the same
299
- * `pdf`-relative path this address would have taken had this package
300
- * shipped it.
301
- * @throws {Error} When the replacement that answers the address has not
302
- * been fetched.
303
- */
304
- function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolveReplacement }) {
305
- const address = localAssetAddress(forms, config);
306
- if (!address) return null;
307
- const relationships = assetReplacementRelationships(config);
308
- if (!relationships.length) return null;
309
- const hit = resolveReplacement(address, {
310
- localPackage: config.contentPackage,
311
- replacements: relationships.map((rel) => rel.package),
312
- index: foreignIndex,
313
- });
314
- if (!hit) return null;
315
- const assetPath = hit.record?.asset?.path;
316
- if (!assetPath) return null;
317
- const rel = relationships.find((entry) => entry.package === hit.package);
318
- const dir = cachedReplacementDir(config, rel?.id ?? hit.package);
319
- if (!dir) {
320
- throw new Error(
321
- `\`${hit.package}\` declares \`assetReplacement: true\` and carries \`${address}\`, ` +
322
- "but its archive has not been fetched. Run `package-build deps fetch` first.",
323
- );
324
- }
325
- return {
326
- from: path.join(dir, ASSETS_SEGMENT, assetPath),
327
- to: forms.pdf,
328
- };
329
- }
330
-
331
181
  /**
332
182
  * Where the book keeps the pictures its section plates are drawn over.
333
183
  *
@@ -553,10 +403,6 @@ function configFileFor(config, rootDir) {
553
403
  * {@link module:engine/pdf-pages}.
554
404
  * @param {Function} [opts.readPages] - {@link
555
405
  * module:engine/pdf-pages.readEntryPages}, for tests.
556
- * @param {Function} [opts.resolveReplacement] - {@link
557
- * module:engine/asset-replacement.resolveAssetReplacement}, for tests.
558
- * Loaded from that module otherwise, and only when a relationship declares
559
- * `assetReplacement: true` — a build with none never imports it.
560
406
  * @returns {Promise<{built: boolean, reason: string|null, findings: object[],
561
407
  * imageReports: object[], books: Array<{id: string, title: string,
562
408
  * built: boolean, typ: string, pdf: string|null, stats: object}>,
@@ -576,7 +422,6 @@ export async function buildPdf({
576
422
  version = "",
577
423
  compile = true,
578
424
  readPages = readEntryPages,
579
- resolveReplacement,
580
425
  } = {}) {
581
426
  const resolved = config ?? loadPackConfig();
582
427
  const findings = [];
@@ -642,7 +487,7 @@ export async function buildPdf({
642
487
  out || resolved.pdf.out || path.join("build", "dist"),
643
488
  );
644
489
 
645
- const world = await prepareWorld(resolved, { contentBase, findings, resolveReplacement });
490
+ const world = await prepareWorld(resolved, { contentBase, findings });
646
491
  if (!world) return none(null);
647
492
 
648
493
  const books = [];
@@ -944,11 +789,10 @@ function locateType(contentBase, record) {
944
789
  * @param {object} shared - Options.
945
790
  * @param {string} shared.contentBase - The content tree.
946
791
  * @param {object[]} shared.findings - Collected here rather than thrown.
947
- * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
948
792
  * @returns {Promise<object|null>} The world, or null when a site gate fired
949
793
  * and the findings say why.
950
794
  */
951
- async function prepareWorld(resolved, { contentBase, findings, resolveReplacement }) {
795
+ async function prepareWorld(resolved, { contentBase, findings }) {
952
796
  // The corpus, once — the same records the index, the site and the SQL
953
797
  // tables read, so the book cannot select a note the other surfaces do not
954
798
  // have.
@@ -1038,17 +882,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1038
882
  const md = createParser(resolved.icons);
1039
883
  const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
1040
884
 
1041
- // Asked only where a relationship declares `assetReplacement: true`: with
1042
- // none declared the resolver is never consulted, and staging gives the
1043
- // answer it gives a package that declares no replacement. A caller may
1044
- // supply its own, which is how the staging tests drive it.
1045
- const stagingOpts = {
1046
- foreignIndex: gates.foreign.index,
1047
- resolveReplacement:
1048
- resolveReplacement ??
1049
- (assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
1050
- };
1051
-
1052
885
  return {
1053
886
  records,
1054
887
  db,
@@ -1060,7 +893,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
1060
893
  assets,
1061
894
  md,
1062
895
  glyphs,
1063
- stagingOpts,
1064
896
  };
1065
897
  }
1066
898
 
@@ -1150,7 +982,6 @@ async function selectBook(book, index, { world, configFile, configText, findings
1150
982
  * @param {{get: Function}} [shared.elsewhere] - From
1151
983
  * {@link module:engine/pdf-pages.elsewhereFor}: the notes another book is
1152
984
  * home to, which this book names by book and page rather than links.
1153
- * @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
1154
985
  * @param {object[]} shared.findings - Collected here rather than thrown.
1155
986
  * @returns {Promise<{built: boolean, typ: string|null, pdf: string|null,
1156
987
  * stats: object|null, imageReports?: object[]}>} What was written: the
@@ -1178,10 +1009,10 @@ async function buildBook(book, shared) {
1178
1009
  * @returns {{from: string, to: string}|null} As {@link stagedImagePath}.
1179
1010
  */
1180
1011
  const stage = (src) => {
1181
- const staged = stagedImagePath(src, resolved, world.stagingOpts);
1012
+ const staged = stagedImagePath(src, resolved);
1182
1013
  return staged && { ...staged, to: inBook(staged.to) };
1183
1014
  };
1184
- const { records, site, gates, byFile, sqlTables, assets, md, glyphs, stagingOpts } = world;
1015
+ const { records, site, gates, byFile, sqlTables, assets, md, glyphs } = world;
1185
1016
 
1186
1017
  fs.mkdirSync(outDir, { recursive: true });
1187
1018
 
@@ -211,3 +211,26 @@ export function pruneGenerated(html, outside) {
211
211
  });
212
212
  return kept.join("");
213
213
  }
214
+
215
+ /**
216
+ * Entries' links set as plain text where they lead outside a set of pages.
217
+ * Each link is the only child of a span carrying the class.
218
+ *
219
+ * @param {string} html - A page body.
220
+ * @param {(href: string) => boolean} outside - Whether an `href` names a page
221
+ * of the site that is not to be linked.
222
+ * @param {string} className - The class the entries' links carry.
223
+ * @returns {string} The body with each such `<a>` replaced by its content.
224
+ */
225
+ export function unlinkEntries(html, outside, className) {
226
+ const marked = new RegExp(
227
+ `<span\\b[^>]*?\\sclass=(?:"[^"]*\\b${className}\\b[^"]*"|'[^']*\\b${className}\\b[^']*')[^>]*>(<a\\b[^>]*>[\\s\\S]*?</a>)</span>`,
228
+ "gi",
229
+ );
230
+ return String(html ?? "").replace(marked, (span, anchor) => {
231
+ const match = new RegExp(ANCHOR.source, "i").exec(anchor);
232
+ const href = match ? (match[1] ?? match[2] ?? match[3] ?? "").replace(/&amp;/g, "&") : "";
233
+ if (href === "" || !outside(href)) return span;
234
+ return anchor.replace(/^<a\b[^>]*>/, "").replace(/<\/a>$/, "");
235
+ });
236
+ }
@@ -69,6 +69,7 @@ import fs from "node:fs";
69
69
  import path from "node:path";
70
70
  import { fileURLToPath } from "node:url";
71
71
 
72
+ import { LEXICAL_LINK_CLASS } from "./dictionary-layout.mjs";
72
73
  import { createRenderer } from "./content-render.mjs";
73
74
  import { resolvePathname } from "./pathnames.mjs";
74
75
  import { minifyHtml } from "./html-minify.mjs";
@@ -82,7 +83,13 @@ import {
82
83
  import { buildSite, gatesFailed } from "./site-build.mjs";
83
84
  import { readCachedNavigation, siteSettings } from "./site-config.mjs";
84
85
  import { atomFeed, robotsTxt, sitemapIndexXml, sitemapXml } from "./site-files.mjs";
85
- import { pageAddress, pruneGenerated, reachablePages, splitContent } from "./site-reach.mjs";
86
+ import {
87
+ pageAddress,
88
+ pruneGenerated,
89
+ reachablePages,
90
+ splitContent,
91
+ unlinkEntries,
92
+ } from "./site-reach.mjs";
86
93
  import { prepareTreeSqlTables } from "./sql-tables.mjs";
87
94
  import { placeGeography, searchMetadata } from "./site-search.mjs";
88
95
  import {
@@ -369,7 +376,12 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
369
376
  },
370
377
  report: sink,
371
378
  });
372
- const authoredOf = (page) => splitContent(page.content).authored;
379
+ const authoredOf = (page) =>
380
+ unlinkEntries(
381
+ splitContent(page.content).authored,
382
+ (url) => pageOfUrl(url) !== undefined,
383
+ LEXICAL_LINK_CLASS,
384
+ );
373
385
  const first = makeTheme({
374
386
  visible: (url) => pageOfUrl(url) === undefined,
375
387
  contentOf: authoredOf,
@@ -405,7 +417,10 @@ export function writeSite({ site, pages, out, report, staticDir = SITE_STATIC_DI
405
417
  const contentOf = (page) => {
406
418
  if (page.content === undefined) return page.content;
407
419
  const { authored, generated } = splitContent(page.content);
408
- return authored + pruneGenerated(generated, outside);
420
+ return (
421
+ unlinkEntries(authored, outside, LEXICAL_LINK_CLASS) +
422
+ pruneGenerated(generated, outside)
423
+ );
409
424
  };
410
425
  const theme = makeTheme({ visible: (url) => !outside(url), contentOf, report });
411
426
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "24.0.2",
3
+ "version": "24.0.3",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -184,6 +184,7 @@
184
184
  },
185
185
  "devDependencies": {
186
186
  "@changesets/cli": "^3.0.0",
187
+ "@heroiclands/fonts": "^0.1.5",
187
188
  "@types/node": "^26.2.0",
188
189
  "esbuild": "^0.28.2",
189
190
  "markdownlint": "^0.42.0",