@heroiclands/package-build 22.4.7 → 22.5.1
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 +34 -0
- package/CONTENT.md +89 -1
- package/bin/content-build.mjs +16 -2
- package/docs/api.md +50 -12
- package/docs/content-format.md +192 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +75 -24
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/foundry-entries.mjs +32 -16
- package/engine/holdings.mjs +42 -17
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +7 -0
- package/engine/journals.mjs +13 -5
- package/engine/note-state.mjs +242 -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 +20 -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 +136 -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,19 @@ 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 its `id`, and a `foundry`
|
|
426
|
+
* block naming every document that was made — which is every document
|
|
427
|
+
* derived from `data:`, and no journal, because a journal of an empty body
|
|
428
|
+
* would be empty and is not created.
|
|
415
429
|
* @throws {Error} When the note carries a key this module derives, which would
|
|
416
430
|
* otherwise be overwritten without a word. `file` and, where the file was
|
|
417
431
|
* read, `position` ride on the error.
|
|
@@ -430,23 +444,38 @@ export function buildIndexRecord({
|
|
|
430
444
|
const posix = relPath.split(path.sep).join("/");
|
|
431
445
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
432
446
|
const address = noteAddress(frontmatter, contentPackage);
|
|
447
|
+
// Derived from the address the note *would* hold, because a stub still
|
|
448
|
+
// compiles every document derived from `data:` — an unwritten arcane talent
|
|
449
|
+
// is its Item. The one document it has none of is the one that would be
|
|
450
|
+
// empty, and `entriesForNote` withholds that UUID, so this block names
|
|
451
|
+
// nothing that was not made.
|
|
433
452
|
const entries = foundryEntries({ frontmatter, address, body, manifest });
|
|
453
|
+
// A stub — an empty body on a type an empty body suppresses — publishes no
|
|
454
|
+
// page, so it holds no address and offers no anchor to link to. **The
|
|
455
|
+
// absent address is the signal**, and it is the column the table renderer
|
|
456
|
+
// already reads through `_ref`. Everything else the note declares is kept:
|
|
457
|
+
// its file is what the author edits and what every diagnostic names, and
|
|
458
|
+
// its `id` is the identity every diagnostic and every table reads.
|
|
459
|
+
const stub = isStubNote(frontmatter, body);
|
|
434
460
|
|
|
435
461
|
return /** @type {Record<string, any>} */ (
|
|
436
462
|
sortKeysDeep({
|
|
437
463
|
...frontmatter,
|
|
438
464
|
package: contentPackage,
|
|
439
|
-
address,
|
|
465
|
+
address: stub ? null : address,
|
|
440
466
|
nameAscii: asciiName(frontmatter?.name?.full),
|
|
441
467
|
aliasesAscii: asciiAliases(frontmatter?.name?.aliases),
|
|
442
468
|
// Each anchor carries the link that reaches it, so a section is
|
|
443
469
|
// addressable from the index without anyone re-deriving how an
|
|
444
470
|
// anchor is spelled — and its file line, so an editor can jump
|
|
445
471
|
// there rather than search for the heading.
|
|
446
|
-
anchors:
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
472
|
+
anchors:
|
|
473
|
+
stub ? null : (
|
|
474
|
+
collectAnchors(body, bodyLine).map((a) => ({
|
|
475
|
+
...a,
|
|
476
|
+
link: address ? `${address.slug}#${a.slug}` : null,
|
|
477
|
+
}))
|
|
478
|
+
),
|
|
450
479
|
foundry: foundryBlock(
|
|
451
480
|
entries?.own,
|
|
452
481
|
systemOf(frontmatter?.type, KNOWN_DOCUMENT_SUBTYPE_MAPS),
|
|
@@ -504,6 +533,12 @@ export function buildIndexRecord({
|
|
|
504
533
|
* @returns {Record<string, any>} The documentation journal's index record.
|
|
505
534
|
*/
|
|
506
535
|
function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anchors }) {
|
|
536
|
+
// **The entry names a document only where one was made**, and where it does
|
|
537
|
+
// not, nothing on this record may name one either: no id, no address and no
|
|
538
|
+
// anchors. The record itself stays, because the index records what a note
|
|
539
|
+
// produces and `null` is how it says a document was not produced — the same
|
|
540
|
+
// signal a stub's absent address already is.
|
|
541
|
+
const made = Boolean(entry.uuid);
|
|
507
542
|
return /** @type {Record<string, any>} */ (
|
|
508
543
|
sortKeysDeep({
|
|
509
544
|
package: contentPackage,
|
|
@@ -514,13 +549,13 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
|
|
|
514
549
|
// re-derived: every entry the index gives an identity to publishes
|
|
515
550
|
// both the id and the UUID, computed once by whatever owns that
|
|
516
551
|
// entry's derivation.
|
|
517
|
-
id: entry.id,
|
|
552
|
+
id: made ? entry.id : null,
|
|
518
553
|
nameAscii: asciiName(frontmatter?.name?.full),
|
|
519
|
-
address: { slug: address.slug, canonical: entry.key },
|
|
554
|
+
address: made ? { slug: address.slug, canonical: entry.key } : null,
|
|
520
555
|
// The record this is the documentation *for*. `documentation` is
|
|
521
556
|
// the forward link on that record, so either end reaches the other.
|
|
522
557
|
documents: address.canonical,
|
|
523
|
-
anchors,
|
|
558
|
+
anchors: made ? anchors : null,
|
|
524
559
|
foundry: foundryBlock(entry), // a journal: no system key
|
|
525
560
|
file,
|
|
526
561
|
})
|
|
@@ -609,22 +644,21 @@ export function collectContentIndex(
|
|
|
609
644
|
}
|
|
610
645
|
records.push(record);
|
|
611
646
|
|
|
612
|
-
// An item note is two documents, so it is two records.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (doc?.key && record.address) {
|
|
647
|
+
// An item note is two documents, so it is two records. Derived from
|
|
648
|
+
// the address the note *would* hold rather than from the one its
|
|
649
|
+
// record carries, because a stub withholds its own address; the second
|
|
650
|
+
// record says whether the journal beside the item was made.
|
|
651
|
+
const address = noteAddress(fm, contentPackage);
|
|
652
|
+
const doc = foundryEntries({ frontmatter: fm, address, body, manifest })?.doc;
|
|
653
|
+
if (doc?.key && address) {
|
|
620
654
|
records.push(
|
|
621
655
|
buildDocRecord({
|
|
622
656
|
frontmatter: fm,
|
|
623
|
-
address
|
|
657
|
+
address,
|
|
624
658
|
entry: doc,
|
|
625
659
|
file: record.file,
|
|
626
660
|
contentPackage,
|
|
627
|
-
anchors: record.anchors,
|
|
661
|
+
anchors: record.anchors ?? [],
|
|
628
662
|
}),
|
|
629
663
|
);
|
|
630
664
|
}
|
|
@@ -751,8 +785,11 @@ export function indexRecordsFor({
|
|
|
751
785
|
* `paths.contentIndex`.
|
|
752
786
|
* @param {object} [options.config] - A resolved configuration; loaded when omitted.
|
|
753
787
|
* @returns {{file: string, notes: number, assets: number, records: number,
|
|
754
|
-
* bytes: number
|
|
755
|
-
*
|
|
788
|
+
* bytes: number, full: number, draft: number, stub: number}} Where it was
|
|
789
|
+
* written, how many notes and how many assets it holds, how many records that
|
|
790
|
+
* is in all, its size, and the ladder those notes sit on — so the ratio of
|
|
791
|
+
* written to unwritten is visible on every build rather than discovered in a
|
|
792
|
+
* year.
|
|
756
793
|
* @throws {Error} When the content tree is absent, or when it yields no note at
|
|
757
794
|
* all — an empty index is indistinguishable from a mis-pointed tree, and a
|
|
758
795
|
* reader would take it as the authoritative statement that this package has
|
|
@@ -794,7 +831,21 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
|
|
|
794
831
|
// note yields a second record for its documentation journal and a file
|
|
795
832
|
// yields an asset record, so reporting records as notes would overstate how
|
|
796
833
|
// large the tree is.
|
|
797
|
-
const
|
|
834
|
+
const noteRecords = records.filter(isNoteRecord);
|
|
798
835
|
const assets = records.filter(isAssetRecord).length;
|
|
799
|
-
|
|
836
|
+
// Read off the records rather than from a second pass over the tree: a stub
|
|
837
|
+
// is the note whose address the emitter just withheld, and a draft is the
|
|
838
|
+
// tag the note carries, so both are already in hand.
|
|
839
|
+
const stub = noteRecords.filter(isStub).length;
|
|
840
|
+
const draft = noteRecords.filter((record) => !isStub(record) && isDraftNote(record)).length;
|
|
841
|
+
return {
|
|
842
|
+
file,
|
|
843
|
+
notes: noteRecords.length,
|
|
844
|
+
assets,
|
|
845
|
+
records: records.length,
|
|
846
|
+
bytes: Buffer.byteLength(text),
|
|
847
|
+
full: noteRecords.length - stub - draft,
|
|
848
|
+
draft,
|
|
849
|
+
stub,
|
|
850
|
+
};
|
|
800
851
|
}
|
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);
|
|
@@ -69,6 +69,7 @@ import { walkMarkdownTree } from "./helpers.mjs";
|
|
|
69
69
|
import { resolveNoteId } from "./note-ids.mjs";
|
|
70
70
|
import { compendiumUuid, currentType, packForType, pageUuid } from "./ids.mjs";
|
|
71
71
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
72
|
+
import { journalHasContent } from "./note-state.mjs";
|
|
72
73
|
import { isHomepage } from "./homepage.mjs";
|
|
73
74
|
import { declaresNoPack } from "./pack-router.mjs";
|
|
74
75
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
@@ -163,7 +164,19 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
163
164
|
// A published address must name the pack the document actually shipped in:
|
|
164
165
|
// a consumer resolves the UUID verbatim, and a repository may ship several
|
|
165
166
|
// packs of one type.
|
|
166
|
-
const uuidFor = (type, id, routeFm) =>
|
|
167
|
+
const uuidFor = (type, id, routeFm) => {
|
|
168
|
+
// **A document that would be empty is not created, so nothing names
|
|
169
|
+
// one.** A JournalEntry's content is the note's prose, and an empty
|
|
170
|
+
// body compiles no pages, so the pass emits no entry — and a manifest
|
|
171
|
+
// naming one would send every consumer to a document that is not
|
|
172
|
+
// there. Asked of the **document type**, which is what makes one
|
|
173
|
+
// statement cover both journals a note can produce: its own, and the
|
|
174
|
+
// documentation standing beside an item. Every other document is
|
|
175
|
+
// derived from `data:` and is emitted whatever the body holds.
|
|
176
|
+
if (packForType(type)?.docType === "JournalEntry" && !journalHasContent(body)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
// A type this cannot name a single compendium document for has no UUID
|
|
168
181
|
// to publish, whatever id it derives. That used to follow from such a
|
|
169
182
|
// note authoring no `id:`; every addressable note derives
|
|
@@ -185,21 +198,24 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
185
198
|
// statement: it publishes a page and compiles into nothing, so it has
|
|
186
199
|
// an address and no UUID. The shared declaration is what is read,
|
|
187
200
|
// because an entry names one document and this pass routes no system.
|
|
188
|
-
(
|
|
189
|
-
id
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
if (
|
|
202
|
+
!id ||
|
|
203
|
+
NEVER_PACKED_TYPES.has(String(type)) ||
|
|
204
|
+
DERIVED_PACKED_TYPES.has(String(type)) ||
|
|
205
|
+
(routeFm && declaresNoPack(routeFm))
|
|
206
|
+
) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return compendiumUuid(
|
|
211
|
+
foundryPackageId,
|
|
212
|
+
type,
|
|
213
|
+
id,
|
|
214
|
+
routeFm ?
|
|
215
|
+
packRouter.resolveOrNull(routeFm, packForType(type).docType)
|
|
216
|
+
: packRouter.defaultOf("JournalEntry"),
|
|
217
|
+
);
|
|
218
|
+
};
|
|
203
219
|
|
|
204
220
|
const carriesDoc =
|
|
205
221
|
ctx.docEntryTypes ?
|
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";
|
package/engine/journals.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
|
|
|
55
55
|
import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
56
56
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
57
57
|
import { JOURNAL_TYPES } from "./ids.mjs";
|
|
58
|
+
import { journalHasContent } from "./note-state.mjs";
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Splits a markdown body into pages by top-level H1 headings. Fenced
|
|
@@ -382,17 +383,24 @@ export class Journals extends BasePackCompiler {
|
|
|
382
383
|
}
|
|
383
384
|
|
|
384
385
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* the
|
|
386
|
+
* **A journal that would be empty is not created**, and that is the whole
|
|
387
|
+
* rule — one test, asked of every note this pass claims, whether its
|
|
388
|
+
* journal is its own document or the documentation standing beside an item.
|
|
389
|
+
* A note with no prose gets no entry: the items pass leaves its description
|
|
390
|
+
* empty rather than pointing at nothing, a map gets no pin target, and
|
|
391
|
+
* {@link module:engine/foundry-entries} publishes no UUID, so nothing names
|
|
392
|
+
* a document that was not made.
|
|
393
|
+
*
|
|
394
|
+
* An entry holding only its infobox is empty by this test. The panel is a
|
|
395
|
+
* rendering of `data:` that the content index already carries, so a
|
|
396
|
+
* compendium entry of nothing else tells a reader what the index told them.
|
|
389
397
|
*
|
|
390
398
|
* @param {object} fm - The note's frontmatter.
|
|
391
399
|
* @param {string} body - The note body, frontmatter stripped.
|
|
392
400
|
* @returns {boolean} True to skip the note.
|
|
393
401
|
*/
|
|
394
402
|
skipNote(fm, body) {
|
|
395
|
-
return
|
|
403
|
+
return !journalHasContent(body);
|
|
396
404
|
}
|
|
397
405
|
|
|
398
406
|
/**
|