@heroiclands/package-build 23.8.0 → 23.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/content-commands.mjs +12 -4
  3. package/docs/api.md +328 -28
  4. package/docs/authoring/first-note.md +1 -1
  5. package/docs/authoring/links-and-markup.md +140 -56
  6. package/docs/commands.md +19 -10
  7. package/docs/content-format.md +2 -2
  8. package/docs/diagnostics.md +1 -1
  9. package/docs/guides/packs.md +2 -1
  10. package/docs/guides/reference-pages.md +86 -63
  11. package/docs/reference/format-details.md +269 -123
  12. package/docs/reference/note-types.md +184 -170
  13. package/engine/anchors.mjs +7 -20
  14. package/engine/compile-corpus.mjs +3 -2
  15. package/engine/content-format.yaml +1 -0
  16. package/engine/content-index.mjs +412 -11
  17. package/engine/content-links.mjs +43 -15
  18. package/engine/content-tables.mjs +8 -4
  19. package/engine/culture-reach.mjs +61 -34
  20. package/engine/data-keys.mjs +3 -1
  21. package/engine/dictionary-layout.mjs +151 -19
  22. package/engine/dictionary-rows.mjs +129 -79
  23. package/engine/foundry-entries.mjs +21 -3
  24. package/engine/glossary-entries.mjs +11 -11
  25. package/engine/heading-attributes.mjs +21 -28
  26. package/engine/helpers.mjs +12 -6
  27. package/engine/journals.mjs +4 -1
  28. package/engine/language-data.mjs +53 -0
  29. package/engine/language-terms.mjs +86 -0
  30. package/engine/language-words.mjs +2 -2
  31. package/engine/lexical-sections.mjs +247 -0
  32. package/engine/note-claims.mjs +2 -0
  33. package/engine/note-state.mjs +9 -3
  34. package/engine/note-vocabulary.mjs +7 -6
  35. package/engine/pdf-build.mjs +9 -9
  36. package/engine/site-build.mjs +16 -2
  37. package/engine/sql-tables.mjs +262 -43
  38. package/engine/stub-lint.mjs +6 -2
  39. package/engine/web-wikilinks.mjs +8 -6
  40. package/engine/word-rows.mjs +20 -12
  41. package/engine/word-terms.mjs +15 -6
  42. package/package.json +1 -1
  43. package/types/engine/content-index.d.mts +65 -1
  44. package/types/engine/content-tables.d.mts +2 -1
  45. package/types/engine/culture-reach.d.mts +22 -2
  46. package/types/engine/data-keys.d.mts +7 -0
  47. package/types/engine/dictionary-layout.d.mts +35 -4
  48. package/types/engine/dictionary-rows.d.mts +9 -0
  49. package/types/engine/foundry-entries.d.mts +3 -1
  50. package/types/engine/heading-attributes.d.mts +2 -2
  51. package/types/engine/helpers.d.mts +4 -1
  52. package/types/engine/language-terms.d.mts +7 -0
  53. package/types/engine/lexical-sections.d.mts +95 -0
  54. package/types/engine/note-state.d.mts +2 -1
  55. package/types/engine/site-build.d.mts +4 -1
  56. package/types/engine/sql-tables.d.mts +33 -3
  57. package/types/engine/word-rows.d.mts +9 -7
@@ -39,10 +39,9 @@ import { scanAlerts } from "./content-alerts.mjs";
39
39
  import { scanBlocks } from "./content-blocks.mjs";
40
40
  import { scanPoetry } from "./content-poetry.mjs";
41
41
  import { scanSpans } from "./content-spans.mjs";
42
- import { codeRegions } from "./code-fences.mjs";
43
42
  import { slugify } from "./content-slug.mjs";
44
43
  import { scanFigures } from "./content-figures.mjs";
45
- import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
44
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
46
45
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
47
46
 
48
47
  /**
@@ -71,25 +70,13 @@ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
71
70
  */
72
71
  export function collectAnchors(body, bodyLine = 1, resolveRole) {
73
72
  const anchors = [];
74
- const literal = codeRegions(body, { spans: false, poetryAsProse: false });
75
- let offset = 0;
76
- const lines = String(body ?? "").split("\n");
77
-
78
- for (let i = 0; i < lines.length; i++) {
79
- const lineOffset = offset;
80
- offset += lines[i].length + 1;
81
- if (literal.some((region) => lineOffset >= region.start && lineOffset < region.end))
82
- continue;
83
-
84
- const heading = HEADING_LINE.exec(lines[i]);
85
- if (!heading) continue;
86
- const attributes = splitHeadingAttributes(heading[2]);
87
- if (!attributes.id) continue;
73
+ for (const heading of scanHeadingAttributes(body, bodyLine).headings) {
74
+ if (!heading.id) continue;
88
75
  anchors.push({
89
- slug: attributes.id,
90
- name: attributes.text,
91
- level: heading[1].length,
92
- line: bodyLine + i,
76
+ slug: heading.id,
77
+ name: heading.text,
78
+ level: heading.level,
79
+ line: heading.line,
93
80
  kind: "prose",
94
81
  });
95
82
  }
@@ -84,11 +84,12 @@ export async function buildCompileCorpus({
84
84
  });
85
85
  const corpusOptions = { config: resolved, records };
86
86
  const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
87
+ const sqlTables = await prepareTreeSqlTables(contentBase, sqlOptions);
87
88
  return {
88
89
  records,
89
90
  parsed,
90
91
  problems: collected,
91
- linkIndex: buildContentLinkIndex(contentBase, router, corpusOptions),
92
- sqlTables: await prepareTreeSqlTables(contentBase, sqlOptions),
92
+ linkIndex: buildContentLinkIndex(contentBase, router, { ...corpusOptions, sqlTables }),
93
+ sqlTables,
93
94
  };
94
95
  }
@@ -201,6 +201,7 @@ types:
201
201
  - other
202
202
  skill:
203
203
  data:
204
+ - grammar
204
205
  - templatePriority
205
206
  - masteryLevel
206
207
  - parentSkill
@@ -99,6 +99,7 @@ import {
99
99
  noteAddressContext,
100
100
  encodeAddresses,
101
101
  publishAddresses,
102
+ cloneAddressState,
102
103
  } from "./note-addresses.mjs";
103
104
  import { AddressLink } from "./address-values.mjs";
104
105
  import fs from "node:fs";
@@ -113,9 +114,24 @@ import { addressSlug, canonicalKey } from "./content-address.mjs";
113
114
  import { isAddressTuple, ownDocumentSystem, readCanonicalKey, renderAddress } from "./address.mjs";
114
115
  import { NOTE_SYSTEM } from "./systems.mjs";
115
116
  import { dictionaryAndTermRows, serializeRows } from "./dictionary-rows.mjs";
117
+ import { insertLexicalSections, lexicalSectionsFor } from "./lexical-sections.mjs";
118
+ import { isGmNote } from "./note-vocabulary.mjs";
116
119
  import { attachCitations } from "./word-citations.mjs";
117
120
  import { assetAddressIndex } from "./art-fields.mjs";
118
121
  import { embedRole } from "./content-embeds.mjs";
122
+ import { expandContentTables } from "./content-tables.mjs";
123
+ import {
124
+ codeRegions,
125
+ FENCE_LINE,
126
+ matchAllOutsideCode,
127
+ parseHeaderArgs,
128
+ replaceOutsideCode,
129
+ } from "./code-fences.mjs";
130
+ import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
131
+ import { EXPRESSION, renderMarkdownExpressions } from "./markdown-expressions.mjs";
132
+ import { scanFigures } from "./content-figures.mjs";
133
+ import { eventNoteIndex } from "./event-fields.mjs";
134
+ import { derivedSections } from "./derived-sections.mjs";
119
135
  // One reader for a note's anchors, shared with the link checker and with the
120
136
  // builds that emit a link. Re-exported because this is where callers
121
137
  // have always addressed it.
@@ -133,7 +149,7 @@ import { NO_SYSTEM } from "./document-subtypes.mjs";
133
149
  */
134
150
 
135
151
  export { collectAnchors };
136
- import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
152
+ import { entriesForNote, foundryIdentities, LEAD_ANCHOR } from "./foundry-entries.mjs";
137
153
  import { walkMarkdownTree } from "./helpers.mjs";
138
154
  // The one statement of what an empty body means, shared with the lint and the
139
155
  // SQL view that derives the ladder from the absence this writes.
@@ -643,11 +659,21 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
643
659
  // entry's derivation.
644
660
  id: made ? entry.id : null,
645
661
  nameAscii: asciiName(frontmatter?.name?.full),
646
- address: made ? { slug: address.slug, canonical: entry.key } : null,
662
+ address: made ? { slug: address.slug, canonical: readCanonicalKey(entry.key) } : null,
647
663
  // The record this is the documentation *for*. `documentation` is
648
664
  // the forward link on that record, so either end reaches the other.
649
- documents: address.canonical,
650
- anchors: made ? anchors : null,
665
+ documents: readCanonicalKey(address.canonical),
666
+ anchors:
667
+ made ?
668
+ anchors.map((anchor) => ({
669
+ ...anchor,
670
+ link: new AddressLink(
671
+ readCanonicalKey(entry.key),
672
+ anchor.slug,
673
+ anchor.kind,
674
+ ),
675
+ }))
676
+ : null,
651
677
  foundry: foundryBlock(entry, NOTE_SYSTEM),
652
678
  file,
653
679
  })
@@ -1222,6 +1248,26 @@ export function collectContentIndex(
1222
1248
  for (const [note, inherited] of inheritance.values)
1223
1249
  if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1224
1250
 
1251
+ // Automatic lexical sections require only the recorded frontmatter. Their
1252
+ // published anchors and journal targets therefore use the same pure body
1253
+ // projection as the surfaces, without making the index emitter async.
1254
+ // Public notes resolve providers in their audience, including lineage
1255
+ // through hidden languages, just as their build does.
1256
+ const visible = records.filter((record) => !isGmNote(record));
1257
+ const publicSections = lexicalSectionsFor(visible, records);
1258
+ const allSections = lexicalSectionsFor(records);
1259
+ for (const { record, body } of bodies) {
1260
+ const key = canonicalKey(contentPackage, "note", record.type, record.shortcode);
1261
+ const sections = (isGmNote(record) ? allSections : publicSections).get(key);
1262
+ if (!sections) continue;
1263
+ const expanded = insertLexicalSections(body, sections).markdown;
1264
+ projectPublicationBody(records, record, expanded, {
1265
+ contentPackage,
1266
+ manifest,
1267
+ resolveRole,
1268
+ });
1269
+ }
1270
+
1225
1271
  // Sorted at every depth like a note's record, and for the same reason: the
1226
1272
  // declaration order of the `asset` fields is a fact about the emitter, not
1227
1273
  // about the content, and the artifact is meant to be byte-identical across
@@ -1407,6 +1453,283 @@ export function indexRecordsFor({
1407
1453
  });
1408
1454
  }
1409
1455
 
1456
+ /** Active body directives whose output cannot be inferred synchronously. */
1457
+ function dynamicPublicationBody(body) {
1458
+ const source = String(body ?? "");
1459
+ return (
1460
+ [...matchAllOutsideCode(source, EXPRESSION)].length > 0 ||
1461
+ codeRegions(source, { spans: false, poetryAsProse: false }).some(({ start, end }) => {
1462
+ const opener = FENCE_LINE.exec(source.slice(start, end).split("\n", 1)[0]);
1463
+ const language = opener && parseHeaderArgs(opener[3]).language;
1464
+ return language === "sql" || language === PAGE_LIST_LANGUAGE;
1465
+ })
1466
+ );
1467
+ }
1468
+
1469
+ /** Publication errors are located and always precede output creation. */
1470
+ function publicationProblems(problems) {
1471
+ if (!problems.length) return;
1472
+ throw Object.assign(new Error(problems.map(formatDiagnostic).join("\n")), { located: true });
1473
+ }
1474
+
1475
+ /** Render the shared expression language and carry inserted scalar lines back to their source. */
1476
+ function publicationExpressions(expanded, context) {
1477
+ const source = expanded.markdown;
1478
+ const additions = [];
1479
+ const findings = [];
1480
+ const markdown = replaceOutsideCode(source, EXPRESSION, (expression, _inside, offset) => {
1481
+ const line = source.slice(0, offset).split("\n").length - 1;
1482
+ const location = expanded.lineMap[line] ?? { line, generated: false };
1483
+ const result = renderMarkdownExpressions(expression, {
1484
+ ...context,
1485
+ bodyLine: (context.bodyLine ?? 1) + location.line,
1486
+ });
1487
+ const column = offset - source.lastIndexOf("\n", offset - 1) - 1;
1488
+ findings.push(
1489
+ ...result.findings.map((finding) => ({ ...finding, column: finding.column + column })),
1490
+ );
1491
+ const count = result.markdown.split("\n").length - 1;
1492
+ if (count) additions.push({ line, count, location });
1493
+ return result.markdown;
1494
+ });
1495
+ publicationProblems(findings);
1496
+ const lineMap = expanded.lineMap.map((location) => ({ ...location }));
1497
+ let generatedFrom = expanded.generatedFrom;
1498
+ for (const addition of additions.sort((a, b) => b.line - a.line)) {
1499
+ lineMap.splice(
1500
+ addition.line + 1,
1501
+ 0,
1502
+ ...Array.from({ length: addition.count }, () => ({
1503
+ ...addition.location,
1504
+ generated: true,
1505
+ })),
1506
+ );
1507
+ if (generatedFrom !== undefined && addition.line < expanded.generatedFrom)
1508
+ generatedFrom += addition.count;
1509
+ }
1510
+ return { markdown, lineMap, ...(generatedFrom === undefined ? {} : { generatedFrom }) };
1511
+ }
1512
+
1513
+ /**
1514
+ * Prepare the complete bodies a content-index publication describes.
1515
+ * Prose declarations use each source's audience. Native SQL uses the complete
1516
+ * corpus, with target maps limited to that source's declared prose slugs.
1517
+ * No output files are written, and the returned inputs are read-only to the emitter.
1518
+ *
1519
+ * @param {{contentBase?: string, config?: object}} [options]
1520
+ * @returns {Promise<PreparedContentIndex>}
1521
+ */
1522
+ export async function prepareContentIndex({ contentBase, config } = {}) {
1523
+ const resolved = config ?? loadPackConfig();
1524
+ const tree = path.resolve(contentBase ?? resolved.paths.content);
1525
+ const parsed = new Map();
1526
+ const records = indexRecordsFor({ contentBase: tree, config: resolved, parsed });
1527
+ const expandedBodies = new Map();
1528
+ const foundryBodies = new Map();
1529
+ const foreign = loadForeignIndexes(resolved, [resolved.contentPackage]);
1530
+ publicationProblems(
1531
+ foreign.stale.map((entry) => ({
1532
+ file: tree,
1533
+ severity: "error",
1534
+ message: `unusable ${entry.package} content index: ${entry.reason}`,
1535
+ })),
1536
+ );
1537
+ // Dynamic import keeps SQL preparation out of the synchronous reader's import cycle.
1538
+ const { prepareTreeSqlTables } = await import("./sql-tables.mjs");
1539
+ const { buildSiteIndex, figureIndexKeys, resolveCrossNoteFigures } =
1540
+ await import("./site-index.mjs");
1541
+ const notes = records.filter(isNoteRecord);
1542
+ for (const audience of new Set([
1543
+ ...notes.map((record) => (isGmNote(record) ? "all" : "public")),
1544
+ "all",
1545
+ ])) {
1546
+ const visible = records.filter((record) => audience === "all" || !isGmNote(record));
1547
+ const sources = audience === "all" ? notes : notes.filter((record) => !isGmNote(record));
1548
+ const prepared = await prepareTreeSqlTables(tree, {
1549
+ config: resolved,
1550
+ records,
1551
+ parsed,
1552
+ audience,
1553
+ foreignIndex: foreign.index,
1554
+ });
1555
+ const bodies = new Map();
1556
+ const figuresByFile = new Map();
1557
+ const figuresByAddress = new Map();
1558
+ const art = assetAddressIndex(visible, { config: resolved, foreign });
1559
+ const resolveRole = (address) => embedRole(art, address);
1560
+ const pages = visible.filter(isNoteRecord).flatMap((record) => {
1561
+ const file = noteFile(tree, record);
1562
+ const authored = parsed.get(file);
1563
+ const expanded = expandContentTables(authored.body, {
1564
+ source: file,
1565
+ sqlTables: prepared?.get(file),
1566
+ pageLists: prepared?.pageLists?.get(file),
1567
+ });
1568
+ publicationProblems(
1569
+ expanded.errors.map((problem) => ({
1570
+ file,
1571
+ line: (authored.bodyLine ?? 1) + problem.line,
1572
+ column: problem.column,
1573
+ severity: "error",
1574
+ message: problem.reason,
1575
+ })),
1576
+ );
1577
+ bodies.set(file, expanded);
1578
+ const byId = new Map(
1579
+ scanFigures(expanded.markdown, { resolveRole })
1580
+ .figures.filter((figure) => figure.id)
1581
+ .map((figure) => [
1582
+ figure.id,
1583
+ {
1584
+ label: figure.label,
1585
+ caption: figure.caption,
1586
+ hasCaption: figure.hasCaption,
1587
+ },
1588
+ ]),
1589
+ );
1590
+ figuresByFile.set(file, byId);
1591
+ const address = noteAddress(record, resolved.contentPackage);
1592
+ if (!address) return [];
1593
+ for (const key of figureIndexKeys(
1594
+ resolved.contentPackage,
1595
+ record.type,
1596
+ record.shortcode,
1597
+ ))
1598
+ figuresByAddress.set(key, byId);
1599
+ const slug = address.slug;
1600
+ return [
1601
+ {
1602
+ kind: "content",
1603
+ file,
1604
+ fm: authoredFrontmatter(record),
1605
+ body: authored.body,
1606
+ pkg: resolved.contentPackage,
1607
+ slug,
1608
+ url: `${resolved.site?.base || `/${resolved.contentPackage}/`}${slug}/`,
1609
+ name: record.name?.full,
1610
+ relPath: record.file.path,
1611
+ },
1612
+ ];
1613
+ });
1614
+ const index = buildSiteIndex(pages, {
1615
+ package: resolved.contentPackage,
1616
+ records: visible,
1617
+ foreignIndex: foreign.index,
1618
+ });
1619
+ const dates = index.dateContext;
1620
+ const events = eventNoteIndex(visible, {
1621
+ contentPackage: resolved.contentPackage,
1622
+ foreignIndex: foreign.index,
1623
+ dates,
1624
+ });
1625
+ for (const record of sources) {
1626
+ const file = noteFile(tree, record);
1627
+ const authored = parsed.get(file);
1628
+ const figures = figuresByFile.get(file);
1629
+ const expanded = publicationExpressions(bodies.get(file), {
1630
+ fm: authoredFrontmatter(record),
1631
+ dates,
1632
+ events,
1633
+ file,
1634
+ bodyLine: authored.bodyLine,
1635
+ sqlResults: prepared?.inline?.get(file),
1636
+ figures: {
1637
+ get: (id) => figures?.get(id),
1638
+ note: (target) =>
1639
+ resolveCrossNoteFigures(target, {
1640
+ contentTypes: index.contentTypes,
1641
+ packages: index.packages,
1642
+ noIndexPackages: index.noIndexPackages,
1643
+ contentPackage: index.contentPackage,
1644
+ siteIndexMap: index.index,
1645
+ figuresByAddress,
1646
+ }),
1647
+ },
1648
+ });
1649
+ if (audience === "all") foundryBodies.set(file, expanded);
1650
+ if ((isGmNote(record) ? "all" : "public") === audience)
1651
+ expandedBodies.set(file, expanded);
1652
+ }
1653
+ }
1654
+ return { config: resolved, contentBase: tree, records, parsed, expandedBodies, foundryBodies };
1655
+ }
1656
+
1657
+ /**
1658
+ * A captured source context, consumed without mutation by the synchronous emitter.
1659
+ * @typedef {object} PreparedContentIndex
1660
+ * @property {object} config - The exact resolved configuration for preparation.
1661
+ * @property {string} contentBase - The absolute captured source root.
1662
+ * @property {object[]} records - Complete index records, including hidden providers.
1663
+ * @property {Map<string, {frontmatter: object|null, body: string, raw: string, bodyLine?: number, bodyColumn?: number}>} parsed - Authored sources by absolute file.
1664
+ * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} expandedBodies - Complete rendered bodies in each note's audience.
1665
+ * @property {Map<string, {markdown: string, lineMap: Array<{line: number, generated: boolean}>, generatedFrom?: number}>} foundryBodies - Complete native bodies with explicit SQL using the ALL corpus.
1666
+ */
1667
+
1668
+ /** Update owned copies from an actual output body, retaining authored locations and events. */
1669
+ function projectPublicationBody(
1670
+ records,
1671
+ record,
1672
+ body,
1673
+ { contentPackage, manifest, resolveRole, foundryBody = body },
1674
+ ) {
1675
+ const fm = authoredFrontmatter(record);
1676
+ const address = noteAddress(fm, contentPackage);
1677
+ if (!address) return;
1678
+ const original = new Map(
1679
+ (record.anchors ?? [])
1680
+ .filter((anchor) => anchor.kind === "prose")
1681
+ .map((anchor) => [anchor.slug, anchor]),
1682
+ );
1683
+ const stub = isStubNote(fm, body);
1684
+ record.address = stub ? null : { ...address, canonical: readCanonicalKey(address.canonical) };
1685
+ record.anchors =
1686
+ stub ? null : (
1687
+ [
1688
+ ...collectAnchors(body, 1, resolveRole).map((anchor) => {
1689
+ const { line: _, ...generated } = anchor;
1690
+ return {
1691
+ ...(original.get(anchor.slug) ?? generated),
1692
+ link: new AddressLink(
1693
+ readCanonicalKey(address.canonical),
1694
+ anchor.slug,
1695
+ anchor.kind,
1696
+ ),
1697
+ };
1698
+ }),
1699
+ ...(record.anchors ?? []).filter((anchor) => anchor.kind === "event"),
1700
+ ]
1701
+ );
1702
+ const entries = foundryEntries({ frontmatter: fm, address, body: foundryBody, manifest });
1703
+ const declared = new Set(
1704
+ (record.anchors ?? [])
1705
+ .filter((anchor) => anchor.kind === "prose")
1706
+ .map((anchor) => anchor.slug),
1707
+ );
1708
+ // Native SQL keeps its complete corpus; only a declared prose target is public.
1709
+ for (const entry of [entries?.own, entries?.doc])
1710
+ if (entry?.anchors)
1711
+ entry.anchors = Object.fromEntries(
1712
+ Object.entries(entry.anchors).filter(
1713
+ ([slug]) => slug === LEAD_ANCHOR || declared.has(slug),
1714
+ ),
1715
+ );
1716
+ record.foundry = foundryBlock(entries?.own, ownDocumentSystem(fm.type));
1717
+ record.documentation = entries?.doc?.key ? readCanonicalKey(entries.doc.key) : null;
1718
+ if (entries?.doc?.key) {
1719
+ const doc = buildDocRecord({
1720
+ frontmatter: fm,
1721
+ address,
1722
+ entry: entries.doc,
1723
+ file: record.file,
1724
+ contentPackage,
1725
+ anchors: record.anchors ?? [],
1726
+ });
1727
+ const existing = records.find((one) => one.documents && one.file.path === record.file.path);
1728
+ if (existing) Object.assign(existing, doc);
1729
+ else records.push(doc);
1730
+ }
1731
+ }
1732
+
1410
1733
  /**
1411
1734
  * Emit this package's content index.
1412
1735
  *
@@ -1416,6 +1739,7 @@ export function indexRecordsFor({
1416
1739
  * @param {string} [options.outDir] - Where to write; defaults to the configured
1417
1740
  * `paths.contentIndex`.
1418
1741
  * @param {object} [options.config] - A resolved configuration; loaded when omitted.
1742
+ * @param {PreparedContentIndex} [options.publication] - Complete preparation for this tree and configuration.
1419
1743
  * @returns {{file: string, notes: number, assets: number, records: number,
1420
1744
  * bytes: number, full: number, draft: number, stub: number,
1421
1745
  * dictionary: {file: string, rows: number}, terms: {file: string, rows: number}}}
@@ -1429,9 +1753,9 @@ export function indexRecordsFor({
1429
1753
  * reader would take it as the authoritative statement that this package has
1430
1754
  * no content.
1431
1755
  */
1432
- export function emitContentIndex({ contentBase, outDir, config } = {}) {
1433
- const resolved = config ?? loadPackConfig();
1434
- const tree = contentBase ?? resolved.paths.content;
1756
+ export function emitContentIndex({ contentBase, outDir, config, publication } = {}) {
1757
+ const resolved = config ?? publication?.config ?? loadPackConfig();
1758
+ const tree = path.resolve(contentBase ?? publication?.contentBase ?? resolved.paths.content);
1435
1759
  const dir = outDir ?? resolved.paths.contentIndex;
1436
1760
  const contentPackage = resolved.contentPackage;
1437
1761
 
@@ -1448,7 +1772,81 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1448
1772
  // publishes pages is no part of an address, and depending on it would make
1449
1773
  // the index refuse to build for a configuration that is perfectly able to
1450
1774
  // state one.
1451
- const records = indexRecordsFor({ contentBase: tree, config: resolved });
1775
+ const parsed = publication?.parsed ?? new Map();
1776
+ if (
1777
+ publication &&
1778
+ (publication.config !== resolved ||
1779
+ publication.contentBase !== tree ||
1780
+ !Array.isArray(publication.records) ||
1781
+ !(publication.parsed instanceof Map) ||
1782
+ !(publication.expandedBodies instanceof Map) ||
1783
+ !(publication.foundryBodies instanceof Map))
1784
+ )
1785
+ throw new Error(
1786
+ "Use await prepareContentIndex({ config, contentBase }) for this publication's source tree and configuration",
1787
+ );
1788
+ const records = cloneAddressState(
1789
+ publication?.records ?? indexRecordsFor({ contentBase: tree, config: resolved, parsed }),
1790
+ );
1791
+ const notes = records.filter(isNoteRecord);
1792
+ const needsPreparation =
1793
+ !publication &&
1794
+ (notes.some(
1795
+ (record) =>
1796
+ dynamicPublicationBody(parsed.get(noteFile(tree, record))?.body) ||
1797
+ record.data?.events?.length,
1798
+ ) ||
1799
+ derivedSections(notes, {
1800
+ fileOf: (record) => noteFile(tree, record),
1801
+ config: resolved,
1802
+ foreignIndex: loadForeignIndexes(resolved, [contentPackage]).index,
1803
+ }).size > 0);
1804
+ if (needsPreparation)
1805
+ throw new Error(
1806
+ "Dynamic content-index publication requires await prepareContentIndex({ config, contentBase }); pass its result as emitContentIndex({ config, contentBase, publication })",
1807
+ );
1808
+ if (
1809
+ publication &&
1810
+ notes.some((record) => {
1811
+ const file = noteFile(tree, record);
1812
+ const body = publication.expandedBodies.get(file);
1813
+ const native = publication.foundryBodies.get(file);
1814
+ return (
1815
+ !parsed.has(file) ||
1816
+ typeof body?.markdown !== "string" ||
1817
+ !Array.isArray(body.lineMap) ||
1818
+ typeof native?.markdown !== "string" ||
1819
+ !Array.isArray(native.lineMap)
1820
+ );
1821
+ })
1822
+ )
1823
+ throw new Error(
1824
+ "Incomplete content-index bodies: run await prepareContentIndex({ config, contentBase }) before publication",
1825
+ );
1826
+ const art = assetAddressIndex(records, { config: resolved });
1827
+ const publicLexical =
1828
+ publication ? undefined : (
1829
+ lexicalSectionsFor(
1830
+ records.filter((record) => !isGmNote(record)),
1831
+ records,
1832
+ )
1833
+ );
1834
+ const allLexical = publication ? undefined : lexicalSectionsFor(records);
1835
+ for (const record of notes) {
1836
+ const file = noteFile(tree, record);
1837
+ const lexical = (isGmNote(record) ? allLexical : publicLexical)?.get(
1838
+ canonicalKey(contentPackage, "note", record.type, record.shortcode),
1839
+ );
1840
+ const body =
1841
+ publication?.expandedBodies.get(file)?.markdown ??
1842
+ insertLexicalSections(parsed.get(file).body, lexical).markdown;
1843
+ projectPublicationBody(records, record, body, {
1844
+ contentPackage,
1845
+ manifest: foundryIdentities(resolved),
1846
+ resolveRole: (address) => embedRole(art, address),
1847
+ foundryBody: publication?.foundryBodies.get(file).markdown,
1848
+ });
1849
+ }
1452
1850
  const assetBindings = checkForeignAssetBindings(resolved);
1453
1851
  if (assetBindings.length)
1454
1852
  throw Object.assign(new Error(assetBindings.map(formatDiagnostic).join("\n")), {
@@ -1463,16 +1861,19 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
1463
1861
 
1464
1862
  const text = serializeContentIndex(records);
1465
1863
  const file = path.join(dir, metadataFileName(contentPackage));
1466
- fs.mkdirSync(dir, { recursive: true });
1467
- fs.writeFileSync(file, text);
1468
1864
 
1469
1865
  // The `dictionary` and `terms` relations as datafiles beside the index, for
1470
1866
  // `jq`, an editor or another package. Nothing in the build reads them back.
1471
1867
  const derived = dictionaryAndTermRows(records.map((record) => encodeAddresses(record)));
1868
+ const serialized = Object.fromEntries(
1869
+ ["dictionary", "terms"].map((relation) => [relation, serializeRows(derived[relation])]),
1870
+ );
1871
+ fs.mkdirSync(dir, { recursive: true });
1872
+ fs.writeFileSync(file, text);
1472
1873
  const datafiles = {};
1473
1874
  for (const relation of ["dictionary", "terms"]) {
1474
1875
  const rowsFile = path.join(dir, `${contentPackage}-${relation}.jsonl`);
1475
- fs.writeFileSync(rowsFile, serializeRows(derived[relation]));
1876
+ fs.writeFileSync(rowsFile, serialized[relation]);
1476
1877
  datafiles[relation] = { file: rowsFile, rows: derived[relation].length };
1477
1878
  }
1478
1879
 
@@ -90,7 +90,7 @@ import {
90
90
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
91
91
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
92
92
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
93
- import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
93
+ import { isEventAnchor, parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
94
94
  import { readQualifier } from "./wikilinks.mjs";
95
95
 
96
96
  /**
@@ -243,7 +243,10 @@ export function buildLinkIndex(
243
243
  // same headings — the disagreement the anchor half removes.
244
244
  anchors.set(
245
245
  note,
246
- new Set((record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug)),
246
+ new Set([
247
+ ...(record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug),
248
+ ...(sqlTables?.get(absPath)?.anchors ?? []).map((a) => a.slug),
249
+ ]),
247
250
  );
248
251
  eventAnchorsOf.set(
249
252
  note,
@@ -331,7 +334,13 @@ export function buildLinkIndex(
331
334
  */
332
335
  function expandedBody(note) {
333
336
  const body = note.body;
334
- if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)) return body;
337
+ const prepared = sqlTables?.get(note.file);
338
+ if (
339
+ !prepared?.generated &&
340
+ !prepared?.lexical?.length &&
341
+ !/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)
342
+ )
343
+ return body;
335
344
  return expandContentTables(body, {
336
345
  source: note.file,
337
346
  // A generated link is checked like an authored one, so both
@@ -940,18 +949,37 @@ export function auditLinks(index) {
940
949
  for (const note of notes) {
941
950
  for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
942
951
  if (!anchor || !labelled) continue;
943
- const dest = target ? resolve(target) : note;
944
- // An unresolvable target is reported by the pass below; its anchor
945
- // has nothing to be checked against. Neither has a file: an asset
946
- // resolves but has no body, so it declares the empty set of anchors
947
- // and every `#section` on one is dead.
948
- if (!dest) continue;
949
- if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
952
+ const local = target ? resolve(target) : note;
953
+ const foreign = !local && target ? manifestHit(target) : null;
954
+ // An unresolved address is reported below. A foreign sheet opens
955
+ // its system document, so only the readable note view names prose
956
+ // sections. A public note's GM target is an audience refusal below.
957
+ if (!local) {
958
+ if (!foreign || (foreign.gm && !isGmNote(note.fm))) continue;
959
+ const qualified = readQualifier(
960
+ target,
961
+ index.types,
962
+ index.packages,
963
+ index.noIndexPackages,
964
+ );
965
+ if (qualified?.system !== NOTE_SYSTEM) continue;
966
+ }
967
+ const dest = local ?? foreign;
968
+ const slug = local ? slugify(anchor) : anchor;
969
+ const event =
970
+ local ?
971
+ (index.eventAnchorsOf?.get(local) ?? new Set()).has(anchor)
972
+ : isEventAnchor(foreign, anchor);
973
+ // Fetched note anchors are the publisher's declaration, independent
974
+ // of a site URL or a Foundry UUID. An asset has no body and therefore
975
+ // declares no sections; an absent foreign anchor list does too.
976
+ const declared =
977
+ local ?
978
+ (anchors.get(local) ?? new Set()).has(slug)
979
+ : foreign.noteAnchors?.some((one) => one.slug === anchor && one.kind === "prose");
980
+ if (!declared || (!local && event)) {
950
981
  deadAnchors.push({
951
- reason:
952
- (index.eventAnchorsOf?.get(dest) ?? new Set()).has(anchor) ?
953
- "event-anchor"
954
- : "unknown-anchor",
982
+ reason: event ? "event-anchor" : "unknown-anchor",
955
983
  note,
956
984
  link: `${rawTarget}#${anchor}`,
957
985
  // The two the shared `unknown-anchor` wording reads, in the
@@ -960,7 +988,7 @@ export function auditLinks(index) {
960
988
  // declare. Carrying them is what lets one sentence serve
961
989
  // the checker and the build.
962
990
  target: rawTarget,
963
- anchor: slugify(anchor),
991
+ anchor: slug,
964
992
  dest,
965
993
  text,
966
994
  occurrence,