@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.
- package/CHANGELOG.md +27 -0
- package/bin/content-commands.mjs +12 -4
- package/docs/api.md +328 -28
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +140 -56
- package/docs/commands.md +19 -10
- package/docs/content-format.md +2 -2
- package/docs/diagnostics.md +1 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/reference/format-details.md +269 -123
- package/docs/reference/note-types.md +184 -170
- package/engine/anchors.mjs +7 -20
- package/engine/compile-corpus.mjs +3 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +412 -11
- package/engine/content-links.mjs +43 -15
- package/engine/content-tables.mjs +8 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/foundry-entries.mjs +21 -3
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +12 -6
- package/engine/journals.mjs +4 -1
- package/engine/language-data.mjs +53 -0
- package/engine/language-terms.mjs +86 -0
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/pdf-build.mjs +9 -9
- package/engine/site-build.mjs +16 -2
- package/engine/sql-tables.mjs +262 -43
- package/engine/stub-lint.mjs +6 -2
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +65 -1
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/culture-reach.d.mts +22 -2
- package/types/engine/data-keys.d.mts +7 -0
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/foundry-entries.d.mts +3 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +4 -1
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +95 -0
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/site-build.d.mts +4 -1
- package/types/engine/sql-tables.d.mts +33 -3
- package/types/engine/word-rows.d.mts +9 -7
package/engine/anchors.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
75
|
-
|
|
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:
|
|
90
|
-
name:
|
|
91
|
-
level: heading
|
|
92
|
-
line:
|
|
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
|
|
92
|
+
linkIndex: buildContentLinkIndex(contentBase, router, { ...corpusOptions, sqlTables }),
|
|
93
|
+
sqlTables,
|
|
93
94
|
};
|
|
94
95
|
}
|
package/engine/content-index.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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,
|
|
1876
|
+
fs.writeFileSync(rowsFile, serialized[relation]);
|
|
1476
1877
|
datafiles[relation] = { file: rowsFile, rows: derived[relation].length };
|
|
1477
1878
|
}
|
|
1478
1879
|
|
package/engine/content-links.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
944
|
-
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
if (!
|
|
949
|
-
|
|
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:
|
|
991
|
+
anchor: slug,
|
|
964
992
|
dest,
|
|
965
993
|
text,
|
|
966
994
|
occurrence,
|