@heroiclands/package-build 22.4.7 → 22.5.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 +23 -0
- package/CONTENT.md +83 -1
- package/bin/content-build.mjs +16 -2
- package/docs/api.md +49 -12
- package/docs/content-format.md +167 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/holdings.mjs +42 -17
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +7 -0
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +40 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +8 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +17 -6
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +15 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
package/engine/content-index.mjs
CHANGED
|
@@ -114,6 +114,9 @@ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./note-claims.mjs";
|
|
|
114
114
|
export { collectAnchors };
|
|
115
115
|
import { entriesForNote, foundryIdentities } from "./foundry-entries.mjs";
|
|
116
116
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
117
|
+
// The one statement of what an empty body means, shared with the lint and the
|
|
118
|
+
// SQL view that derives the ladder from the absence this writes.
|
|
119
|
+
import { isStubNote } from "./note-state.mjs";
|
|
117
120
|
import { resolveNoteId } from "./note-ids.mjs";
|
|
118
121
|
// The retired-field refusal and the key locator, so a note authoring a derived
|
|
119
122
|
// key is reported where it is rather than as a bare abort.
|
|
@@ -128,16 +131,19 @@ import {
|
|
|
128
131
|
DERIVED_KEYS,
|
|
129
132
|
isAssetRecord,
|
|
130
133
|
isNoteRecord,
|
|
134
|
+
isStub,
|
|
131
135
|
noteFile,
|
|
132
136
|
recordPath,
|
|
133
137
|
sortKeysDeep,
|
|
134
138
|
} from "./index-records.mjs";
|
|
139
|
+
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
135
140
|
|
|
136
141
|
export {
|
|
137
142
|
authoredFrontmatter,
|
|
138
143
|
DERIVED_KEYS,
|
|
139
144
|
isAssetRecord,
|
|
140
145
|
isNoteRecord,
|
|
146
|
+
isStub,
|
|
141
147
|
noteFile,
|
|
142
148
|
recordPath,
|
|
143
149
|
sortKeysDeep,
|
|
@@ -407,11 +413,18 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
|
407
413
|
* @param {string} [options.absPath] - The file, read only on the failing path to
|
|
408
414
|
* locate the offending key.
|
|
409
415
|
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
410
|
-
* @param {string} [options.body] - The note's markdown body, for its anchors
|
|
416
|
+
* @param {string} [options.body] - The note's markdown body, for its anchors
|
|
417
|
+
* and for the one question that decides whether the note publishes a page. A
|
|
418
|
+
* caller that states no body is stating an empty one, and the note is a stub.
|
|
411
419
|
* @param {number} [options.bodyLine] - The 1-based file line the body starts on.
|
|
412
420
|
* @param {object} [options.manifest] - The package manifest, which the Foundry
|
|
413
421
|
* entries are derived against.
|
|
414
|
-
* @returns {Record<string, any>} The record, keys sorted at every depth.
|
|
422
|
+
* @returns {Record<string, any>} The record, keys sorted at every depth. A
|
|
423
|
+
* **stub** — a note with an empty body, on a type an empty body suppresses —
|
|
424
|
+
* carries `address` and `anchors` as `null`: it publishes no page, so it
|
|
425
|
+
* holds no address and offers no anchor. It keeps everything else, its `id`
|
|
426
|
+
* and its `foundry` block included, because the document it compiles is
|
|
427
|
+
* derived from `data:` rather than from prose.
|
|
415
428
|
* @throws {Error} When the note carries a key this module derives, which would
|
|
416
429
|
* otherwise be overwritten without a word. `file` and, where the file was
|
|
417
430
|
* read, `position` ride on the error.
|
|
@@ -430,23 +443,36 @@ export function buildIndexRecord({
|
|
|
430
443
|
const posix = relPath.split(path.sep).join("/");
|
|
431
444
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
432
445
|
const address = noteAddress(frontmatter, contentPackage);
|
|
446
|
+
// Derived from the address the note *would* hold, because a stub keeps its
|
|
447
|
+
// Foundry document: a document is derived from `data:`, not from prose, and
|
|
448
|
+
// the empty body suppresses the page rather than the document.
|
|
433
449
|
const entries = foundryEntries({ frontmatter, address, body, manifest });
|
|
450
|
+
// A stub — an empty body on a type an empty body suppresses — publishes no
|
|
451
|
+
// page, so it holds no address and offers no anchor to link to. **The
|
|
452
|
+
// absent address is the signal**, and it is the column the table renderer
|
|
453
|
+
// already reads through `_ref`. Everything else the note declares is kept:
|
|
454
|
+
// its file is what the author edits and what every diagnostic names, and
|
|
455
|
+
// its `id` and `foundry` block are the document it still compiles.
|
|
456
|
+
const stub = isStubNote(frontmatter, body);
|
|
434
457
|
|
|
435
458
|
return /** @type {Record<string, any>} */ (
|
|
436
459
|
sortKeysDeep({
|
|
437
460
|
...frontmatter,
|
|
438
461
|
package: contentPackage,
|
|
439
|
-
address,
|
|
462
|
+
address: stub ? null : address,
|
|
440
463
|
nameAscii: asciiName(frontmatter?.name?.full),
|
|
441
464
|
aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
|
|
442
465
|
// Each anchor carries the link that reaches it, so a section is
|
|
443
466
|
// addressable from the index without anyone re-deriving how an
|
|
444
467
|
// anchor is spelled — and its file line, so an editor can jump
|
|
445
468
|
// there rather than search for the heading.
|
|
446
|
-
anchors:
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
469
|
+
anchors:
|
|
470
|
+
stub ? null : (
|
|
471
|
+
collectAnchors(body, bodyLine).map((a) => ({
|
|
472
|
+
...a,
|
|
473
|
+
link: address ? `${address.slug}#${a.slug}` : null,
|
|
474
|
+
}))
|
|
475
|
+
),
|
|
450
476
|
foundry: foundryBlock(
|
|
451
477
|
entries?.own,
|
|
452
478
|
systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
@@ -609,22 +635,21 @@ export function collectContentIndex(
|
|
|
609
635
|
}
|
|
610
636
|
records.push(record);
|
|
611
637
|
|
|
612
|
-
// An item note is two documents, so it is two records.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (doc?.key && record.address) {
|
|
638
|
+
// An item note is two documents, so it is two records. Derived from
|
|
639
|
+
// the address the note *would* hold rather than from the one its
|
|
640
|
+
// record carries: a stub withholds its own address and still compiles
|
|
641
|
+
// both documents, and the journal is one of them.
|
|
642
|
+
const address = noteAddress(fm, contentPackage);
|
|
643
|
+
const doc = foundryEntries({ frontmatter: fm, address, body, manifest })?.doc;
|
|
644
|
+
if (doc?.key && address) {
|
|
620
645
|
records.push(
|
|
621
646
|
buildDocRecord({
|
|
622
647
|
frontmatter: fm,
|
|
623
|
-
address
|
|
648
|
+
address,
|
|
624
649
|
entry: doc,
|
|
625
650
|
file: record.file,
|
|
626
651
|
contentPackage,
|
|
627
|
-
anchors: record.anchors,
|
|
652
|
+
anchors: record.anchors ?? [],
|
|
628
653
|
}),
|
|
629
654
|
);
|
|
630
655
|
}
|
|
@@ -751,8 +776,11 @@ export function indexRecordsFor({
|
|
|
751
776
|
* `paths.contentIndex`.
|
|
752
777
|
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
753
778
|
* @returns {{file: string, notes: number, assets: number, records: number,
|
|
754
|
-
* bytes: number
|
|
755
|
-
*
|
|
779
|
+
* bytes: number, full: number, draft: number, stub: number}} Where it was
|
|
780
|
+
* written, how many notes and how many assets it holds, how many records that
|
|
781
|
+
* is in all, its size, and the ladder those notes sit on — so the ratio of
|
|
782
|
+
* written to unwritten is visible on every build rather than discovered in a
|
|
783
|
+
* year.
|
|
756
784
|
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
757
785
|
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
758
786
|
* reader would take it as the authoritative statement that this package has
|
|
@@ -794,7 +822,21 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
|
794
822
|
// note yields a second record for its documentation journal and a file
|
|
795
823
|
// yields an asset record, so reporting records as notes would overstate how
|
|
796
824
|
// large the tree is.
|
|
797
|
-
const
|
|
825
|
+
const noteRecords = records.filter(isNoteRecord);
|
|
798
826
|
const assets = records.filter(isAssetRecord).length;
|
|
799
|
-
|
|
827
|
+
// Read off the records rather than from a second pass over the tree: a stub
|
|
828
|
+
// is the note whose address the emitter just withheld, and a draft is the
|
|
829
|
+
// tag the note carries, so both are already in hand.
|
|
830
|
+
const stub = noteRecords.filter(isStub).length;
|
|
831
|
+
const draft = noteRecords.filter((record) => !isStub(record) && isDraftNote(record)).length;
|
|
832
|
+
return {
|
|
833
|
+
file,
|
|
834
|
+
notes: noteRecords.length,
|
|
835
|
+
assets,
|
|
836
|
+
records: records.length,
|
|
837
|
+
bytes: Buffer.byteLength(text),
|
|
838
|
+
full: noteRecords.length - stub - draft,
|
|
839
|
+
draft,
|
|
840
|
+
stub,
|
|
841
|
+
};
|
|
800
842
|
}
|
package/engine/content-links.mjs
CHANGED
|
@@ -182,6 +182,21 @@ export function buildLinkIndex(
|
|
|
182
182
|
records ?? indexRecordsFor({ contentBase, config: resolved, skipDirectories, problems });
|
|
183
183
|
|
|
184
184
|
const byKey = new Map();
|
|
185
|
+
/**
|
|
186
|
+
* Canonical address to the **stub** that would hold it.
|
|
187
|
+
*
|
|
188
|
+
* A stub has no page, so nothing may link to one — but it is a note the
|
|
189
|
+
* tree holds, and keeping it here is what lets the checker say so. The
|
|
190
|
+
* diagnostic names the note and reports that it has no body, where a link
|
|
191
|
+
* resolving against nothing at all can only say "resolves to no note".
|
|
192
|
+
*
|
|
193
|
+
* Kept beside {@link byKey} rather than in it, because the two answer
|
|
194
|
+
* different questions: `byKey` is what a **page** link may reach, and a
|
|
195
|
+
* stub is reachable only as **data** — a border's far side, a reference
|
|
196
|
+
* naming an item to stand beside. That is the whole boundary, and it is
|
|
197
|
+
* drawn here rather than per field.
|
|
198
|
+
*/
|
|
199
|
+
const byStub = new Map();
|
|
185
200
|
/** Canonical address to asset record, for the files this package ships. */
|
|
186
201
|
const byAssetKey = new Map();
|
|
187
202
|
const anchors = new Map();
|
|
@@ -234,12 +249,15 @@ export function buildLinkIndex(
|
|
|
234
249
|
const canonical =
|
|
235
250
|
record.address?.canonical ??
|
|
236
251
|
canonicalKey(pkg, systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS), type, fm.shortcode);
|
|
237
|
-
|
|
252
|
+
// A note with no address publishes no page, so its identity is
|
|
253
|
+
// filed where a link cannot reach it and a reference still can.
|
|
254
|
+
const into = record.address ? byKey : byStub;
|
|
255
|
+
into.set(canonical, note);
|
|
238
256
|
if (hasDocEntry(type)) {
|
|
239
257
|
// A documentation journal is `none`: no game system defines a
|
|
240
258
|
// JournalEntry, and one note has one of them however many
|
|
241
259
|
// system blocks it carries.
|
|
242
|
-
|
|
260
|
+
into.set(canonicalKey(pkg, NO_SYSTEM, `doc${type}`, fm.shortcode), note);
|
|
243
261
|
}
|
|
244
262
|
}
|
|
245
263
|
}
|
|
@@ -486,6 +504,26 @@ export function buildLinkIndex(
|
|
|
486
504
|
return byKey.get(canonical) ?? byAssetKey.get(canonical);
|
|
487
505
|
}
|
|
488
506
|
|
|
507
|
+
/**
|
|
508
|
+
* The **stub** an address names, or `undefined`.
|
|
509
|
+
*
|
|
510
|
+
* Asked only after {@link resolveAddress} has answered nothing, and read
|
|
511
|
+
* exactly as it reads: same qualifier, same defaults, same expansion. What
|
|
512
|
+
* it buys is the diagnostic — a link into a stub is refused because there
|
|
513
|
+
* is no page to reach, and the message can name the file the author has to
|
|
514
|
+
* open rather than saying the address resolves nowhere.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} target - The link target, anchor already removed.
|
|
517
|
+
* @param {string} [keyPath] - The dotted frontmatter key path the link sits
|
|
518
|
+
* under; body prose has none.
|
|
519
|
+
* @returns {object|undefined} The stub it addresses.
|
|
520
|
+
*/
|
|
521
|
+
function stubAt(target, keyPath) {
|
|
522
|
+
const qualified = readQualifier(target, types, packages, noIndexPackages);
|
|
523
|
+
if (!qualified || qualified.reason) return undefined;
|
|
524
|
+
return byStub.get(expandAddress(qualified, { package: pkg, system: blockSystem(keyPath) }));
|
|
525
|
+
}
|
|
526
|
+
|
|
489
527
|
/**
|
|
490
528
|
* Every foreign manifest entry an address names, in package order.
|
|
491
529
|
*
|
|
@@ -558,7 +596,12 @@ export function buildLinkIndex(
|
|
|
558
596
|
function referenceHit(target) {
|
|
559
597
|
const q = readQualifier(target, types, packages, noIndexPackages);
|
|
560
598
|
if (!q || q.reason) return null;
|
|
561
|
-
|
|
599
|
+
// Stubs last, and deliberately included: a reference names an item to
|
|
600
|
+
// stand beside rather than a document to point at, so a border with a
|
|
601
|
+
// stub on the far side is a valid statement about the world. Resolving
|
|
602
|
+
// these against the addressed rows alone would report every one of
|
|
603
|
+
// them dead.
|
|
604
|
+
const local = matchAddress([...byKey, ...byAssetKey, ...byStub], q);
|
|
562
605
|
if (local.length) return local[0][1];
|
|
563
606
|
const abroad = matchAddress([...foreign.index], q);
|
|
564
607
|
return abroad.length ? abroad[0][1] : null;
|
|
@@ -599,6 +642,8 @@ export function buildLinkIndex(
|
|
|
599
642
|
*/
|
|
600
643
|
resolve: resolveAddress,
|
|
601
644
|
resolveAddress,
|
|
645
|
+
/** The stub an address names, for the refusal that says which file. */
|
|
646
|
+
stubAt,
|
|
602
647
|
manifestHit,
|
|
603
648
|
foreignHits,
|
|
604
649
|
referenceHit,
|
|
@@ -893,9 +938,9 @@ export function auditHomepageLinks(index) {
|
|
|
893
938
|
* which addresses a foreign manifest answered. Each `deadAddresses` entry
|
|
894
939
|
* carries a `reason` from {@link LINK_FINDING_REASONS} —
|
|
895
940
|
* `"not-an-address"`, `"unknown-type"`, `"ambiguous"` (with the claiming
|
|
896
|
-
* `packages`), `"no-content-index"`,
|
|
897
|
-
* them is an **error**: the three
|
|
898
|
-
* class.
|
|
941
|
+
* `packages`), `"no-content-index"`, `"stub"` (with the stub's path) or
|
|
942
|
+
* `"unresolved"` — and every one of them is an **error**: the three
|
|
943
|
+
* resolvers agree on severity for every class.
|
|
899
944
|
*/
|
|
900
945
|
export function auditLinks(index) {
|
|
901
946
|
const { notes, anchors, linksOf, embedsOf, resolve, manifestHit, isAddress } = index;
|
|
@@ -957,6 +1002,15 @@ export function auditLinks(index) {
|
|
|
957
1002
|
continue;
|
|
958
1003
|
}
|
|
959
1004
|
if (index.resolveAddress(target)) continue;
|
|
1005
|
+
// A stub is in the index, so the refusal can name it. Asked before
|
|
1006
|
+
// the foreign manifests, because a local note is what the author
|
|
1007
|
+
// meant and reporting it as an unresolved foreign address would
|
|
1008
|
+
// send them looking in the wrong package.
|
|
1009
|
+
const stub = index.stubAt?.(target);
|
|
1010
|
+
if (stub) {
|
|
1011
|
+
deadAddresses.push({ ...at, reason: "stub", stub: stub.rel });
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
960
1014
|
// A manifest answers with the target package's own build output
|
|
961
1015
|
// rather than a reviewed guess.
|
|
962
1016
|
const hits = index.foreignHits(target);
|
|
@@ -1107,7 +1107,8 @@ function whereText(query) {
|
|
|
1107
1107
|
* each came from, so a diagnostic about the expanded body can name an
|
|
1108
1108
|
* authored position. An `errors` entry carries the 0-based line of the
|
|
1109
1109
|
* directive that failed, for the same reason. `warnings` holds one entry per
|
|
1110
|
-
* `dataview` directive the body still authors
|
|
1110
|
+
* `dataview` directive the body still authors, and one per `sql` directive
|
|
1111
|
+
* that leaves out stubs it would otherwise list.
|
|
1111
1112
|
*/
|
|
1112
1113
|
export function expandContentTables(
|
|
1113
1114
|
markdown,
|
|
@@ -1176,7 +1177,17 @@ export function expandContentTables(
|
|
|
1176
1177
|
"`sqlTables`, so the query could not be run"
|
|
1177
1178
|
: prepared.reason ? prepared.reason
|
|
1178
1179
|
: prepared.rows === 0 && !prepared.allowEmpty ?
|
|
1179
|
-
|
|
1180
|
+
// A table that lost every row to the stub rule says so.
|
|
1181
|
+
// "selects no notes" is true and useless here: the notes
|
|
1182
|
+
// are there, and the fence is reading the relation that
|
|
1183
|
+
// leaves them out.
|
|
1184
|
+
prepared.stubsExcluded > 0 ?
|
|
1185
|
+
`sql query selects no notes — ${prepared.stubsExcluded} stub(s) ` +
|
|
1186
|
+
"match it, and `FROM notes` leaves stubs out; select " +
|
|
1187
|
+
"`FROM entries` to list them beside the written notes, or " +
|
|
1188
|
+
"write ```sql allow-empty if the empty table is intended"
|
|
1189
|
+
: "sql query selects no notes — write ```sql allow-empty if " +
|
|
1190
|
+
"that is intended"
|
|
1180
1191
|
: undefined;
|
|
1181
1192
|
if (failure) {
|
|
1182
1193
|
errors.push({
|
|
@@ -1190,6 +1201,22 @@ export function expandContentTables(
|
|
|
1190
1201
|
i = close;
|
|
1191
1202
|
continue;
|
|
1192
1203
|
}
|
|
1204
|
+
// A table selecting `FROM notes` that would gain rows from
|
|
1205
|
+
// `FROM entries` has not adopted stubs. It is not wrong — which
|
|
1206
|
+
// tables list the unwritten is the author's decision, and it is a
|
|
1207
|
+
// one-word edit — so this is a warning at the fence rather than a
|
|
1208
|
+
// failure, and the count is what makes it worth reading.
|
|
1209
|
+
if (prepared.stubsExcluded > 0) {
|
|
1210
|
+
warnings.push({
|
|
1211
|
+
source,
|
|
1212
|
+
line: i,
|
|
1213
|
+
column: indent.length + 1,
|
|
1214
|
+
reason:
|
|
1215
|
+
`this table selects \`FROM notes\`, which leaves out stubs; ` +
|
|
1216
|
+
`${prepared.stubsExcluded} stub(s) match it — select ` +
|
|
1217
|
+
`\`FROM entries\` to list them beside the written notes`,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1193
1220
|
if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
|
|
1194
1221
|
for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
|
|
1195
1222
|
if (close + 1 < lines.length && lines[close + 1].trim() !== "") emit("", i, true);
|
package/engine/holdings.mjs
CHANGED
|
@@ -67,8 +67,10 @@ export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
|
|
|
67
67
|
*
|
|
68
68
|
* @typedef {object} HoldingsEntry
|
|
69
69
|
* @property {string} title The page's published title.
|
|
70
|
-
* @property {string} url
|
|
71
|
-
* href this build renders composes it.
|
|
70
|
+
* @property {string} [url] `<base><slug>/` — the page's address as every
|
|
71
|
+
* href this build renders composes it. **Absent
|
|
72
|
+
* for a stub**, which has no page: an entry with no
|
|
73
|
+
* `url` renders as plain text.
|
|
72
74
|
* @property {string} type The note's `type` — `place` or `affiliation`.
|
|
73
75
|
* @property {string} [subType] The note's `subType`, where it declares one.
|
|
74
76
|
*/
|
|
@@ -126,7 +128,7 @@ function namedShortcode(value) {
|
|
|
126
128
|
* @param {unknown} value - The authored value — a list, or a lone entry.
|
|
127
129
|
* @returns {string[]} The shortcodes.
|
|
128
130
|
*/
|
|
129
|
-
function shortcodesOf(value) {
|
|
131
|
+
export function shortcodesOf(value) {
|
|
130
132
|
const list =
|
|
131
133
|
Array.isArray(value) ? value
|
|
132
134
|
: value ? [value]
|
|
@@ -163,18 +165,42 @@ function byTitle(a, b) {
|
|
|
163
165
|
/**
|
|
164
166
|
* The entry a node is listed as.
|
|
165
167
|
*
|
|
166
|
-
*
|
|
168
|
+
* **`url` is absent for a node that publishes no page**, and the entry is
|
|
169
|
+
* emitted all the same. A stub is a place somebody has not written yet: it
|
|
170
|
+
* carries its facts, and Weyshott belongs in Aelwyth's `contains` whether or
|
|
171
|
+
* not anyone has written Weyshott's page. What it cannot have is a link, so
|
|
172
|
+
* the renderer prints the title as plain text — the same rule a table cell
|
|
173
|
+
* follows when its `_ref` is null, in a different renderer.
|
|
174
|
+
*
|
|
175
|
+
* @param {HoldingsNode} node - The node.
|
|
167
176
|
* @returns {HoldingsEntry} Its entry.
|
|
168
177
|
*/
|
|
169
178
|
function entryOf(node) {
|
|
170
179
|
return {
|
|
171
180
|
title: node.title,
|
|
172
|
-
url: node.url,
|
|
181
|
+
...(listed(node) ? { url: node.url } : {}),
|
|
173
182
|
type: node.type,
|
|
174
183
|
...(node.subType === undefined ? {} : { subType: node.subType }),
|
|
175
184
|
};
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Whether a node publishes a page.
|
|
189
|
+
*
|
|
190
|
+
* **A URL gates where a list is written, never whether a node may appear in
|
|
191
|
+
* someone else's list.** You cannot write a `contains` block on a page that
|
|
192
|
+
* does not exist, so this guards {@link holdingsPages}'s `on(url)` calls — and
|
|
193
|
+
* nothing else. Guarding the membership too is how a stub vanishes from every
|
|
194
|
+
* containment and tenure list in the corpus, silently, which is the one failure
|
|
195
|
+
* mode a diff cannot show.
|
|
196
|
+
*
|
|
197
|
+
* @param {HoldingsNode} node - The node.
|
|
198
|
+
* @returns {boolean} Whether it has a page to write a list on.
|
|
199
|
+
*/
|
|
200
|
+
function listed(node) {
|
|
201
|
+
return node.url !== undefined && node.url !== null && node.url !== "";
|
|
202
|
+
}
|
|
203
|
+
|
|
178
204
|
/**
|
|
179
205
|
* Read a local note into a node, or `null` for a note that takes no part.
|
|
180
206
|
*
|
|
@@ -238,10 +264,11 @@ export function foreignHoldingsNodes(foreignIndex) {
|
|
|
238
264
|
*
|
|
239
265
|
* A shortcode is declared once per type: the first node declaring it wins,
|
|
240
266
|
* so local nodes handed in ahead of fetched ones shadow a dependency's, the
|
|
241
|
-
* way the link resolver answers. A node with no URL
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
267
|
+
* way the link resolver answers. A node with no URL carries no lists of its
|
|
268
|
+
* own, since there is no page to write them on — and is still an entry on
|
|
269
|
+
* everybody else's, rendered as plain text. A name no node declares is
|
|
270
|
+
* dropped: a `parents` naming nowhere is the map's finding, a `domains`
|
|
271
|
+
* naming nowhere the reference check's.
|
|
245
272
|
*
|
|
246
273
|
* The result holds only pages with at least one entry on at least one list,
|
|
247
274
|
* and a page's block carries only the keys it has entries for — the theme's
|
|
@@ -283,24 +310,22 @@ export function holdingsPages(nodes) {
|
|
|
283
310
|
}
|
|
284
311
|
return found;
|
|
285
312
|
};
|
|
286
|
-
|
|
287
|
-
|
|
313
|
+
// Every gate below is on the page a list is *written* on, never on the
|
|
314
|
+
// node the list names — see {@link listed}.
|
|
288
315
|
for (const child of places.values()) {
|
|
289
|
-
if (!listed(child)) continue;
|
|
290
316
|
for (const shortcode of child.parents) {
|
|
291
317
|
const parent = places.get(shortcode);
|
|
292
318
|
if (!parent || !listed(parent) || parent === child) continue;
|
|
293
|
-
on(parent.url).contains.push(entryOf(
|
|
319
|
+
on(parent.url).contains.push(entryOf(child));
|
|
294
320
|
}
|
|
295
321
|
}
|
|
296
322
|
|
|
297
323
|
for (const holder of affiliations.values()) {
|
|
298
|
-
if (!listed(holder)) continue;
|
|
299
324
|
for (const shortcode of holder.domains) {
|
|
300
325
|
const held = places.get(shortcode);
|
|
301
|
-
if (!held
|
|
302
|
-
on(holder.url).holdings.push(entryOf(
|
|
303
|
-
on(held.url).held_by.push(entryOf(
|
|
326
|
+
if (!held) continue;
|
|
327
|
+
if (listed(holder)) on(holder.url).holdings.push(entryOf(held));
|
|
328
|
+
if (listed(held)) on(held.url).held_by.push(entryOf(holder));
|
|
304
329
|
}
|
|
305
330
|
}
|
|
306
331
|
|
package/engine/index-records.mjs
CHANGED
|
@@ -168,6 +168,31 @@ export function isNoteRecord(record) {
|
|
|
168
168
|
return !record?.documents && !isAssetRecord(record);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Whether a record is a **stub**: a note with an empty body.
|
|
173
|
+
*
|
|
174
|
+
* Asked of the **absent address**, not of a marker block, because the absent
|
|
175
|
+
* address is the whole signal. A note's body is not in the index — the index
|
|
176
|
+
* carries what is *about* a note — so there is nothing else to ask, and nothing
|
|
177
|
+
* else needs to be: an address is emitted if and only if the body is non-empty
|
|
178
|
+
* on a type an empty body suppresses (see
|
|
179
|
+
* {@link module:engine/note-state.isStubNote}).
|
|
180
|
+
*
|
|
181
|
+
* A stub keeps its file, its `id` and, where its type compiles one, its Foundry
|
|
182
|
+
* document. What the empty body suppresses is the **page**, and the address and
|
|
183
|
+
* the link target that go with it.
|
|
184
|
+
*
|
|
185
|
+
* Asked only of a note's own record. A documentation journal and an asset are
|
|
186
|
+
* documents rather than notes, and both are addressed whatever the note they
|
|
187
|
+
* belong to says.
|
|
188
|
+
*
|
|
189
|
+
* @param {Record<string, any>} record - An index record.
|
|
190
|
+
* @returns {boolean} True for a stub's record.
|
|
191
|
+
*/
|
|
192
|
+
export function isStub(record) {
|
|
193
|
+
return isNoteRecord(record) && !record?.address;
|
|
194
|
+
}
|
|
195
|
+
|
|
171
196
|
/**
|
|
172
197
|
* The path a record names inside its package, whichever shape it is.
|
|
173
198
|
*
|
package/engine/index.mjs
CHANGED
|
@@ -84,11 +84,18 @@ export * as noteSchemas from "./note-schemas.mjs";
|
|
|
84
84
|
/** The closed frontmatter regions: the `data:` container and each type's `subType`. */
|
|
85
85
|
export * as noteVocabulary from "./note-vocabulary.mjs";
|
|
86
86
|
|
|
87
|
+
/** The three states of a note — stub, draft, full — and the empty body they rest on. */
|
|
88
|
+
export * as noteState from "./note-state.mjs";
|
|
89
|
+
|
|
90
|
+
/** Whether every empty body is a stub on purpose, and how finished a tree is. */
|
|
91
|
+
export * as stubLint from "./stub-lint.mjs";
|
|
92
|
+
|
|
87
93
|
/** What a place is next to and reachable from: the closed sets, and the checks. */
|
|
88
94
|
export * as placeRelations from "./place-relations.mjs";
|
|
89
95
|
|
|
90
96
|
/** What lies within a place, who holds it, and what an affiliation holds — the page lists, and the tenure check. */
|
|
91
97
|
export * as holdings from "./holdings.mjs";
|
|
98
|
+
export * as populations from "./populations.mjs";
|
|
92
99
|
|
|
93
100
|
/** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
|
|
94
101
|
export * as marketClass from "./market-class.mjs";
|