@heroiclands/package-build 23.8.0 → 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 +27 -0
- package/bin/content-commands.mjs +12 -4
- package/docs/api.md +328 -28
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +140 -56
- package/docs/commands.md +19 -10
- package/docs/content-format.md +2 -2
- package/docs/diagnostics.md +1 -1
- package/docs/guides/packs.md +2 -1
- package/docs/guides/reference-pages.md +86 -63
- package/docs/reference/format-details.md +269 -123
- package/docs/reference/note-types.md +184 -170
- package/engine/anchors.mjs +7 -20
- package/engine/compile-corpus.mjs +3 -2
- package/engine/content-format.yaml +1 -0
- package/engine/content-index.mjs +412 -11
- package/engine/content-links.mjs +43 -15
- package/engine/content-tables.mjs +8 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/foundry-entries.mjs +21 -3
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +12 -6
- package/engine/journals.mjs +4 -1
- 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/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/pdf-build.mjs +9 -9
- package/engine/site-build.mjs +16 -2
- package/engine/sql-tables.mjs +262 -43
- 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/package.json +1 -1
- package/types/engine/content-index.d.mts +65 -1
- 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/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 +4 -1
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +95 -0
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/site-build.d.mts +4 -1
- package/types/engine/sql-tables.d.mts +33 -3
- 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,
|
|
@@ -953,6 +1010,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
953
1010
|
labelOf,
|
|
954
1011
|
showKey: block.showKey,
|
|
955
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,
|
|
956
1027
|
})
|
|
957
1028
|
: renderSqlTable(result, options),
|
|
958
1029
|
rows: result.rows.length,
|
|
@@ -1108,8 +1179,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
1108
1179
|
* @param {Map<string, Map<string, string>>} [opts.derived] - The derived
|
|
1109
1180
|
* lists, from an earlier call over the same records, which are not derived
|
|
1110
1181
|
* again.
|
|
1182
|
+
* @param {object[]} [opts.all=records] - Complete records for lexical lineage
|
|
1183
|
+
* traversal through providers outside the surface's audience.
|
|
1111
1184
|
* @returns {Promise<{sections: Map<string, string>, views: Set<string>,
|
|
1112
|
-
* 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,
|
|
1113
1187
|
* joined in order; the files whose sections hold an event view's fence; and
|
|
1114
1188
|
* the derived lists alone.
|
|
1115
1189
|
*/
|
|
@@ -1117,7 +1191,7 @@ export async function generatedSectionsFor(
|
|
|
1117
1191
|
db,
|
|
1118
1192
|
records,
|
|
1119
1193
|
contentBase,
|
|
1120
|
-
{ only, foreignIndex, config, derived: given } = {},
|
|
1194
|
+
{ only, foreignIndex, config, derived: given, all = records } = {},
|
|
1121
1195
|
) {
|
|
1122
1196
|
const notes = records.filter((record) => isNoteRecord(record));
|
|
1123
1197
|
const fileOf = (record) => noteFile(contentBase, record);
|
|
@@ -1135,7 +1209,26 @@ export async function generatedSectionsFor(
|
|
|
1135
1209
|
const sections = new Map();
|
|
1136
1210
|
for (const file of new Set([...derived.keys(), ...views.keys()]))
|
|
1137
1211
|
sections.set(file, joinSections([derived.get(file), views.get(file)]));
|
|
1138
|
-
|
|
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 };
|
|
1139
1232
|
}
|
|
1140
1233
|
|
|
1141
1234
|
/**
|
|
@@ -1182,7 +1275,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1182
1275
|
* empty set carrying them.
|
|
1183
1276
|
*
|
|
1184
1277
|
* @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
|
|
1185
|
-
* @param {{sections: Map<string, string>}} generated - From
|
|
1278
|
+
* @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
|
|
1186
1279
|
* {@link generatedSectionsFor}.
|
|
1187
1280
|
* @returns {void}
|
|
1188
1281
|
*/
|
|
@@ -1192,6 +1285,51 @@ export function attachGeneratedSections(prepared, generated) {
|
|
|
1192
1285
|
if (!results) prepared.set(absPath, (results = []));
|
|
1193
1286
|
results.generated = text;
|
|
1194
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
|
+
}
|
|
1195
1333
|
}
|
|
1196
1334
|
|
|
1197
1335
|
/**
|
|
@@ -1305,9 +1443,9 @@ export async function prepareTreeSqlTables(
|
|
|
1305
1443
|
// uninitialised for whichever module the runtime happened to load first.
|
|
1306
1444
|
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1307
1445
|
const notes = parsed ?? new Map();
|
|
1308
|
-
const
|
|
1309
|
-
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
|
|
1310
|
-
|
|
1446
|
+
const allRecords =
|
|
1447
|
+
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
|
|
1448
|
+
const indexRecords = allRecords.filter((record) => audience !== "public" || !isGmNote(record));
|
|
1311
1449
|
|
|
1312
1450
|
// Which notes carry a directive, discovered over the same corpus every
|
|
1313
1451
|
// other pass reads rather than over a walk of this one's own. The
|
|
@@ -1354,8 +1492,10 @@ export async function prepareTreeSqlTables(
|
|
|
1354
1492
|
const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
|
|
1355
1493
|
foreignIndex: foreign,
|
|
1356
1494
|
config,
|
|
1495
|
+
all: allRecords,
|
|
1357
1496
|
});
|
|
1358
|
-
if (!sources.length && !hasEvents && !derivedOnly.sections.size
|
|
1497
|
+
if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
|
|
1498
|
+
return undefined;
|
|
1359
1499
|
|
|
1360
1500
|
// A page list is a filter over the records, so it needs no database. A tree
|
|
1361
1501
|
// whose only corpus-reading directive is one therefore opens none — which
|
|
@@ -1374,6 +1514,7 @@ export async function prepareTreeSqlTables(
|
|
|
1374
1514
|
pageOnly.inline = new Map();
|
|
1375
1515
|
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
1376
1516
|
attachGeneratedSections(pageOnly, derivedOnly);
|
|
1517
|
+
await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config);
|
|
1377
1518
|
return pageOnly;
|
|
1378
1519
|
}
|
|
1379
1520
|
// A cell links only where the address it would emit resolves, so a table
|
|
@@ -1388,7 +1529,11 @@ export async function prepareTreeSqlTables(
|
|
|
1388
1529
|
.filter((record) => !record.documents)
|
|
1389
1530
|
.map((record) => record.documentation ?? record.address?.canonical)
|
|
1390
1531
|
.map((target) => target && encodeAddresses(target))
|
|
1391
|
-
.filter(Boolean)
|
|
1532
|
+
.filter(Boolean)
|
|
1533
|
+
.flatMap((target) => [
|
|
1534
|
+
target,
|
|
1535
|
+
renderAddress({ ...readCanonicalKey(target), system: "note" }),
|
|
1536
|
+
]),
|
|
1392
1537
|
);
|
|
1393
1538
|
// Each declared dependency's published index, attached as its own schema so
|
|
1394
1539
|
// a table can read `FROM <package>.notes`. Imported here for the
|
|
@@ -1397,13 +1542,14 @@ export async function prepareTreeSqlTables(
|
|
|
1397
1542
|
// been fetched already fails earlier with a message naming the fetch.
|
|
1398
1543
|
const db =
|
|
1399
1544
|
database ??
|
|
1400
|
-
(await openNotesDatabase(
|
|
1545
|
+
(await openNotesDatabase(allRecords, {
|
|
1401
1546
|
dependencies: await cachedDependencies(config),
|
|
1402
1547
|
audience,
|
|
1403
1548
|
}));
|
|
1404
1549
|
try {
|
|
1405
1550
|
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
|
1406
1551
|
derived: derivedOnly.derived,
|
|
1552
|
+
all: allRecords,
|
|
1407
1553
|
});
|
|
1408
1554
|
joinGeneratedSections(generated, sources, notes);
|
|
1409
1555
|
const prepared = await prepareSqlTables(db, sources, {
|
|
@@ -1413,8 +1559,81 @@ export async function prepareTreeSqlTables(
|
|
|
1413
1559
|
});
|
|
1414
1560
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
1415
1561
|
attachGeneratedSections(prepared, generated);
|
|
1562
|
+
await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config);
|
|
1416
1563
|
return prepared;
|
|
1417
1564
|
} finally {
|
|
1418
1565
|
if (!database) await db.close();
|
|
1419
1566
|
}
|
|
1420
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/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),
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -73,12 +73,12 @@ import { resolveEmbeds } from "./content-embeds.mjs";
|
|
|
73
73
|
/**
|
|
74
74
|
* Whether a page's declared anchors include the slug a link names.
|
|
75
75
|
*
|
|
76
|
-
* A local page
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
76
|
+
* A local page carries a `Set` built by {@link module:engine/site-index}.
|
|
77
|
+
* A foreign page declares prose anchors in its note metadata, independently
|
|
78
|
+
* of whether it has a Foundry target map. An empty collection declares none.
|
|
79
|
+
* An absent collection leaves the target unchecked.
|
|
80
80
|
*
|
|
81
|
-
* @param {Set<string>|Record<string, string>|undefined} anchors - The page's
|
|
81
|
+
* @param {Set<string>|Record<string, string>|Array<{slug: string, kind: string}>|undefined} anchors - The page's
|
|
82
82
|
* declared anchors, or `undefined` where none were recorded.
|
|
83
83
|
* @param {string} slug - The anchor a link names.
|
|
84
84
|
* @returns {boolean} Whether the anchor is declared, or `true` when `anchors`
|
|
@@ -87,6 +87,8 @@ import { resolveEmbeds } from "./content-embeds.mjs";
|
|
|
87
87
|
*/
|
|
88
88
|
function hasAnchor(anchors, slug) {
|
|
89
89
|
if (!anchors) return true;
|
|
90
|
+
if (Array.isArray(anchors))
|
|
91
|
+
return anchors.some((anchor) => anchor.kind === "prose" && anchor.slug === slug);
|
|
90
92
|
return anchors instanceof Set ? anchors.has(slug) : Object.hasOwn(anchors, slug);
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -478,7 +480,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
478
480
|
label ?? hit.name ?? target,
|
|
479
481
|
);
|
|
480
482
|
}
|
|
481
|
-
if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
|
|
483
|
+
if (anchor && hit.url && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
|
|
482
484
|
return report(
|
|
483
485
|
all,
|
|
484
486
|
{ target, reason: "unknown-anchor", anchor, addressed: true },
|
package/engine/word-rows.mjs
CHANGED
|
@@ -82,15 +82,17 @@ const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.i
|
|
|
82
82
|
const bare = (form) => String(form).replace(/^-+|-+$/g, "");
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* A lookup from a built piece to its gloss, over the words (stems included)
|
|
86
|
-
* and
|
|
85
|
+
* A lookup from a built piece to its gloss, over the words (stems included),
|
|
86
|
+
* affixes and elements of one or more languages.
|
|
87
87
|
*
|
|
88
|
-
*
|
|
89
|
-
* affixes, then the next language's
|
|
90
|
-
*
|
|
91
|
-
*
|
|
88
|
+
* For each spelling, languages are given nearest first: a language's own
|
|
89
|
+
* words, then its affixes and elements, then the next language's. A piece
|
|
90
|
+
* matches an exact spelling across the lineage first, and failing that one
|
|
91
|
+
* written with the same letters and different joining hyphens, so
|
|
92
92
|
* `wulf-` finds the word `wulf`. Within a language a word's own gloss wins over
|
|
93
|
-
* an affix's.
|
|
93
|
+
* an affix's, and both precede an element's sense. Elements also match their
|
|
94
|
+
* declared variants; affixes also match their front-vowel forms. Spelling,
|
|
95
|
+
* including case and accents, is preserved.
|
|
94
96
|
*
|
|
95
97
|
* @param {...object} datas - Each language note's `data`, nearest first.
|
|
96
98
|
* @returns {(piece: string) => string|null} The gloss, or `null` where none resolves.
|
|
@@ -98,18 +100,24 @@ const bare = (form) => String(form).replace(/^-+|-+$/g, "");
|
|
|
98
100
|
export function pieceGlosses(...datas) {
|
|
99
101
|
const exact = new Map();
|
|
100
102
|
const loose = new Map();
|
|
101
|
-
const take = (entries) => {
|
|
103
|
+
const take = (entries, key = "gloss", alternateKey = "") => {
|
|
102
104
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
103
105
|
if (!isMap(entry) || typeof entry.form !== "string") continue;
|
|
104
|
-
const gloss = glossText(entry
|
|
106
|
+
const gloss = glossText(entry[key]);
|
|
105
107
|
if (gloss === null) continue;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
const alternate = entry[alternateKey];
|
|
109
|
+
const forms = [entry.form, ...(Array.isArray(alternate) ? alternate : [alternate])];
|
|
110
|
+
for (const form of forms) {
|
|
111
|
+
if (typeof form !== "string") continue;
|
|
112
|
+
if (!exact.has(form)) exact.set(form, gloss);
|
|
113
|
+
if (!loose.has(bare(form))) loose.set(bare(form), gloss);
|
|
114
|
+
}
|
|
108
115
|
}
|
|
109
116
|
};
|
|
110
117
|
for (const data of datas) {
|
|
111
118
|
take(data?.words);
|
|
112
|
-
take(data?.affixes);
|
|
119
|
+
take(data?.affixes, "gloss", "front");
|
|
120
|
+
take(data?.elements, "sense", "variants");
|
|
113
121
|
}
|
|
114
122
|
return (piece) => exact.get(piece) ?? loose.get(bare(piece)) ?? null;
|
|
115
123
|
}
|
package/engine/word-terms.mjs
CHANGED
|
@@ -136,12 +136,20 @@ export const WORD_FIELDS = Object.freeze([
|
|
|
136
136
|
"A class from the master word classes: a base, then the modifiers the language " +
|
|
137
137
|
"declares in `classModifiers`.",
|
|
138
138
|
}),
|
|
139
|
+
Object.freeze({
|
|
140
|
+
name: "plural",
|
|
141
|
+
kind: "string",
|
|
142
|
+
shape: "a string",
|
|
143
|
+
describe:
|
|
144
|
+
"The noun's explicit plural, where it differs from the language's optional plural " +
|
|
145
|
+
"rule. A dictionary uses this form before the rule; no rule agreement is checked.",
|
|
146
|
+
}),
|
|
139
147
|
Object.freeze({
|
|
140
148
|
name: "field",
|
|
141
149
|
kind: "string",
|
|
142
150
|
required: true,
|
|
143
151
|
shape: "one of the language's `fields` names",
|
|
144
|
-
describe: "The field
|
|
152
|
+
describe: "The field tag beside the dictionary entry, named in the language's `fields`.",
|
|
145
153
|
}),
|
|
146
154
|
Object.freeze({
|
|
147
155
|
name: "gloss",
|
|
@@ -376,27 +384,28 @@ export const GLOSSARY_ENTRY_FIELDS = Object.freeze([
|
|
|
376
384
|
name: "name_sort",
|
|
377
385
|
kind: "string",
|
|
378
386
|
shape: "a string",
|
|
379
|
-
describe: "Its sort key; the name when unstated.",
|
|
387
|
+
describe: "Its sort key; the dictionary word's sort key, or the name, when unstated.",
|
|
380
388
|
}),
|
|
381
389
|
Object.freeze({
|
|
382
390
|
name: "common",
|
|
383
391
|
kind: "boolean",
|
|
384
392
|
shape: "`true` or `false`",
|
|
385
|
-
describe: "
|
|
393
|
+
describe: "Core vocabulary for this glossary's owner; `false` when unstated.",
|
|
386
394
|
}),
|
|
387
395
|
Object.freeze({
|
|
388
396
|
name: "definition",
|
|
389
397
|
kind: "string",
|
|
390
398
|
shape: "a string",
|
|
391
399
|
describe:
|
|
392
|
-
"The sense text
|
|
393
|
-
"
|
|
400
|
+
"The sense text, overriding the word's or governance sources' senses. Unstated, " +
|
|
401
|
+
"a named dictionary word supplies all its senses and class; a named rank or office supplies its descriptions.",
|
|
394
402
|
}),
|
|
395
403
|
Object.freeze({
|
|
396
404
|
name: "pronunciation",
|
|
397
405
|
kind: "string",
|
|
398
406
|
shape: "a respelling such as `sol-MEH-reh`",
|
|
399
|
-
describe:
|
|
407
|
+
describe:
|
|
408
|
+
"A respelling, held to the same rule as a word's; the dictionary word's when unstated.",
|
|
400
409
|
}),
|
|
401
410
|
Object.freeze({
|
|
402
411
|
name: "language",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.9.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|