@heroiclands/package-build 23.0.0 → 23.1.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 +66 -0
- package/README.md +1 -1
- package/bin/content-commands.mjs +1 -1
- package/ci/content-format-reference.mjs +46 -0
- package/docs/api.md +124 -141
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +145 -29
- package/docs/commands.md +8 -10
- package/docs/configuration.md +13 -11
- package/docs/content-format.md +2 -2
- package/docs/guides/book.md +59 -4
- package/docs/guides/icons.md +4 -5
- package/docs/guides/packs.md +6 -0
- package/docs/guides/reference-pages.md +21 -1
- package/docs/guides/release-and-deploy.md +39 -7
- package/docs/guides/site.md +17 -18
- package/docs/reference/format-details.md +877 -276
- package/docs/reference/note-types.md +278 -91
- package/docs/reference/theme-pages.md +58 -97
- package/engine/address-values.mjs +124 -4
- package/engine/address.mjs +44 -3
- package/engine/anchors.mjs +103 -3
- package/engine/art-slots.mjs +2 -9
- package/engine/base-compiler.mjs +25 -6
- package/engine/calendar-artifacts.mjs +4 -1
- package/engine/calendar-notes.mjs +166 -102
- package/engine/content-format-check.mjs +2 -3
- package/engine/content-format.mjs +44 -1
- package/engine/content-format.yaml +79 -16
- package/engine/content-images.mjs +10 -1
- package/engine/content-index.mjs +37 -9
- package/engine/content-links.mjs +24 -5
- package/engine/content-tables.mjs +24 -3
- package/engine/data-keys.mjs +347 -0
- package/engine/derived-sections.mjs +322 -0
- package/engine/diagnostics.mjs +28 -0
- package/engine/event-fields.mjs +187 -0
- package/engine/event-rows.mjs +126 -0
- package/engine/event-views.mjs +305 -0
- package/engine/exported-scene.mjs +29 -53
- package/engine/frontmatter-lint.mjs +103 -77
- package/engine/frontmatter.mjs +7 -2
- package/engine/generate.mjs +53 -0
- package/engine/generated-sections.mjs +223 -0
- package/engine/heading-attributes.mjs +4 -2
- package/engine/helpers.mjs +12 -1
- package/engine/holdings.mjs +12 -33
- package/engine/ids.mjs +17 -11
- package/engine/index.mjs +4 -4
- package/engine/infobox-render.mjs +21 -6
- package/engine/infobox.mjs +217 -99
- package/engine/journals.mjs +104 -47
- package/engine/literature-notes.mjs +4 -1
- package/engine/literature-works.mjs +21 -13
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +26 -0
- package/engine/near-miss.mjs +90 -0
- package/engine/note-addresses.mjs +75 -19
- package/engine/note-dates.mjs +5 -3
- package/engine/note-event-terms.mjs +364 -0
- package/engine/note-events.mjs +823 -0
- package/engine/note-vocabulary.mjs +184 -88
- package/engine/office-holders.mjs +20 -54
- package/engine/packagebuild-index.mjs +16 -1
- package/engine/pdf-build.mjs +97 -60
- package/engine/pdf-index.mjs +263 -0
- package/engine/pdf-render.mjs +47 -5
- package/engine/place-relation-terms.mjs +174 -0
- package/engine/place-relations.mjs +72 -160
- package/engine/rank-ladder.mjs +14 -41
- package/engine/reckoning-markers.mjs +2 -0
- package/engine/region-events.mjs +6 -12
- package/engine/scenes.mjs +73 -468
- package/engine/site-build.mjs +75 -101
- package/engine/site-index.mjs +3 -1
- package/engine/site-maps.mjs +43 -29
- package/engine/site-preview.mjs +40 -31
- package/engine/social-ties.mjs +2 -1
- package/engine/sql-tables.mjs +212 -9
- package/engine/standing-terms.mjs +145 -6
- package/engine/standings.mjs +37 -49
- package/engine/web-wikilinks.mjs +21 -1
- package/engine/wikilink-syntax.mjs +34 -4
- package/engine/wikilinks.mjs +27 -2
- package/engine/yaml-lint.mjs +7 -4
- package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
- package/hugo-theme/layouts/partials/page-body.html +0 -3
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- package/release.mjs +62 -4
- package/sohl/note-schemas.mjs +8 -73
- package/types/engine/address-values.d.mts +50 -3
- package/types/engine/address.d.mts +21 -0
- package/types/engine/anchors.d.mts +49 -2
- package/types/engine/art-slots.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +13 -4
- package/types/engine/calendar-notes.d.mts +7 -0
- package/types/engine/content-format.d.mts +52 -0
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-tables.d.mts +9 -1
- package/types/engine/data-keys.d.mts +145 -0
- package/types/engine/derived-sections.d.mts +73 -0
- package/types/engine/diagnostics.d.mts +15 -0
- package/types/engine/event-fields.d.mts +99 -0
- package/types/engine/event-rows.d.mts +12 -0
- package/types/engine/event-views.d.mts +67 -0
- package/types/engine/exported-scene.d.mts +5 -2
- package/types/engine/frontmatter-lint.d.mts +3 -3
- package/types/engine/generated-sections.d.mts +143 -0
- package/types/engine/holdings.d.mts +6 -20
- package/types/engine/ids.d.mts +15 -12
- package/types/engine/index.d.mts +1 -1
- package/types/engine/infobox.d.mts +13 -3
- package/types/engine/journals.d.mts +24 -11
- package/types/engine/literature-works.d.mts +1 -2
- package/types/engine/map-graphviz.d.mts +2 -10
- package/types/engine/markdown-expressions.d.mts +12 -3
- package/types/engine/near-miss.d.mts +42 -0
- package/types/engine/note-addresses.d.mts +7 -1
- package/types/engine/note-event-terms.d.mts +68 -0
- package/types/engine/note-events.d.mts +71 -0
- package/types/engine/note-vocabulary.d.mts +37 -8
- package/types/engine/office-holders.d.mts +3 -0
- package/types/engine/pdf-index.d.mts +35 -0
- package/types/engine/pdf-render.d.mts +8 -2
- package/types/engine/place-relation-terms.d.mts +83 -0
- package/types/engine/place-relations.d.mts +11 -66
- package/types/engine/rank-ladder.d.mts +2 -0
- package/types/engine/region-events.d.mts +6 -12
- package/types/engine/scenes.d.mts +6 -32
- package/types/engine/site-build.d.mts +8 -22
- package/types/engine/site-maps.d.mts +15 -7
- package/types/engine/sql-tables.d.mts +77 -2
- package/types/engine/standing-terms.d.mts +37 -4
- package/types/engine/standings.d.mts +14 -9
- package/types/engine/wikilink-syntax.d.mts +20 -0
- package/types/engine/wikilinks.d.mts +3 -2
- package/types/engine/yaml-lint.d.mts +5 -4
- package/types/release.d.mts +4 -0
- package/docs/guides/government-migration.md +0 -52
- package/engine/itinerary-scenes.mjs +0 -166
- package/engine/lore-events.mjs +0 -197
- package/engine/map-notes.mjs +0 -1309
- package/engine/map-raster.mjs +0 -51
- package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
- package/hugo-theme/layouts/partials/holdings.html +0 -61
- package/hugo-theme/layouts/partials/literature-works.html +0 -29
- package/hugo-theme/layouts/partials/place-map.html +0 -9
- package/types/engine/itinerary-scenes.d.mts +0 -29
- package/types/engine/lore-events.d.mts +0 -14
- package/types/engine/map-notes.d.mts +0 -297
- package/types/engine/map-raster.d.mts +0 -8
package/engine/sql-tables.mjs
CHANGED
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
|
|
29
29
|
import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
|
|
30
30
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
31
|
-
import { encodeAddresses } from "./address-values.mjs";
|
|
31
|
+
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
32
|
+
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
33
|
+
import { eventViewSections } from "./event-views.mjs";
|
|
34
|
+
import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
|
|
32
35
|
import fs from "node:fs";
|
|
33
36
|
import os from "node:os";
|
|
34
37
|
import path from "node:path";
|
|
@@ -211,6 +214,13 @@ export async function openNotesDatabase(
|
|
|
211
214
|
const connection = await instance.connect();
|
|
212
215
|
await connection.run("SET threads=1");
|
|
213
216
|
await createRelations(connection, jsonl, "", audience);
|
|
217
|
+
await createEventRelation(
|
|
218
|
+
connection,
|
|
219
|
+
path.join(base, "events.jsonl"),
|
|
220
|
+
records.map((record) => encodeAddresses(record)),
|
|
221
|
+
"",
|
|
222
|
+
audience,
|
|
223
|
+
);
|
|
214
224
|
|
|
215
225
|
// One schema per declared dependency, so `FROM sohl.notes` reads the notes
|
|
216
226
|
// that package published. Quoted, because a package id may carry a hyphen
|
|
@@ -220,7 +230,26 @@ export async function openNotesDatabase(
|
|
|
220
230
|
if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
|
|
221
231
|
const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
|
|
222
232
|
await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
|
223
|
-
|
|
233
|
+
// A published index writes each Address as an object; a fence compares
|
|
234
|
+
// the string an author writes, so each is flattened to it on load.
|
|
235
|
+
const flat = path.join(
|
|
236
|
+
base,
|
|
237
|
+
`dependency-${String(dep.id).replace(/[^a-z0-9]/gi, "_")}.jsonl`,
|
|
238
|
+
);
|
|
239
|
+
const depRecords = fs
|
|
240
|
+
.readFileSync(dep.file, "utf8")
|
|
241
|
+
.split("\n")
|
|
242
|
+
.filter((line) => line.trim())
|
|
243
|
+
.map((line) => flattenPublishedAddresses(JSON.parse(line)));
|
|
244
|
+
fs.writeFileSync(flat, depRecords.map((record) => JSON.stringify(record)).join("\n"));
|
|
245
|
+
await createRelations(connection, flat, `${schema}.`, audience);
|
|
246
|
+
await createEventRelation(
|
|
247
|
+
connection,
|
|
248
|
+
flat.replace(/\.jsonl$/, "-events.jsonl"),
|
|
249
|
+
depRecords,
|
|
250
|
+
`${schema}.`,
|
|
251
|
+
audience,
|
|
252
|
+
);
|
|
224
253
|
}
|
|
225
254
|
|
|
226
255
|
// The market scale as a relation, so a table prints `village` beside the
|
|
@@ -245,6 +274,7 @@ export async function openNotesDatabase(
|
|
|
245
274
|
// {@link prepareSqlTables}.
|
|
246
275
|
await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
|
|
247
276
|
await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
|
|
277
|
+
await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.events AS SELECT * FROM main.events`);
|
|
248
278
|
|
|
249
279
|
// Whether this index holds a stub at all, asked once. A corpus with none
|
|
250
280
|
// pays nothing for the comparison above, which is every package that has
|
|
@@ -394,6 +424,42 @@ async function createRelations(connection, file, prefix = "", audience = "all")
|
|
|
394
424
|
);
|
|
395
425
|
}
|
|
396
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Create the `events` relation: one row per `data.events` entry, across every
|
|
429
|
+
* note type that carries events, with the columns
|
|
430
|
+
* {@link module:engine/event-rows.EVENT_COLUMNS} names.
|
|
431
|
+
*
|
|
432
|
+
* Its rows are derived from the same records `entries` reads, stubs included —
|
|
433
|
+
* an event a stub states is still an event — and a GM note's events are left
|
|
434
|
+
* out on a public surface exactly as the note is. A corpus with no event at all
|
|
435
|
+
* still has the relation, empty, so a fence over it reports no rows rather than
|
|
436
|
+
* a missing table.
|
|
437
|
+
*
|
|
438
|
+
* @param {object} connection - An open DuckDB connection.
|
|
439
|
+
* @param {string} file - Where to write the rows.
|
|
440
|
+
* @param {object[]} records - Index records, every Address a string.
|
|
441
|
+
* @param {string} prefix - A schema to qualify the view name with.
|
|
442
|
+
* @param {"all"|"public"} audience - Whether to leave out GM notes.
|
|
443
|
+
* @returns {Promise<void>}
|
|
444
|
+
*/
|
|
445
|
+
async function createEventRelation(connection, file, records, prefix, audience) {
|
|
446
|
+
const rows = eventRows(
|
|
447
|
+
audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
|
|
448
|
+
);
|
|
449
|
+
if (!rows.length) {
|
|
450
|
+
const columns = EVENT_COLUMNS.map((column) => {
|
|
451
|
+
const type = EVENT_SORT_COLUMNS.includes(column) ? "DOUBLE" : "VARCHAR";
|
|
452
|
+
return `NULL::${type} AS "${column}"`;
|
|
453
|
+
});
|
|
454
|
+
await connection.run(
|
|
455
|
+
`CREATE VIEW ${prefix}events AS SELECT ${columns.join(", ")} WHERE false`,
|
|
456
|
+
);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
|
|
460
|
+
await connection.run(`CREATE VIEW ${prefix}events AS ${readJsonAuto(file)}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
397
463
|
/**
|
|
398
464
|
* The `read_json_auto` clause both the own-notes view and a dependency's use.
|
|
399
465
|
*
|
|
@@ -681,6 +747,106 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
681
747
|
return byNote;
|
|
682
748
|
}
|
|
683
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Each note's generated sections — the derived lists and the event views — see
|
|
752
|
+
* {@link module:engine/generated-sections}.
|
|
753
|
+
*
|
|
754
|
+
* @param {object|undefined} db - From {@link openNotesDatabase}, over
|
|
755
|
+
* `records`; absent, no event view is asked for.
|
|
756
|
+
* @param {object[]} records - Content-index records, of the audience the
|
|
757
|
+
* surface publishes to.
|
|
758
|
+
* @param {string} contentBase - Root of the content tree.
|
|
759
|
+
* @param {object} [opts]
|
|
760
|
+
* @param {string} [opts.only] - One note file to give sections to; absent,
|
|
761
|
+
* every note.
|
|
762
|
+
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes, whose
|
|
763
|
+
* places, affiliations and works the lists take in.
|
|
764
|
+
* @param {object} [opts.config] - The resolved configuration.
|
|
765
|
+
* @param {Map<string, Map<string, string>>} [opts.derived] - The derived
|
|
766
|
+
* lists, from an earlier call over the same records, which are not derived
|
|
767
|
+
* again.
|
|
768
|
+
* @returns {Promise<{sections: Map<string, string>, views: Set<string>,
|
|
769
|
+
* derived: Map<string, Map<string, string>>}>} Note file to its sections,
|
|
770
|
+
* joined in order; the files whose sections hold an event view's fence; and
|
|
771
|
+
* the derived lists alone.
|
|
772
|
+
*/
|
|
773
|
+
export async function generatedSectionsFor(
|
|
774
|
+
db,
|
|
775
|
+
records,
|
|
776
|
+
contentBase,
|
|
777
|
+
{ only, foreignIndex, config, derived: given } = {},
|
|
778
|
+
) {
|
|
779
|
+
const notes = records.filter((record) => isNoteRecord(record));
|
|
780
|
+
const fileOf = (record) => noteFile(contentBase, record);
|
|
781
|
+
// Imported here for the cycle reason the index is: the derivations read
|
|
782
|
+
// the map world, which reaches the content index.
|
|
783
|
+
const derived =
|
|
784
|
+
given ??
|
|
785
|
+
(await import("./derived-sections.mjs")).derivedSections(notes, {
|
|
786
|
+
fileOf,
|
|
787
|
+
foreignIndex,
|
|
788
|
+
config,
|
|
789
|
+
only,
|
|
790
|
+
});
|
|
791
|
+
const views = db ? await eventViewSections(db, notes, fileOf, only) : new Map();
|
|
792
|
+
const sections = new Map();
|
|
793
|
+
for (const file of new Set([...derived.keys(), ...views.keys()]))
|
|
794
|
+
sections.set(file, joinSections([derived.get(file), views.get(file)]));
|
|
795
|
+
return { sections, views: new Set(views.keys()), derived };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Join each note's generated sections to its body among the sources a
|
|
800
|
+
* preparation answers, so the fences the event views hold are answered with
|
|
801
|
+
* the author's own, in order after them. A note whose sections hold a fence
|
|
802
|
+
* and whose body holds no directive of its own joins the sources, its body
|
|
803
|
+
* read as {@link prepareTreeSqlTables} reads one.
|
|
804
|
+
*
|
|
805
|
+
* The expansion every surface runs appends the same text again, from the
|
|
806
|
+
* `generated` {@link attachGeneratedSections} sets on the note's results —
|
|
807
|
+
* see {@link module:engine/content-tables.expandContentTables}.
|
|
808
|
+
*
|
|
809
|
+
* @param {{sections: Map<string, string>, views: Set<string>}} generated -
|
|
810
|
+
* From {@link generatedSectionsFor}.
|
|
811
|
+
* @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
|
|
812
|
+
* The bodies to answer, extended in place.
|
|
813
|
+
* @returns {void}
|
|
814
|
+
*/
|
|
815
|
+
export function joinGeneratedSections(generated, sources) {
|
|
816
|
+
for (const [absPath, text] of generated.sections) {
|
|
817
|
+
const source = sources.find((one) => one.source === absPath);
|
|
818
|
+
if (source) {
|
|
819
|
+
source.markdown = appendGeneratedSections(source.markdown, text);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (!generated.views.has(absPath)) continue;
|
|
823
|
+
const { body, frontmatter } = parseMarkdownFile(absPath);
|
|
824
|
+
sources.push({
|
|
825
|
+
source: absPath,
|
|
826
|
+
markdown: appendGeneratedSections(body, text),
|
|
827
|
+
frontmatter,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Record each note's generated sections on its prepared results, where the
|
|
834
|
+
* table expansion reads them. A note with no results of its own is given an
|
|
835
|
+
* empty set carrying them.
|
|
836
|
+
*
|
|
837
|
+
* @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
|
|
838
|
+
* @param {{sections: Map<string, string>}} generated - From
|
|
839
|
+
* {@link generatedSectionsFor}.
|
|
840
|
+
* @returns {void}
|
|
841
|
+
*/
|
|
842
|
+
export function attachGeneratedSections(prepared, generated) {
|
|
843
|
+
for (const [absPath, text] of generated.sections) {
|
|
844
|
+
let results = prepared.get(absPath);
|
|
845
|
+
if (!results) prepared.set(absPath, (results = []));
|
|
846
|
+
results.generated = text;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
684
850
|
/**
|
|
685
851
|
* Answer every `sql` directive in a content tree.
|
|
686
852
|
*
|
|
@@ -706,13 +872,16 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
706
872
|
* A command that also builds a link index holds them already, and deriving
|
|
707
873
|
* them twice is the duplicated-corpus failure this closes.
|
|
708
874
|
* @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
|
|
875
|
+
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
|
|
876
|
+
* generated sections take in, where the caller holds them already; absent,
|
|
877
|
+
* they are read from the cache.
|
|
709
878
|
* @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
|
|
710
879
|
* carrying `inline` and `pageLists` beside them, or nothing when the tree
|
|
711
|
-
* holds no such directive.
|
|
880
|
+
* holds no such directive and no note is given a generated section.
|
|
712
881
|
*/
|
|
713
882
|
export async function prepareTreeSqlTables(
|
|
714
883
|
contentBase,
|
|
715
|
-
{ config, skipDirectories, records, audience = "all" } = {},
|
|
884
|
+
{ config, skipDirectories, records, audience = "all", foreignIndex } = {},
|
|
716
885
|
) {
|
|
717
886
|
// Imported here rather than at module scope: the index reaches the pack
|
|
718
887
|
// compilers through `manifest-emit` → `journals`, so a static import from a
|
|
@@ -745,20 +914,49 @@ export async function prepareTreeSqlTables(
|
|
|
745
914
|
)
|
|
746
915
|
sources.push({ source: absPath, markdown: body, frontmatter });
|
|
747
916
|
}
|
|
748
|
-
|
|
917
|
+
// A note's event views are fences too, so a tree whose notes carry events
|
|
918
|
+
// asks a query even where no author wrote one.
|
|
919
|
+
const hasEvents = indexRecords.some(
|
|
920
|
+
(record) =>
|
|
921
|
+
isNoteRecord(record) &&
|
|
922
|
+
Array.isArray(record.data?.events) &&
|
|
923
|
+
record.data.events.length > 0,
|
|
924
|
+
);
|
|
925
|
+
// The derived lists read no database, so they are found before one is
|
|
926
|
+
// opened, and a tree whose notes are given only those opens none.
|
|
927
|
+
let foreign = foreignIndex;
|
|
928
|
+
if (!foreign) {
|
|
929
|
+
try {
|
|
930
|
+
const { loadForeignIndexes } = await import("./metadata-index.mjs");
|
|
931
|
+
foreign =
|
|
932
|
+
config ? loadForeignIndexes(config, [config.contentPackage]).index : new Map();
|
|
933
|
+
} catch {
|
|
934
|
+
foreign = new Map();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
|
|
938
|
+
foreignIndex: foreign,
|
|
939
|
+
config,
|
|
940
|
+
});
|
|
941
|
+
if (!sources.length && !hasEvents && !derivedOnly.sections.size) return undefined;
|
|
749
942
|
|
|
750
943
|
// A page list is a filter over the records, so it needs no database. A tree
|
|
751
944
|
// whose only corpus-reading directive is one therefore opens none — which
|
|
752
945
|
// is the same bargain the early return above makes for a tree with no
|
|
753
946
|
// directive at all.
|
|
754
|
-
const needsDatabase =
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
947
|
+
const needsDatabase =
|
|
948
|
+
hasEvents ||
|
|
949
|
+
sources.some(
|
|
950
|
+
({ markdown, frontmatter }) =>
|
|
951
|
+
findSqlBlocks(markdown).length ||
|
|
952
|
+
sqlQueriesInMarkdown(markdown, frontmatter).length,
|
|
953
|
+
);
|
|
758
954
|
if (!needsDatabase) {
|
|
759
955
|
const pageOnly = new Map();
|
|
956
|
+
joinGeneratedSections(derivedOnly, sources);
|
|
760
957
|
pageOnly.inline = new Map();
|
|
761
958
|
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
959
|
+
attachGeneratedSections(pageOnly, derivedOnly);
|
|
762
960
|
return pageOnly;
|
|
763
961
|
}
|
|
764
962
|
// A cell links only where the address it would emit resolves, so a table
|
|
@@ -790,11 +988,16 @@ export async function prepareTreeSqlTables(
|
|
|
790
988
|
}
|
|
791
989
|
const db = await openNotesDatabase(indexRecords, { dependencies, audience });
|
|
792
990
|
try {
|
|
991
|
+
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
|
992
|
+
derived: derivedOnly.derived,
|
|
993
|
+
});
|
|
994
|
+
joinGeneratedSections(generated, sources);
|
|
793
995
|
const prepared = await prepareSqlTables(db, sources, {
|
|
794
996
|
linkable: (ref) => addresses.has(renderAddress(ref)),
|
|
795
997
|
records: indexRecords,
|
|
796
998
|
});
|
|
797
999
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
1000
|
+
attachGeneratedSections(prepared, generated);
|
|
798
1001
|
return prepared;
|
|
799
1002
|
} finally {
|
|
800
1003
|
await db.close();
|
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The terms of a body's standing — what a membership carries, and the rungs
|
|
16
|
+
* and posts a body confers — as inner-key declarations.
|
|
17
|
+
*
|
|
18
|
+
* A leaf module, importing nothing: the note vocabulary reads these
|
|
19
|
+
* declarations as it loads, and the checks that judge what the values mean
|
|
20
|
+
* reach the vocabulary through the Address reader.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
2
24
|
|
|
3
25
|
/**
|
|
4
26
|
* The note type a standing is held in.
|
|
@@ -10,10 +32,127 @@
|
|
|
10
32
|
export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
|
|
11
33
|
|
|
12
34
|
/**
|
|
13
|
-
* The
|
|
35
|
+
* The keys one standing carries, as inner-key declarations.
|
|
36
|
+
*
|
|
37
|
+
* `rank` is the level on the body's own `governance.ranks` ladder, and every
|
|
38
|
+
* membership states one. `office` is a key of its `governance.offices` map, and
|
|
39
|
+
* is stated only where the being holds a post.
|
|
40
|
+
*
|
|
41
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
42
|
+
*/
|
|
43
|
+
export const STANDING_FIELDS = Object.freeze([
|
|
44
|
+
Object.freeze({
|
|
45
|
+
name: "rank",
|
|
46
|
+
kind: "integer",
|
|
47
|
+
required: true,
|
|
48
|
+
shape:
|
|
49
|
+
"the level on that body's own ladder, as a whole number — an ordinary " +
|
|
50
|
+
"member is `1`, and `0` is the rung for someone cast out",
|
|
51
|
+
describe: "The level on the body's own `governance.ranks` ladder.",
|
|
52
|
+
}),
|
|
53
|
+
Object.freeze({
|
|
54
|
+
name: "office",
|
|
55
|
+
kind: "string",
|
|
56
|
+
shape: "a post from that body's `governance.offices`",
|
|
57
|
+
describe: "A post the body names in its `governance.offices`, where one is held.",
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** The keys of {@link STANDING_FIELDS}, in declared order. */
|
|
62
|
+
export const STANDING_KEYS = Object.freeze(STANDING_FIELDS.map((field) => field.name));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The keys a rung carries, as inner-key declarations.
|
|
66
|
+
*
|
|
67
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
68
|
+
*/
|
|
69
|
+
export const RUNG_FIELDS = Object.freeze([
|
|
70
|
+
Object.freeze({
|
|
71
|
+
name: "level",
|
|
72
|
+
kind: "integer",
|
|
73
|
+
required: true,
|
|
74
|
+
shape: "a whole number — the rung's position on this body's own ladder",
|
|
75
|
+
describe: "The rung's position on the ladder; a member's `rank` names it.",
|
|
76
|
+
}),
|
|
77
|
+
Object.freeze({
|
|
78
|
+
name: "title",
|
|
79
|
+
kind: "string",
|
|
80
|
+
required: true,
|
|
81
|
+
shape: "what the standing is called",
|
|
82
|
+
describe: "What the standing is called.",
|
|
83
|
+
}),
|
|
84
|
+
Object.freeze({
|
|
85
|
+
name: "description",
|
|
86
|
+
kind: "string",
|
|
87
|
+
required: true,
|
|
88
|
+
shape: "what the standing is",
|
|
89
|
+
describe: "What the standing is.",
|
|
90
|
+
}),
|
|
91
|
+
Object.freeze({
|
|
92
|
+
name: "lore",
|
|
93
|
+
kind: "address",
|
|
94
|
+
shape: "a lore Address",
|
|
95
|
+
describe: "Lore saying more about the standing than a description holds.",
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The keys one row of an office's `holders` carries, as inner-key declarations.
|
|
101
|
+
*
|
|
102
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
103
|
+
*/
|
|
104
|
+
export const HOLDER_FIELDS = Object.freeze([
|
|
105
|
+
Object.freeze({
|
|
106
|
+
name: "being",
|
|
107
|
+
kind: "address",
|
|
108
|
+
required: true,
|
|
109
|
+
shape: "a being Address",
|
|
110
|
+
describe: "Who held the office.",
|
|
111
|
+
}),
|
|
112
|
+
Object.freeze({
|
|
113
|
+
name: "start",
|
|
114
|
+
kind: "date",
|
|
115
|
+
shape: "a date",
|
|
116
|
+
describe: "When the term began; unstated, it began before anything recorded.",
|
|
117
|
+
}),
|
|
118
|
+
Object.freeze({
|
|
119
|
+
name: "end",
|
|
120
|
+
kind: "date",
|
|
121
|
+
shape: "a date",
|
|
122
|
+
describe: "When the term ended; unstated, the being holds the office now.",
|
|
123
|
+
}),
|
|
124
|
+
Object.freeze({
|
|
125
|
+
name: "contested",
|
|
126
|
+
kind: "boolean",
|
|
127
|
+
shape: "`true` or `false`",
|
|
128
|
+
describe: "The term may overlap another holder's.",
|
|
129
|
+
}),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The keys an office written as a map carries. An office may instead be its
|
|
134
|
+
* description alone, as a string.
|
|
14
135
|
*
|
|
15
|
-
*
|
|
16
|
-
* `office` is a key of its `governance.offices` map, as a string. Both are
|
|
17
|
-
* optional — a membership with neither is the ordinary shape.
|
|
136
|
+
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
18
137
|
*/
|
|
19
|
-
export const
|
|
138
|
+
export const OFFICE_FIELDS = Object.freeze([
|
|
139
|
+
Object.freeze({
|
|
140
|
+
name: "description",
|
|
141
|
+
kind: "string",
|
|
142
|
+
required: true,
|
|
143
|
+
shape: "a string",
|
|
144
|
+
describe: "What the office is.",
|
|
145
|
+
}),
|
|
146
|
+
Object.freeze({
|
|
147
|
+
name: "holders",
|
|
148
|
+
kind: "list",
|
|
149
|
+
required: true,
|
|
150
|
+
shape: "a list of `{ being, start?, end?, contested? }`",
|
|
151
|
+
entries: Object.freeze({
|
|
152
|
+
kind: "map",
|
|
153
|
+
shape: "a map — `{ being, start?, end?, contested? }`",
|
|
154
|
+
fields: HOLDER_FIELDS,
|
|
155
|
+
}),
|
|
156
|
+
describe: "Who has held it, each with the dates of the term.",
|
|
157
|
+
}),
|
|
158
|
+
]);
|
package/engine/standings.mjs
CHANGED
|
@@ -55,9 +55,10 @@ import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./addr
|
|
|
55
55
|
import { AddressEntries } from "./address-values.mjs";
|
|
56
56
|
import { slugify } from "./content-slug.mjs";
|
|
57
57
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
58
|
-
import {
|
|
58
|
+
import { fitsKind } from "./data-keys.mjs";
|
|
59
|
+
import { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS } from "./standing-terms.mjs";
|
|
59
60
|
|
|
60
|
-
export { STANDING_BODY_TYPES, STANDING_KEYS };
|
|
61
|
+
export { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS };
|
|
61
62
|
|
|
62
63
|
/** Whether a value is a plain mapping. */
|
|
63
64
|
function mapping(value) {
|
|
@@ -232,31 +233,35 @@ export function standingsDigest(fm) {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
/**
|
|
235
|
-
*
|
|
236
|
+
* What a standing says before the body that confers it — `War Chief, Hárár
|
|
237
|
+
* (5), of ` — or `""` where no standing is held.
|
|
236
238
|
*
|
|
237
239
|
* The office leads, because it is the more particular of the two: a reader
|
|
238
|
-
* meeting "War Chief" has been told the rung as well. The
|
|
239
|
-
*
|
|
240
|
-
* no
|
|
240
|
+
* meeting "War Chief" has been told the rung as well. The rung follows as its
|
|
241
|
+
* title with its level in parentheses, read from the body's own ladder; where
|
|
242
|
+
* the ladder names no rung at that level, or none is available, it reads as
|
|
243
|
+
* `Rank 5` and the number is not repeated. The body closes the phrase, which is
|
|
244
|
+
* the whole point of keying the entry by it — a standing with no body named is
|
|
245
|
+
* the lossy form.
|
|
241
246
|
*
|
|
242
247
|
* No article is supplied. One body's name reads with a "the" in front of it and
|
|
243
248
|
* the next does not, and inventing one is this build editing the corpus.
|
|
244
249
|
*
|
|
245
250
|
* @param {object} standing - `{rank, office}`, either or both absent.
|
|
246
|
-
* @param {string} body - What the body is called.
|
|
247
251
|
* @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
|
|
248
|
-
* @returns {string} The
|
|
252
|
+
* @returns {string} The words before the body's name, ending `of `.
|
|
249
253
|
*/
|
|
250
|
-
export function
|
|
254
|
+
export function standingLead(standing, digest) {
|
|
251
255
|
const parts = [];
|
|
252
256
|
if (typeof standing?.office === "string" && standing.office.trim())
|
|
253
257
|
parts.push(standing.office.trim());
|
|
254
|
-
const
|
|
255
|
-
|
|
258
|
+
const rank = standing?.rank;
|
|
259
|
+
const level = typeof rank === "number" || typeof rank === "string" ? Number(rank) : NaN;
|
|
260
|
+
if (rank !== "" && Number.isFinite(level)) {
|
|
256
261
|
const title = digest?.ranks?.[String(level)];
|
|
257
|
-
parts.push(title ? title : `Rank ${level}`);
|
|
262
|
+
parts.push(title ? `${title} (${level})` : `Rank ${level}`);
|
|
258
263
|
}
|
|
259
|
-
return parts.length ? `${parts.join(", ")}, of
|
|
264
|
+
return parts.length ? `${parts.join(", ")}, of ` : "";
|
|
260
265
|
}
|
|
261
266
|
|
|
262
267
|
/**
|
|
@@ -267,7 +272,8 @@ export function standingPhrase(standing, body, digest) {
|
|
|
267
272
|
*
|
|
268
273
|
* 1. the value is a map keyed by Address (or the list this window still takes);
|
|
269
274
|
* 2. every key names an affiliation that resolves, once;
|
|
270
|
-
* 3. the entry states a
|
|
275
|
+
* 3. the entry states a standing at all — what a stated standing holds is the
|
|
276
|
+
* inner-key check's, read from {@link STANDING_FIELDS};
|
|
271
277
|
* 4. a `rank` is a level the named body's `governance.ranks` declares;
|
|
272
278
|
* 5. an `office` is a key of that body's `governance.offices`.
|
|
273
279
|
*
|
|
@@ -320,35 +326,20 @@ export function checkStandings(note, { index } = {}) {
|
|
|
320
326
|
const named = resolved.address ?? String(body);
|
|
321
327
|
// An entry with nothing after its key, and an emptied map arriving from
|
|
322
328
|
// the property editor as `[]`, say exactly what an authored `{}` says:
|
|
323
|
-
// this being belongs to this body, and no standing in it is stated.
|
|
329
|
+
// this being belongs to this body, and no standing in it is stated. The
|
|
330
|
+
// inner-key check reads a map and has nothing to walk in either, so the
|
|
331
|
+
// absence is reported here.
|
|
324
332
|
if (
|
|
325
333
|
standing === undefined ||
|
|
326
334
|
standing === null ||
|
|
327
335
|
(Array.isArray(standing) && standing.length === 0)
|
|
328
336
|
) {
|
|
329
|
-
findings.push(...absentRankFindings(note,
|
|
337
|
+
findings.push(...absentRankFindings(note, named, path));
|
|
330
338
|
continue;
|
|
331
339
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
message:
|
|
336
|
-
`\`data.affiliations\` entry for ${named} must hold that body's standing ` +
|
|
337
|
-
"as a map of `rank` and `office`, but reads " +
|
|
338
|
-
JSON.stringify(standing),
|
|
339
|
-
});
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
for (const key of Object.keys(standing)) {
|
|
343
|
-
if (STANDING_KEYS.includes(key)) continue;
|
|
344
|
-
findings.push({
|
|
345
|
-
...position(note, [...path, key], true),
|
|
346
|
-
message:
|
|
347
|
-
`\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
|
|
348
|
-
`standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
findings.push(...absentRankFindings(note, standing, named, path));
|
|
340
|
+
// A standing of the wrong shape, an undeclared key and a missing `rank`
|
|
341
|
+
// are the inner-key check's findings, made from `STANDING_FIELDS`.
|
|
342
|
+
if (!mapping(standing)) continue;
|
|
352
343
|
findings.push(...rankFindings(note, standing, named, path, resolved));
|
|
353
344
|
findings.push(...officeFindings(note, standing, named, path, resolved));
|
|
354
345
|
}
|
|
@@ -447,27 +438,22 @@ function bodyFindings(note, body, path, isKey, found) {
|
|
|
447
438
|
* @param {Array<string|number>} path - The entry's frontmatter path.
|
|
448
439
|
* @returns {object[]} The finding, or nothing.
|
|
449
440
|
*/
|
|
450
|
-
function absentRankFindings(note,
|
|
451
|
-
if (standing.rank !== undefined && standing.rank !== null) return [];
|
|
452
|
-
const office = typeof standing.office === "string" ? standing.office.trim() : "";
|
|
441
|
+
function absentRankFindings(note, body, path) {
|
|
453
442
|
return [
|
|
454
443
|
{
|
|
455
444
|
...position(note, path, true),
|
|
456
445
|
message:
|
|
457
|
-
`\`data.affiliations\` entry for ${body} states no \`rank
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
`distinguishes a person within a standing rather than standing in ` +
|
|
461
|
-
`for one`
|
|
462
|
-
: " — a being that belongs to a body holds some standing in it") +
|
|
463
|
-
". An ordinary member is `1`, and `0` is the rung for someone cast out",
|
|
446
|
+
`\`data.affiliations\` entry for ${body} states no \`rank\` — a being ` +
|
|
447
|
+
"that belongs to a body holds some standing in it. An ordinary member " +
|
|
448
|
+
"is `1`, and `0` is the rung for someone cast out",
|
|
464
449
|
},
|
|
465
450
|
];
|
|
466
451
|
}
|
|
467
452
|
|
|
468
453
|
/** Whether a `rank` is a rung the named body confers. */
|
|
469
454
|
function rankFindings(note, standing, body, path, resolved) {
|
|
470
|
-
|
|
455
|
+
// Absent, or not a whole number: the inner-key check's finding.
|
|
456
|
+
if (!fitsKind(standing.rank ?? null, "integer")) return [];
|
|
471
457
|
const at = position(note, [...path, "rank"]);
|
|
472
458
|
const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
|
|
473
459
|
if (typeof standing.rank !== "number" || !Number.isInteger(level))
|
|
@@ -504,9 +490,11 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
504
490
|
|
|
505
491
|
/** Whether an `office` is a post the named body names. */
|
|
506
492
|
function officeFindings(note, standing, body, path, resolved) {
|
|
507
|
-
|
|
493
|
+
// Absent is a membership with no post; a value that is not text is the
|
|
494
|
+
// inner-key check's finding.
|
|
495
|
+
if (typeof standing.office !== "string") return [];
|
|
508
496
|
const at = position(note, [...path, "office"]);
|
|
509
|
-
if (
|
|
497
|
+
if (!standing.office.trim())
|
|
510
498
|
return [
|
|
511
499
|
{
|
|
512
500
|
...at,
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -61,7 +61,13 @@ import { slugify } from "./content-slug.mjs";
|
|
|
61
61
|
// resolution: the same rule that names a page also names an anchor within it.
|
|
62
62
|
/** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
|
|
63
63
|
export { slugify };
|
|
64
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
authoredLabel,
|
|
66
|
+
WIKILINK,
|
|
67
|
+
isEventAnchor,
|
|
68
|
+
isSamePage,
|
|
69
|
+
parseWikilink,
|
|
70
|
+
} from "./wikilink-syntax.mjs";
|
|
65
71
|
import { resolveEmbeds } from "./content-embeds.mjs";
|
|
66
72
|
|
|
67
73
|
/**
|
|
@@ -424,6 +430,13 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
424
430
|
// Checked against this page's own anchors, exactly as a link into
|
|
425
431
|
// another page is: a self-link naming a section the page does not
|
|
426
432
|
// declare is as dead as one naming a section nowhere does.
|
|
433
|
+
if (isEventAnchor(ctx, anchor)) {
|
|
434
|
+
return report(
|
|
435
|
+
all,
|
|
436
|
+
{ target, reason: "event-anchor", anchor, addressed: true },
|
|
437
|
+
label ?? anchor,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
427
440
|
if (!hasAnchor(ctx.anchors, anchor)) {
|
|
428
441
|
return report(
|
|
429
442
|
all,
|
|
@@ -459,6 +472,13 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
459
472
|
// hash any slug into something that resolves, so an undeclared
|
|
460
473
|
// one otherwise joins onto the URL unchecked and dead-ends for
|
|
461
474
|
// the reader.
|
|
475
|
+
if (anchor && isEventAnchor(hit, anchor)) {
|
|
476
|
+
return report(
|
|
477
|
+
all,
|
|
478
|
+
{ target, reason: "event-anchor", anchor, addressed: true },
|
|
479
|
+
label ?? hit.name ?? target,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
462
482
|
if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
|
|
463
483
|
return report(
|
|
464
484
|
all,
|