@heroiclands/package-build 23.7.1 → 23.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +32 -0
- package/bin/content-commands.mjs +32 -10
- package/ci/check-banners.mjs +37 -19
- package/container.mjs +48 -10
- package/docs/api.md +402 -58
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/frontmatter.md +30 -1
- package/docs/authoring/links-and-markup.md +154 -56
- package/docs/commands.md +31 -12
- package/docs/content-format.md +25 -2
- package/docs/design.md +31 -0
- package/docs/diagnostics.md +1 -1
- package/docs/guides/e2e.md +37 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/project-setup.md +30 -15
- package/docs/reference/format-details.md +313 -132
- package/docs/reference/note-types.md +191 -174
- package/e2e.mjs +89 -2
- package/engine/anchors.mjs +7 -20
- package/engine/base-compiler.mjs +11 -18
- package/engine/compile-corpus.mjs +5 -3
- package/engine/content-alerts.mjs +4 -2
- package/engine/content-blocks.mjs +18 -36
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +447 -33
- package/engine/content-links.mjs +43 -15
- package/engine/content-poetry.mjs +9 -0
- package/engine/content-tables.mjs +8 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +24 -11
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/foundry-entries.mjs +21 -3
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/generate.mjs +12 -22
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +20 -12
- package/engine/infobox.mjs +3 -0
- package/engine/journals.mjs +15 -2
- package/engine/language-data.mjs +53 -0
- package/engine/language-terms.mjs +86 -0
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/markdownlint-run.mjs +12 -2
- package/engine/markup-validation.mjs +34 -0
- package/engine/note-addresses.mjs +1 -0
- package/engine/note-claims.mjs +21 -10
- package/engine/note-package.mjs +3 -5
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +36 -7
- package/engine/pdf-build.mjs +17 -10
- package/engine/prose-lint.mjs +12 -6
- package/engine/related-pages.mjs +22 -3
- package/engine/see-also.mjs +150 -0
- package/engine/site-build.mjs +21 -3
- package/engine/site-preview.mjs +4 -2
- package/engine/sql-tables.mjs +287 -53
- package/engine/standings.mjs +14 -12
- package/engine/stub-lint.mjs +6 -2
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/hugo-theme/layouts/partials/related.html +25 -8
- package/package.json +2 -1
- package/types/container.d.mts +4 -3
- package/types/e2e.d.mts +28 -0
- package/types/engine/base-compiler.d.mts +2 -2
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +85 -8
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/culture-reach.d.mts +22 -2
- package/types/engine/data-keys.d.mts +7 -0
- package/types/engine/diagnostics.d.mts +7 -6
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/foundry-entries.d.mts +3 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +11 -5
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +95 -0
- package/types/engine/markdownlint-run.d.mts +3 -1
- package/types/engine/markup-validation.d.mts +14 -0
- package/types/engine/note-addresses.d.mts +10 -0
- package/types/engine/note-claims.d.mts +7 -1
- package/types/engine/note-package.d.mts +3 -1
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/note-vocabulary.d.mts +1 -0
- package/types/engine/related-pages.d.mts +10 -1
- package/types/engine/see-also.d.mts +55 -0
- package/types/engine/site-build.d.mts +4 -1
- package/types/engine/sql-tables.d.mts +40 -6
- package/types/engine/word-rows.d.mts +9 -7
package/engine/sql-tables.mjs
CHANGED
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* @module
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
|
|
29
|
+
import { parseAddress, readCanonicalKey, renderAddress, isAddressTuple } from "./address.mjs";
|
|
30
|
+
import { collectAnchors } from "./anchors.mjs";
|
|
31
|
+
import { expandContentTables } from "./content-tables.mjs";
|
|
30
32
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
31
33
|
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
32
34
|
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
@@ -45,8 +47,10 @@ import {
|
|
|
45
47
|
} from "./word-rows.mjs";
|
|
46
48
|
import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
|
|
47
49
|
import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
|
|
50
|
+
import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
|
|
48
51
|
import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
|
|
49
52
|
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
53
|
+
import { lexicalSectionsFor } from "./lexical-sections.mjs";
|
|
50
54
|
import { eventViewSections } from "./event-views.mjs";
|
|
51
55
|
import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
|
|
52
56
|
import fs from "node:fs";
|
|
@@ -60,7 +64,7 @@ import { MARKET_CLASSES } from "./market-class.mjs";
|
|
|
60
64
|
import { parseMarkdownFile } from "./helpers.mjs";
|
|
61
65
|
import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
|
|
62
66
|
// The record accessors only — see `engine/index-records.mjs`.
|
|
63
|
-
import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
|
|
67
|
+
import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
|
|
64
68
|
|
|
65
69
|
/** Rendered in a cell whose value is absent. */
|
|
66
70
|
const EMPTY_CELL = "—";
|
|
@@ -261,16 +265,12 @@ export async function openNotesDatabase(
|
|
|
261
265
|
const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
|
|
262
266
|
fs.mkdirSync(base, { recursive: true });
|
|
263
267
|
const jsonl = path.join(base, "notes.jsonl");
|
|
264
|
-
|
|
265
|
-
jsonl,
|
|
266
|
-
records.map((record) => JSON.stringify(sortKeysDeep(encodeAddresses(record)))).join("\n"),
|
|
267
|
-
);
|
|
268
|
+
const encoded = records.map((record) => encodeAddresses(record));
|
|
268
269
|
|
|
269
270
|
const instance = await DuckDBInstance.create(":memory:");
|
|
270
271
|
const connection = await instance.connect();
|
|
271
272
|
await connection.run("SET threads=1");
|
|
272
|
-
await createRelations(connection, jsonl, "", audience);
|
|
273
|
-
const encoded = records.map((record) => encodeAddresses(record));
|
|
273
|
+
await createRelations(connection, jsonl, encoded, "", audience);
|
|
274
274
|
await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
|
|
275
275
|
await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
|
|
276
276
|
|
|
@@ -293,8 +293,7 @@ export async function openNotesDatabase(
|
|
|
293
293
|
.split("\n")
|
|
294
294
|
.filter((line) => line.trim())
|
|
295
295
|
.map((line) => flattenPublishedAddresses(JSON.parse(line)));
|
|
296
|
-
|
|
297
|
-
await createRelations(connection, flat, `${schema}.`, audience);
|
|
296
|
+
await createRelations(connection, flat, depRecords, `${schema}.`, audience);
|
|
298
297
|
await createEventRelation(
|
|
299
298
|
connection,
|
|
300
299
|
flat.replace(/\.jsonl$/, "-events.jsonl"),
|
|
@@ -462,12 +461,27 @@ const WITH_STUBS_SCHEMA = "__with_stubs";
|
|
|
462
461
|
* of the ladder falls back to a constant where its column is absent.
|
|
463
462
|
*
|
|
464
463
|
* @param {object} connection - An open DuckDB connection.
|
|
465
|
-
* @param {string} file -
|
|
464
|
+
* @param {string} file - Where to write the audience's JSONL.
|
|
465
|
+
* @param {object[]} records - Complete index records, every Address a string.
|
|
466
466
|
* @param {string} [prefix] - A schema to qualify the view names with.
|
|
467
|
+
* @param {"all"|"public"} [audience] - Whether to leave out GM notes.
|
|
467
468
|
* @returns {Promise<void>}
|
|
468
469
|
*/
|
|
469
|
-
async function createRelations(connection, file, prefix = "", audience = "all") {
|
|
470
|
-
|
|
470
|
+
async function createRelations(connection, file, records, prefix = "", audience = "all") {
|
|
471
|
+
// Every record contributes its schema; only visible records contribute rows.
|
|
472
|
+
fs.writeFileSync(
|
|
473
|
+
file,
|
|
474
|
+
records
|
|
475
|
+
.map((record) =>
|
|
476
|
+
JSON.stringify({
|
|
477
|
+
record: sortKeysDeep(record),
|
|
478
|
+
visible: audience !== "public" || !isGmNote(record),
|
|
479
|
+
}),
|
|
480
|
+
)
|
|
481
|
+
.join("\n"),
|
|
482
|
+
);
|
|
483
|
+
const source = readJsonAuto(file);
|
|
484
|
+
const read = records.length ? `SELECT record.* FROM (${source}) WHERE visible` : source;
|
|
471
485
|
const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
|
|
472
486
|
const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
|
|
473
487
|
// A row with no address is a stub. With no address column anywhere, every
|
|
@@ -481,16 +495,11 @@ async function createRelations(connection, file, prefix = "", audience = "all")
|
|
|
481
495
|
"COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
|
|
482
496
|
"OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
|
|
483
497
|
: "false";
|
|
484
|
-
const visible =
|
|
485
|
-
audience === "public" && columns.has("tags") ?
|
|
486
|
-
"NOT (COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'gm'), false) " +
|
|
487
|
-
"OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'gm', false))"
|
|
488
|
-
: "true";
|
|
489
498
|
await createTableFromFile(
|
|
490
499
|
connection,
|
|
491
500
|
`${prefix}entries`,
|
|
492
501
|
`SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
|
|
493
|
-
`ELSE 'full' END AS state FROM (${read})
|
|
502
|
+
`ELSE 'full' END AS state FROM (${read})`,
|
|
494
503
|
);
|
|
495
504
|
await connection.run(
|
|
496
505
|
`CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
|
|
@@ -617,20 +626,68 @@ async function createWordRelations(connection, stem, records, prefix, audience)
|
|
|
617
626
|
LINEAGE_COLUMNS,
|
|
618
627
|
);
|
|
619
628
|
const { dictionary, terms } = dictionaryAndTermRows(visible, records);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
629
|
+
const dictionaryTypes = {
|
|
630
|
+
etymology:
|
|
631
|
+
"STRUCT(kind VARCHAR, language VARCHAR, language_tag VARCHAR, form VARCHAR, " +
|
|
632
|
+
"parts STRUCT(form VARCHAR, gloss VARCHAR)[], root VARCHAR, frame VARCHAR)",
|
|
633
|
+
senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
|
|
634
|
+
usage: "VARCHAR[]",
|
|
635
|
+
depth: "BIGINT",
|
|
636
|
+
verbs: `STRUCT(invariant BOOLEAN, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
|
|
637
|
+
};
|
|
638
|
+
const dictionaryFile = `${stem}-dictionary.jsonl`;
|
|
639
|
+
if (dictionary.length) {
|
|
640
|
+
// Only the dictionary's declared schema: optional patterns must not
|
|
641
|
+
// change its nested/list types when a corpus has no stated grammar.
|
|
642
|
+
fs.writeFileSync(dictionaryFile, dictionary.map((row) => JSON.stringify(row)).join("\n"));
|
|
643
|
+
const columns = DICTIONARY_COLUMNS.map(
|
|
644
|
+
(column) =>
|
|
645
|
+
`CAST("${column}" AS ${dictionaryTypes[column] ?? "VARCHAR"}) AS "${column}"`,
|
|
646
|
+
);
|
|
647
|
+
await createTableFromFile(
|
|
648
|
+
connection,
|
|
649
|
+
`${prefix}dictionary`,
|
|
650
|
+
`SELECT ${columns.join(", ")} FROM (${readJsonAuto(dictionaryFile)})`,
|
|
651
|
+
);
|
|
652
|
+
} else {
|
|
653
|
+
await createRowRelation(
|
|
654
|
+
connection,
|
|
655
|
+
dictionaryFile,
|
|
656
|
+
dictionary,
|
|
657
|
+
`${prefix}dictionary`,
|
|
658
|
+
DICTIONARY_COLUMNS,
|
|
659
|
+
dictionaryTypes,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const termTypes = {
|
|
663
|
+
senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
|
|
664
|
+
usage: "VARCHAR[]",
|
|
665
|
+
common: "BOOLEAN",
|
|
666
|
+
origins: "VARCHAR[]",
|
|
667
|
+
cultures: "VARCHAR[]",
|
|
668
|
+
};
|
|
669
|
+
const termsFile = `${stem}-terms.jsonl`;
|
|
670
|
+
if (terms.length) {
|
|
671
|
+
// Terms retain declared types even when every value of a column is null.
|
|
672
|
+
fs.writeFileSync(termsFile, terms.map((row) => JSON.stringify(row)).join("\n"));
|
|
673
|
+
const columns = TERM_COLUMNS.map(
|
|
674
|
+
(column) => `CAST("${column}" AS ${termTypes[column] ?? "VARCHAR"}) AS "${column}"`,
|
|
675
|
+
);
|
|
676
|
+
await createTableFromFile(
|
|
677
|
+
connection,
|
|
678
|
+
`${prefix}terms`,
|
|
679
|
+
`SELECT ${columns.join(", ")} FROM (${readJsonAuto(termsFile)})`,
|
|
680
|
+
);
|
|
681
|
+
} else {
|
|
682
|
+
await createRowRelation(
|
|
683
|
+
connection,
|
|
684
|
+
termsFile,
|
|
685
|
+
terms,
|
|
686
|
+
`${prefix}terms`,
|
|
687
|
+
TERM_COLUMNS,
|
|
688
|
+
termTypes,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
634
691
|
for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
|
|
635
692
|
await createRowRelation(
|
|
636
693
|
connection,
|
|
@@ -879,8 +936,9 @@ export function renderSqlTable(
|
|
|
879
936
|
* its own table and not the whole build's report.
|
|
880
937
|
*
|
|
881
938
|
* @param {object} db - From {@link openNotesDatabase}.
|
|
882
|
-
* @param {Array<{source: string, markdown: string}>} sources -
|
|
883
|
-
* scan.
|
|
939
|
+
* @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
|
|
940
|
+
* The bodies to scan. `joinGeneratedSections` records the number of authored
|
|
941
|
+
* fences; following generated event fences need no stub comparison.
|
|
884
942
|
* @param {object} [opts]
|
|
885
943
|
* @param {(ref: string) => boolean} [opts.linkable] - Passed to
|
|
886
944
|
* {@link renderSqlTable}.
|
|
@@ -904,26 +962,33 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
904
962
|
const prepared = new Map();
|
|
905
963
|
if (records) prepared.pageLists = preparePageLists(records, sources);
|
|
906
964
|
// The corpus does not change while a preparation runs, so a query written
|
|
907
|
-
// twice has one answer
|
|
908
|
-
//
|
|
965
|
+
// twice has one answer in each stub context. The normal result and the
|
|
966
|
+
// inclusive comparison occupy separate fields of the same cache entry.
|
|
967
|
+
// Each result use gets a copy because a caller rewrites the rows.
|
|
909
968
|
const answers = new Map();
|
|
910
969
|
const reader = {
|
|
911
970
|
...db,
|
|
912
971
|
async query(sql) {
|
|
913
|
-
if (!answers.has(sql)) answers.set(sql, await db.query(sql));
|
|
914
|
-
return structuredClone(answers.get(sql));
|
|
972
|
+
if (!answers.has(sql)) answers.set(sql, { result: await db.query(sql) });
|
|
973
|
+
return structuredClone(answers.get(sql).result);
|
|
974
|
+
},
|
|
975
|
+
async stubsExcluded(sql, rows) {
|
|
976
|
+
const answer = answers.get(sql);
|
|
977
|
+
if (!Object.hasOwn(answer, "excluded"))
|
|
978
|
+
answer.excluded = await db.stubsExcluded?.(sql, rows);
|
|
979
|
+
return answer.excluded;
|
|
915
980
|
},
|
|
916
981
|
};
|
|
917
982
|
let labels;
|
|
918
983
|
const labelOf = (address) => labels.get(address);
|
|
919
984
|
const languageOf =
|
|
920
985
|
pageLanguage ?? ((_source, frontmatter) => frontmatterLanguage(frontmatter, db));
|
|
921
|
-
for (const { source, markdown, frontmatter } of sources) {
|
|
986
|
+
for (const { source, markdown, frontmatter, authoredSqlBlocks = Infinity } of sources) {
|
|
922
987
|
const blocks = findSqlBlocks(markdown);
|
|
923
988
|
if (!blocks.length) continue;
|
|
924
989
|
const forNote = [];
|
|
925
990
|
prepared.set(source, forNote);
|
|
926
|
-
for (const block of blocks) {
|
|
991
|
+
for (const [ordinal, block] of blocks.entries()) {
|
|
927
992
|
try {
|
|
928
993
|
if (block.problems.length) throw new Error(block.problems.join("; "));
|
|
929
994
|
const result = await runSqlQuery(reader, block.query);
|
|
@@ -945,6 +1010,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
945
1010
|
labelOf,
|
|
946
1011
|
showKey: block.showKey,
|
|
947
1012
|
pageLanguage: languageOf(source, frontmatter),
|
|
1013
|
+
pageOwner:
|
|
1014
|
+
(
|
|
1015
|
+
typeof frontmatter?.type === "string" &&
|
|
1016
|
+
typeof frontmatter.shortcode === "string" &&
|
|
1017
|
+
db.addressContext?.package
|
|
1018
|
+
) ?
|
|
1019
|
+
renderAddress({
|
|
1020
|
+
package: db.addressContext.package,
|
|
1021
|
+
system: "note",
|
|
1022
|
+
type: frontmatter.type,
|
|
1023
|
+
shortcode: frontmatter.shortcode,
|
|
1024
|
+
})
|
|
1025
|
+
: undefined,
|
|
1026
|
+
entryAnchors: false,
|
|
948
1027
|
})
|
|
949
1028
|
: renderSqlTable(result, options),
|
|
950
1029
|
rows: result.rows.length,
|
|
@@ -952,7 +1031,12 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
952
1031
|
// relation, so a fence that has not adopted stubs says so
|
|
953
1032
|
// rather than quietly listing fewer settlements than the
|
|
954
1033
|
// region has.
|
|
955
|
-
|
|
1034
|
+
// Generated event views read entries and events, whose
|
|
1035
|
+
// meaning is identical in both stub contexts.
|
|
1036
|
+
stubsExcluded:
|
|
1037
|
+
ordinal >= authoredSqlBlocks ? 0 : (
|
|
1038
|
+
await reader.stubsExcluded(block.query, result.rows.length)
|
|
1039
|
+
),
|
|
956
1040
|
allowEmpty: block.allowEmpty,
|
|
957
1041
|
});
|
|
958
1042
|
} catch (err) {
|
|
@@ -1095,8 +1179,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
1095
1179
|
* @param {Map<string, Map<string, string>>} [opts.derived] - The derived
|
|
1096
1180
|
* lists, from an earlier call over the same records, which are not derived
|
|
1097
1181
|
* again.
|
|
1182
|
+
* @param {object[]} [opts.all=records] - Complete records for lexical lineage
|
|
1183
|
+
* traversal through providers outside the surface's audience.
|
|
1098
1184
|
* @returns {Promise<{sections: Map<string, string>, views: Set<string>,
|
|
1099
|
-
* derived: Map<string, Map<string, string
|
|
1185
|
+
* derived: Map<string, Map<string, string>>,
|
|
1186
|
+
* lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}>} Note file to its sections,
|
|
1100
1187
|
* joined in order; the files whose sections hold an event view's fence; and
|
|
1101
1188
|
* the derived lists alone.
|
|
1102
1189
|
*/
|
|
@@ -1104,7 +1191,7 @@ export async function generatedSectionsFor(
|
|
|
1104
1191
|
db,
|
|
1105
1192
|
records,
|
|
1106
1193
|
contentBase,
|
|
1107
|
-
{ only, foreignIndex, config, derived: given } = {},
|
|
1194
|
+
{ only, foreignIndex, config, derived: given, all = records } = {},
|
|
1108
1195
|
) {
|
|
1109
1196
|
const notes = records.filter((record) => isNoteRecord(record));
|
|
1110
1197
|
const fileOf = (record) => noteFile(contentBase, record);
|
|
@@ -1122,7 +1209,26 @@ export async function generatedSectionsFor(
|
|
|
1122
1209
|
const sections = new Map();
|
|
1123
1210
|
for (const file of new Set([...derived.keys(), ...views.keys()]))
|
|
1124
1211
|
sections.set(file, joinSections([derived.get(file), views.get(file)]));
|
|
1125
|
-
|
|
1212
|
+
// Automatic sections follow their source's audience even in a Foundry
|
|
1213
|
+
// corpus containing both public and private packs. Explicit SQL keeps
|
|
1214
|
+
// the database audience the caller requested.
|
|
1215
|
+
const publicRecords = records.filter((record) => !isGmNote(record));
|
|
1216
|
+
const publicOwners = lexicalSectionsFor(publicRecords, all);
|
|
1217
|
+
const allOwners =
|
|
1218
|
+
publicRecords.length === records.length ? publicOwners : lexicalSectionsFor(records, all);
|
|
1219
|
+
const lexical = new Map();
|
|
1220
|
+
for (const note of notes) {
|
|
1221
|
+
const owners = isGmNote(note) ? allOwners : publicOwners;
|
|
1222
|
+
const key = renderAddress({
|
|
1223
|
+
package: note.package,
|
|
1224
|
+
system: "note",
|
|
1225
|
+
type: note.type,
|
|
1226
|
+
shortcode: note.shortcode,
|
|
1227
|
+
});
|
|
1228
|
+
if (owners.has(key) && (only === undefined || fileOf(note) === only))
|
|
1229
|
+
lexical.set(fileOf(note), owners.get(key));
|
|
1230
|
+
}
|
|
1231
|
+
return { sections, views: new Set(views.keys()), derived, lexical };
|
|
1126
1232
|
}
|
|
1127
1233
|
|
|
1128
1234
|
/**
|
|
@@ -1138,7 +1244,7 @@ export async function generatedSectionsFor(
|
|
|
1138
1244
|
*
|
|
1139
1245
|
* @param {{sections: Map<string, string>, views: Set<string>}} generated -
|
|
1140
1246
|
* From {@link generatedSectionsFor}.
|
|
1141
|
-
* @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
|
|
1247
|
+
* @param {Array<{source: string, markdown: string, frontmatter?: object, authoredSqlBlocks?: number}>} sources -
|
|
1142
1248
|
* The bodies to answer, extended in place.
|
|
1143
1249
|
* @param {Map<string, {body: string, frontmatter: object|null}>} [parsed] -
|
|
1144
1250
|
* Notes already parsed, keyed by file; a note absent from it is read.
|
|
@@ -1148,6 +1254,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1148
1254
|
for (const [absPath, text] of generated.sections) {
|
|
1149
1255
|
const source = sources.find((one) => one.source === absPath);
|
|
1150
1256
|
if (source) {
|
|
1257
|
+
source.authoredSqlBlocks ??= findSqlBlocks(source.markdown).length;
|
|
1151
1258
|
source.markdown = appendGeneratedSections(source.markdown, text);
|
|
1152
1259
|
continue;
|
|
1153
1260
|
}
|
|
@@ -1157,6 +1264,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1157
1264
|
source: absPath,
|
|
1158
1265
|
markdown: appendGeneratedSections(body, text),
|
|
1159
1266
|
frontmatter,
|
|
1267
|
+
authoredSqlBlocks: findSqlBlocks(body).length,
|
|
1160
1268
|
});
|
|
1161
1269
|
}
|
|
1162
1270
|
}
|
|
@@ -1167,7 +1275,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1167
1275
|
* empty set carrying them.
|
|
1168
1276
|
*
|
|
1169
1277
|
* @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
|
|
1170
|
-
* @param {{sections: Map<string, string>}} generated - From
|
|
1278
|
+
* @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
|
|
1171
1279
|
* {@link generatedSectionsFor}.
|
|
1172
1280
|
* @returns {void}
|
|
1173
1281
|
*/
|
|
@@ -1177,6 +1285,51 @@ export function attachGeneratedSections(prepared, generated) {
|
|
|
1177
1285
|
if (!results) prepared.set(absPath, (results = []));
|
|
1178
1286
|
results.generated = text;
|
|
1179
1287
|
}
|
|
1288
|
+
for (const [absPath, sections] of generated.lexical ?? []) {
|
|
1289
|
+
let results = prepared.get(absPath);
|
|
1290
|
+
if (!results) prepared.set(absPath, (results = []));
|
|
1291
|
+
results.lexical = sections;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Index the anchors the prepared bodies actually emit, including anchors in
|
|
1297
|
+
* SQL results. Foundry maps use the same expanded body the compiler splits.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {Map<string, object[]>} prepared - The prepared directives.
|
|
1300
|
+
* @param {object[]} records - The visible content-index records.
|
|
1301
|
+
* @param {string} contentBase - Root of the content tree.
|
|
1302
|
+
* @param {Map<string, object>} parsed - Notes already parsed.
|
|
1303
|
+
* @param {object} [config] - The resolved build configuration.
|
|
1304
|
+
* @returns {Promise<void>}
|
|
1305
|
+
*/
|
|
1306
|
+
async function attachPreparedAnchors(prepared, records, contentBase, parsed, config) {
|
|
1307
|
+
const { entriesForNote, foundryIdentities } = await import("./foundry-entries.mjs");
|
|
1308
|
+
const identities = config ? foundryIdentities(config) : undefined;
|
|
1309
|
+
for (const record of records) {
|
|
1310
|
+
if (!isNoteRecord(record)) continue;
|
|
1311
|
+
const file = noteFile(contentBase, record);
|
|
1312
|
+
const results = prepared.get(file);
|
|
1313
|
+
if (!results) continue;
|
|
1314
|
+
const { body } = parsed.get(file) ?? parseMarkdownFile(file);
|
|
1315
|
+
const expanded = expandContentTables(body, {
|
|
1316
|
+
source: file,
|
|
1317
|
+
sqlTables: results,
|
|
1318
|
+
pageLists: prepared.pageLists?.get(file),
|
|
1319
|
+
}).markdown;
|
|
1320
|
+
results.anchors = collectAnchors(expanded);
|
|
1321
|
+
if (!identities || !record.address?.slug) continue;
|
|
1322
|
+
const frontmatter = authoredFrontmatter(record);
|
|
1323
|
+
const entries = entriesForNote(
|
|
1324
|
+
frontmatter,
|
|
1325
|
+
frontmatter.name?.full ?? record.file.name,
|
|
1326
|
+
record.address.slug,
|
|
1327
|
+
expanded,
|
|
1328
|
+
identities,
|
|
1329
|
+
);
|
|
1330
|
+
results.anchorUuids = entries[0]?.anchors;
|
|
1331
|
+
results.docAnchorUuids = entries[1]?.anchors;
|
|
1332
|
+
}
|
|
1180
1333
|
}
|
|
1181
1334
|
|
|
1182
1335
|
/**
|
|
@@ -1290,9 +1443,9 @@ export async function prepareTreeSqlTables(
|
|
|
1290
1443
|
// uninitialised for whichever module the runtime happened to load first.
|
|
1291
1444
|
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1292
1445
|
const notes = parsed ?? new Map();
|
|
1293
|
-
const
|
|
1294
|
-
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
|
|
1295
|
-
|
|
1446
|
+
const allRecords =
|
|
1447
|
+
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
|
|
1448
|
+
const indexRecords = allRecords.filter((record) => audience !== "public" || !isGmNote(record));
|
|
1296
1449
|
|
|
1297
1450
|
// Which notes carry a directive, discovered over the same corpus every
|
|
1298
1451
|
// other pass reads rather than over a walk of this one's own. The
|
|
@@ -1339,8 +1492,10 @@ export async function prepareTreeSqlTables(
|
|
|
1339
1492
|
const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
|
|
1340
1493
|
foreignIndex: foreign,
|
|
1341
1494
|
config,
|
|
1495
|
+
all: allRecords,
|
|
1342
1496
|
});
|
|
1343
|
-
if (!sources.length && !hasEvents && !derivedOnly.sections.size
|
|
1497
|
+
if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
|
|
1498
|
+
return undefined;
|
|
1344
1499
|
|
|
1345
1500
|
// A page list is a filter over the records, so it needs no database. A tree
|
|
1346
1501
|
// whose only corpus-reading directive is one therefore opens none — which
|
|
@@ -1359,6 +1514,7 @@ export async function prepareTreeSqlTables(
|
|
|
1359
1514
|
pageOnly.inline = new Map();
|
|
1360
1515
|
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
1361
1516
|
attachGeneratedSections(pageOnly, derivedOnly);
|
|
1517
|
+
await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config);
|
|
1362
1518
|
return pageOnly;
|
|
1363
1519
|
}
|
|
1364
1520
|
// A cell links only where the address it would emit resolves, so a table
|
|
@@ -1373,7 +1529,11 @@ export async function prepareTreeSqlTables(
|
|
|
1373
1529
|
.filter((record) => !record.documents)
|
|
1374
1530
|
.map((record) => record.documentation ?? record.address?.canonical)
|
|
1375
1531
|
.map((target) => target && encodeAddresses(target))
|
|
1376
|
-
.filter(Boolean)
|
|
1532
|
+
.filter(Boolean)
|
|
1533
|
+
.flatMap((target) => [
|
|
1534
|
+
target,
|
|
1535
|
+
renderAddress({ ...readCanonicalKey(target), system: "note" }),
|
|
1536
|
+
]),
|
|
1377
1537
|
);
|
|
1378
1538
|
// Each declared dependency's published index, attached as its own schema so
|
|
1379
1539
|
// a table can read `FROM <package>.notes`. Imported here for the
|
|
@@ -1382,13 +1542,14 @@ export async function prepareTreeSqlTables(
|
|
|
1382
1542
|
// been fetched already fails earlier with a message naming the fetch.
|
|
1383
1543
|
const db =
|
|
1384
1544
|
database ??
|
|
1385
|
-
(await openNotesDatabase(
|
|
1545
|
+
(await openNotesDatabase(allRecords, {
|
|
1386
1546
|
dependencies: await cachedDependencies(config),
|
|
1387
1547
|
audience,
|
|
1388
1548
|
}));
|
|
1389
1549
|
try {
|
|
1390
1550
|
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
|
1391
1551
|
derived: derivedOnly.derived,
|
|
1552
|
+
all: allRecords,
|
|
1392
1553
|
});
|
|
1393
1554
|
joinGeneratedSections(generated, sources, notes);
|
|
1394
1555
|
const prepared = await prepareSqlTables(db, sources, {
|
|
@@ -1398,8 +1559,81 @@ export async function prepareTreeSqlTables(
|
|
|
1398
1559
|
});
|
|
1399
1560
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
1400
1561
|
attachGeneratedSections(prepared, generated);
|
|
1562
|
+
await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config);
|
|
1401
1563
|
return prepared;
|
|
1402
1564
|
} finally {
|
|
1403
1565
|
if (!database) await db.close();
|
|
1404
1566
|
}
|
|
1405
1567
|
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Prepare authored link-check tables in each source note's audience. Public
|
|
1571
|
+
* sources share one public database; GM sources share one complete database.
|
|
1572
|
+
* Complete records remain available for authored-link checks.
|
|
1573
|
+
*
|
|
1574
|
+
* @param {string} contentBase - Root of the content tree.
|
|
1575
|
+
* @param {object} [opts]
|
|
1576
|
+
* @param {object} [opts.config] - Resolved configuration.
|
|
1577
|
+
* @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
|
|
1578
|
+
* @param {object[]} [opts.records] - Complete index records already derived.
|
|
1579
|
+
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] - Parsed notes.
|
|
1580
|
+
* @param {Map<string, object>} [opts.foreignIndex] - Published dependency indexes.
|
|
1581
|
+
* @returns {Promise<Map<string, object[]>|undefined>} Prepared tables by source.
|
|
1582
|
+
*/
|
|
1583
|
+
export async function prepareLinkSqlTables(
|
|
1584
|
+
contentBase,
|
|
1585
|
+
{ config, skipDirectories, records, parsed, foreignIndex } = {},
|
|
1586
|
+
) {
|
|
1587
|
+
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1588
|
+
const notes = parsed ?? new Map();
|
|
1589
|
+
const allRecords =
|
|
1590
|
+
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
|
|
1591
|
+
const sources = new Map();
|
|
1592
|
+
const lexicalOwners = lexicalSectionsFor(allRecords);
|
|
1593
|
+
for (const record of allRecords) {
|
|
1594
|
+
if (!isNoteRecord(record)) continue;
|
|
1595
|
+
const file = noteFile(contentBase, record);
|
|
1596
|
+
let note = notes.get(file);
|
|
1597
|
+
if (!note) {
|
|
1598
|
+
note = parseMarkdownFile(file);
|
|
1599
|
+
notes.set(file, note);
|
|
1600
|
+
}
|
|
1601
|
+
const body = note.body ?? "";
|
|
1602
|
+
if (
|
|
1603
|
+
findSqlBlocks(body).length ||
|
|
1604
|
+
findPageListBlocks(body).length ||
|
|
1605
|
+
sqlQueriesInMarkdown(body, note.frontmatter).length ||
|
|
1606
|
+
lexicalOwners.has(
|
|
1607
|
+
renderAddress({
|
|
1608
|
+
package: record.package,
|
|
1609
|
+
system: "note",
|
|
1610
|
+
type: record.type,
|
|
1611
|
+
shortcode: record.shortcode,
|
|
1612
|
+
}),
|
|
1613
|
+
)
|
|
1614
|
+
)
|
|
1615
|
+
sources.set(file, isGmNote(record) ? "all" : "public");
|
|
1616
|
+
}
|
|
1617
|
+
if (!sources.size) return undefined;
|
|
1618
|
+
const selected = new Map();
|
|
1619
|
+
selected.pageLists = new Map();
|
|
1620
|
+
selected.inline = new Map();
|
|
1621
|
+
for (const audience of new Set(sources.values())) {
|
|
1622
|
+
const prepared = await prepareTreeSqlTables(contentBase, {
|
|
1623
|
+
config,
|
|
1624
|
+
skipDirectories,
|
|
1625
|
+
records: allRecords,
|
|
1626
|
+
parsed: notes,
|
|
1627
|
+
foreignIndex,
|
|
1628
|
+
audience,
|
|
1629
|
+
});
|
|
1630
|
+
for (const [file, sourceAudience] of sources) {
|
|
1631
|
+
if (sourceAudience !== audience) continue;
|
|
1632
|
+
if (prepared?.has(file)) selected.set(file, prepared.get(file));
|
|
1633
|
+
if (prepared?.pageLists?.has(file))
|
|
1634
|
+
selected.pageLists.set(file, prepared.pageLists.get(file));
|
|
1635
|
+
if (prepared?.inline?.has(file)) selected.inline.set(file, prepared.inline.get(file));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return selected;
|
|
1639
|
+
}
|
package/engine/standings.mjs
CHANGED
|
@@ -378,17 +378,19 @@ function resolveBody(note, index, body) {
|
|
|
378
378
|
|
|
379
379
|
/** Findings about the body an entry names. */
|
|
380
380
|
function bodyFindings(note, body, path, isKey, found) {
|
|
381
|
-
const at = position(note, path, isKey);
|
|
381
|
+
const at = () => position(note, path, isKey);
|
|
382
382
|
const written = isAddressTuple(body) ? renderAddress(body) : String(body);
|
|
383
383
|
const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
|
|
384
384
|
if (found.reason)
|
|
385
|
-
return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
|
|
385
|
+
return [{ ...at(), message: `${label} is not a complete Address (${found.reason})` }];
|
|
386
386
|
if (found.type)
|
|
387
|
-
return [
|
|
387
|
+
return [
|
|
388
|
+
{ ...at(), message: `\`data.affiliations\` accepts affiliation, not ${found.type}` },
|
|
389
|
+
];
|
|
388
390
|
if (found.missing)
|
|
389
391
|
return [
|
|
390
392
|
{
|
|
391
|
-
...at,
|
|
393
|
+
...at(),
|
|
392
394
|
message:
|
|
393
395
|
`\`data.affiliations\` names ${found.address}, which does not resolve ` +
|
|
394
396
|
"in this package or its declared dependencies",
|
|
@@ -438,12 +440,12 @@ function absentRankFindings(note, body, path) {
|
|
|
438
440
|
function rankFindings(note, standing, body, path, resolved) {
|
|
439
441
|
// Absent, or not a whole number: the inner-key check's finding.
|
|
440
442
|
if (!fitsKind(standing.rank ?? null, "integer")) return [];
|
|
441
|
-
const at = position(note, [...path, "rank"]);
|
|
443
|
+
const at = () => position(note, [...path, "rank"]);
|
|
442
444
|
const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
|
|
443
445
|
if (typeof standing.rank !== "number" || !Number.isInteger(level))
|
|
444
446
|
return [
|
|
445
447
|
{
|
|
446
|
-
...at,
|
|
448
|
+
...at(),
|
|
447
449
|
message:
|
|
448
450
|
`\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
|
|
449
451
|
`ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
|
|
@@ -455,7 +457,7 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
455
457
|
if (!ladder.size)
|
|
456
458
|
return [
|
|
457
459
|
{
|
|
458
|
-
...at,
|
|
460
|
+
...at(),
|
|
459
461
|
message:
|
|
460
462
|
`\`data.affiliations\` entry for ${body} states a rung, and that body ` +
|
|
461
463
|
"declares no `data.governance.ranks` ladder to hold one",
|
|
@@ -464,7 +466,7 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
464
466
|
const levels = [...ladder.keys()].sort((a, b) => a - b);
|
|
465
467
|
return [
|
|
466
468
|
{
|
|
467
|
-
...at,
|
|
469
|
+
...at(),
|
|
468
470
|
message:
|
|
469
471
|
`\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
|
|
470
472
|
`${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
|
|
@@ -477,11 +479,11 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
477
479
|
// Absent is a membership with no post; a value that is not text is the
|
|
478
480
|
// inner-key check's finding.
|
|
479
481
|
if (typeof standing.office !== "string") return [];
|
|
480
|
-
const at = position(note, [...path, "office"]);
|
|
482
|
+
const at = () => position(note, [...path, "office"]);
|
|
481
483
|
if (!standing.office.trim())
|
|
482
484
|
return [
|
|
483
485
|
{
|
|
484
|
-
...at,
|
|
486
|
+
...at(),
|
|
485
487
|
message:
|
|
486
488
|
`\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
|
|
487
489
|
`\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
|
|
@@ -493,7 +495,7 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
493
495
|
if (!roster.size)
|
|
494
496
|
return [
|
|
495
497
|
{
|
|
496
|
-
...at,
|
|
498
|
+
...at(),
|
|
497
499
|
message:
|
|
498
500
|
`\`data.affiliations\` entry for ${body} names an office, and that body ` +
|
|
499
501
|
`${resolved.address} declares no \`data.governance.offices\` to hold one`,
|
|
@@ -501,7 +503,7 @@ function officeFindings(note, standing, body, path, resolved) {
|
|
|
501
503
|
];
|
|
502
504
|
return [
|
|
503
505
|
{
|
|
504
|
-
...at,
|
|
506
|
+
...at(),
|
|
505
507
|
message:
|
|
506
508
|
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
|
|
507
509
|
`an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
|
package/engine/stub-lint.mjs
CHANGED
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Every typed note needs
|
|
15
|
+
* Every typed note needs content: an authored body or automatic lexical data.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Without automatic lexical data, a missing or whitespace-only body is an error, whether or not the note has
|
|
18
18
|
* a `draft` tag or a frontmatter description. Placeholder-only bodies remain
|
|
19
19
|
* errors. A nonempty body below twenty-five words receives a warning when it
|
|
20
20
|
* is not tagged `draft`; the author decides whether that short note is done.
|
|
21
21
|
* A `folder` note is held only to the first rule: any nonempty body passes,
|
|
22
22
|
* with or without `draft`.
|
|
23
|
+
* A language's nonempty `words`, or a culture's or affiliation's nonempty
|
|
24
|
+
* `glossary`, supplies an automatic section and needs no authored prose.
|
|
23
25
|
*
|
|
24
26
|
* Counts and the oldest drafts remain reports. Classification and indexing
|
|
25
27
|
* still derive full, draft and stub states from the existing note records;
|
|
@@ -36,6 +38,7 @@ import { positionInFrontmatter, positionOfLiteral } from "./diagnostics.mjs";
|
|
|
36
38
|
import { parseMarkdownFile } from "./helpers.mjs";
|
|
37
39
|
import { bodyWordCount, placeholderBody } from "./note-state.mjs";
|
|
38
40
|
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
41
|
+
import { hasLexicalEntries } from "./lexical-sections.mjs";
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* The word count below which an unmarked body is asked whether it is a draft.
|
|
@@ -62,6 +65,7 @@ function checkNote({ fm, body, file, raw }) {
|
|
|
62
65
|
const at = (key, value) => positionInFrontmatter(raw, key, value, { topLevel: true });
|
|
63
66
|
|
|
64
67
|
if (String(body ?? "").trim() === "") {
|
|
68
|
+
if (hasLexicalEntries(fm)) return findings;
|
|
65
69
|
findings.push({
|
|
66
70
|
file,
|
|
67
71
|
...at("type", fm.type),
|