@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.
@@ -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: collectAnchors(body, bodyLine).map((a) => ({
447
- ...a,
448
- link: address ? `${address.slug}#${a.slug}` : null,
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
- const doc = foundryEntries({
614
- frontmatter: fm,
615
- address: record.address,
616
- body,
617
- manifest,
618
- })?.doc;
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: record.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}} Where it was written, how many notes and how many assets it
755
- * holds, how many records that is in all, and its size.
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 notes = records.filter(isNoteRecord).length;
825
+ const noteRecords = records.filter(isNoteRecord);
798
826
  const assets = records.filter(isAssetRecord).length;
799
- return { file, notes, assets, records: records.length, bytes: Buffer.byteLength(text) };
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
  }
@@ -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
- byKey.set(canonical, note);
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
- byKey.set(canonicalKey(pkg, NO_SYSTEM, `doc${type}`, fm.shortcode), note);
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
- const local = matchAddress([...byKey, ...byAssetKey], q);
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"`, or `"unresolved"` — and every one of
897
- * them is an **error**: the three resolvers agree on severity for every
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
- "sql query selects no notes — write ```sql allow-empty if " + "that is intended"
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);
@@ -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 `<base><slug>/` — the page's address as every
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
- * @param {HoldingsNode & {url: string}} node - A node with a page.
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 is nobody's entry and
242
- * carries no lists of its own, since there is no page to write them on. A
243
- * name no node declares is dropped: a `parents` naming nowhere is the map's
244
- * finding, a `domains` naming nowhere the reference check's.
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
- const listed = (node) => node.url !== undefined && node.url !== null && node.url !== "";
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(/** @type {any} */ (child)));
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 || !listed(held)) continue;
302
- on(holder.url).holdings.push(entryOf(/** @type {any} */ (held)));
303
- on(held.url).held_by.push(entryOf(/** @type {any} */ (holder)));
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
 
@@ -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";