@heroiclands/package-build 23.8.0 → 23.10.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 +53 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +207 -70
- package/docs/api.md +646 -78
- package/docs/authoring/first-note.md +1 -1
- package/docs/authoring/links-and-markup.md +253 -102
- package/docs/commands.md +111 -13
- package/docs/content-format.md +7 -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 +398 -137
- package/docs/reference/note-types.md +188 -174
- package/engine/anchors.mjs +15 -26
- package/engine/base-compiler.mjs +6 -4
- package/engine/changelog-group.mjs +58 -34
- package/engine/changelog-lint.mjs +6 -2
- package/engine/compendiums.mjs +15 -1
- package/engine/compile-corpus.mjs +17 -3
- package/engine/content-charset.mjs +13 -1
- package/engine/content-format.yaml +1 -0
- package/engine/content-html.mjs +18 -4
- package/engine/content-icons.mjs +15 -1
- package/engine/content-images.mjs +28 -9
- package/engine/content-index.mjs +496 -16
- package/engine/content-links.mjs +70 -27
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +87 -8
- package/engine/content-tables.mjs +9 -4
- package/engine/content-tasklists.mjs +20 -4
- package/engine/culture-reach.mjs +61 -34
- package/engine/data-keys.mjs +3 -1
- package/engine/diagnostics.mjs +17 -6
- package/engine/dictionary-layout.mjs +151 -19
- package/engine/dictionary-rows.mjs +129 -79
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +29 -8
- package/engine/generate.mjs +13 -4
- package/engine/glossary-entries.mjs +11 -11
- package/engine/heading-attributes.mjs +21 -28
- package/engine/helpers.mjs +54 -19
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +13 -6
- package/engine/language-data.mjs +55 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +90 -4
- package/engine/language-words.mjs +2 -2
- package/engine/lexical-sections.mjs +247 -0
- package/engine/markup-validation.mjs +7 -2
- package/engine/metadata-index.mjs +1 -0
- package/engine/name-markup.mjs +114 -0
- package/engine/name-rows.mjs +70 -0
- package/engine/names.mjs +460 -0
- package/engine/note-addresses.mjs +25 -4
- package/engine/note-claims.mjs +2 -0
- package/engine/note-state.mjs +9 -3
- package/engine/note-vocabulary.mjs +7 -6
- package/engine/path-charset.mjs +24 -1
- package/engine/pdf-build.mjs +9 -9
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-build.mjs +16 -2
- package/engine/site-index.mjs +5 -1
- package/engine/sql-tables.mjs +324 -44
- package/engine/stub-lint.mjs +10 -5
- package/engine/web-wikilinks.mjs +8 -6
- package/engine/word-rows.mjs +20 -12
- package/engine/word-terms.mjs +15 -6
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/static/css/style.css +4 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +4 -2
- package/types/engine/compendiums.d.mts +5 -1
- package/types/engine/compile-corpus.d.mts +5 -2
- package/types/engine/content-charset.d.mts +5 -1
- package/types/engine/content-html.d.mts +5 -1
- package/types/engine/content-icons.d.mts +5 -1
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-index.d.mts +102 -18
- package/types/engine/content-links.d.mts +22 -8
- package/types/engine/content-session.d.mts +108 -0
- package/types/engine/content-sources.d.mts +32 -0
- package/types/engine/content-spans.d.mts +3 -5
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/content-tasklists.d.mts +5 -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 +10 -0
- package/types/engine/dictionary-layout.d.mts +35 -4
- package/types/engine/dictionary-rows.d.mts +9 -0
- package/types/engine/extension-attributes.d.mts +16 -2
- package/types/engine/first-use.d.mts +31 -0
- package/types/engine/foundry-entries.d.mts +14 -5
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/heading-attributes.d.mts +2 -2
- package/types/engine/helpers.d.mts +33 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/lexical-sections.d.mts +96 -0
- package/types/engine/markup-validation.d.mts +2 -1
- package/types/engine/name-markup.d.mts +176 -0
- package/types/engine/name-rows.d.mts +35 -0
- package/types/engine/names.d.mts +356 -0
- package/types/engine/note-addresses.d.mts +3 -3
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +6 -3
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +39 -4
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/word-rows.d.mts +9 -7
package/engine/sql-tables.mjs
CHANGED
|
@@ -26,10 +26,13 @@
|
|
|
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";
|
|
35
|
+
import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
|
|
33
36
|
import {
|
|
34
37
|
GLOSSARY_COLUMNS,
|
|
35
38
|
AFFIX_COLUMNS,
|
|
@@ -45,8 +48,10 @@ import {
|
|
|
45
48
|
} from "./word-rows.mjs";
|
|
46
49
|
import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
|
|
47
50
|
import { LANGUAGE_COLUMN_TYPES, LANGUAGE_RELATIONS } from "./language-rows.mjs";
|
|
51
|
+
import { VERB_PERSON_FIELDS } from "./language-terms.mjs";
|
|
48
52
|
import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
|
|
49
53
|
import { glossaryLanguage } from "./glossary-language.mjs";
|
|
54
|
+
import { lexicalSectionsFor } from "./lexical-sections.mjs";
|
|
50
55
|
import { eventViewSections } from "./event-views.mjs";
|
|
51
56
|
import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
|
|
52
57
|
import fs from "node:fs";
|
|
@@ -60,7 +65,7 @@ import { MARKET_CLASSES } from "./market-class.mjs";
|
|
|
60
65
|
import { parseMarkdownFile } from "./helpers.mjs";
|
|
61
66
|
import { sqlQueriesInMarkdown } from "./markdown-expressions.mjs";
|
|
62
67
|
// The record accessors only — see `engine/index-records.mjs`.
|
|
63
|
-
import { isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
|
|
68
|
+
import { authoredFrontmatter, isNoteRecord, noteFile, sortKeysDeep } from "./index-records.mjs";
|
|
64
69
|
|
|
65
70
|
/** Rendered in a cell whose value is absent. */
|
|
66
71
|
const EMPTY_CELL = "—";
|
|
@@ -261,17 +266,14 @@ export async function openNotesDatabase(
|
|
|
261
266
|
const base = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), "content-sql-"));
|
|
262
267
|
fs.mkdirSync(base, { recursive: true });
|
|
263
268
|
const jsonl = path.join(base, "notes.jsonl");
|
|
264
|
-
|
|
265
|
-
jsonl,
|
|
266
|
-
records.map((record) => JSON.stringify(sortKeysDeep(encodeAddresses(record)))).join("\n"),
|
|
267
|
-
);
|
|
269
|
+
const encoded = records.map((record) => encodeAddresses(record));
|
|
268
270
|
|
|
269
271
|
const instance = await DuckDBInstance.create(":memory:");
|
|
270
272
|
const connection = await instance.connect();
|
|
271
273
|
await connection.run("SET threads=1");
|
|
272
|
-
await createRelations(connection, jsonl, "", audience);
|
|
273
|
-
const encoded = records.map((record) => encodeAddresses(record));
|
|
274
|
+
await createRelations(connection, jsonl, encoded, "", audience);
|
|
274
275
|
await createEventRelation(connection, path.join(base, "events.jsonl"), encoded, "", audience);
|
|
276
|
+
await createNameRelation(connection, path.join(base, "names.jsonl"), encoded, "", audience);
|
|
275
277
|
await createWordRelations(connection, path.join(base, "notes"), encoded, "", audience);
|
|
276
278
|
|
|
277
279
|
// One schema per declared dependency, so `FROM sohl.notes` reads the notes
|
|
@@ -293,8 +295,7 @@ export async function openNotesDatabase(
|
|
|
293
295
|
.split("\n")
|
|
294
296
|
.filter((line) => line.trim())
|
|
295
297
|
.map((line) => flattenPublishedAddresses(JSON.parse(line)));
|
|
296
|
-
|
|
297
|
-
await createRelations(connection, flat, `${schema}.`, audience);
|
|
298
|
+
await createRelations(connection, flat, depRecords, `${schema}.`, audience);
|
|
298
299
|
await createEventRelation(
|
|
299
300
|
connection,
|
|
300
301
|
flat.replace(/\.jsonl$/, "-events.jsonl"),
|
|
@@ -302,6 +303,13 @@ export async function openNotesDatabase(
|
|
|
302
303
|
`${schema}.`,
|
|
303
304
|
audience,
|
|
304
305
|
);
|
|
306
|
+
await createNameRelation(
|
|
307
|
+
connection,
|
|
308
|
+
flat.replace(/\.jsonl$/, "-names.jsonl"),
|
|
309
|
+
depRecords,
|
|
310
|
+
`${schema}.`,
|
|
311
|
+
audience,
|
|
312
|
+
);
|
|
305
313
|
await createWordRelations(
|
|
306
314
|
connection,
|
|
307
315
|
flat.replace(/\.jsonl$/, ""),
|
|
@@ -335,6 +343,7 @@ export async function openNotesDatabase(
|
|
|
335
343
|
await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
|
|
336
344
|
for (const relation of [
|
|
337
345
|
"events",
|
|
346
|
+
"names",
|
|
338
347
|
"words",
|
|
339
348
|
"affixes",
|
|
340
349
|
"fields",
|
|
@@ -462,12 +471,27 @@ const WITH_STUBS_SCHEMA = "__with_stubs";
|
|
|
462
471
|
* of the ladder falls back to a constant where its column is absent.
|
|
463
472
|
*
|
|
464
473
|
* @param {object} connection - An open DuckDB connection.
|
|
465
|
-
* @param {string} file -
|
|
474
|
+
* @param {string} file - Where to write the audience's JSONL.
|
|
475
|
+
* @param {object[]} records - Complete index records, every Address a string.
|
|
466
476
|
* @param {string} [prefix] - A schema to qualify the view names with.
|
|
477
|
+
* @param {"all"|"public"} [audience] - Whether to leave out GM notes.
|
|
467
478
|
* @returns {Promise<void>}
|
|
468
479
|
*/
|
|
469
|
-
async function createRelations(connection, file, prefix = "", audience = "all") {
|
|
470
|
-
|
|
480
|
+
async function createRelations(connection, file, records, prefix = "", audience = "all") {
|
|
481
|
+
// Every record contributes its schema; only visible records contribute rows.
|
|
482
|
+
fs.writeFileSync(
|
|
483
|
+
file,
|
|
484
|
+
records
|
|
485
|
+
.map((record) =>
|
|
486
|
+
JSON.stringify({
|
|
487
|
+
record: sortKeysDeep(record),
|
|
488
|
+
visible: audience !== "public" || !isGmNote(record),
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
.join("\n"),
|
|
492
|
+
);
|
|
493
|
+
const source = readJsonAuto(file);
|
|
494
|
+
const read = records.length ? `SELECT record.* FROM (${source}) WHERE visible` : source;
|
|
471
495
|
const described = await connection.runAndReadAll(`DESCRIBE ${read}`);
|
|
472
496
|
const columns = new Set(described.getRowObjects().map((row) => String(row.column_name)));
|
|
473
497
|
// A row with no address is a stub. With no address column anywhere, every
|
|
@@ -481,16 +505,11 @@ async function createRelations(connection, file, prefix = "", audience = "all")
|
|
|
481
505
|
"COALESCE(list_contains(TRY_CAST(tags AS VARCHAR[]), 'draft'), false) " +
|
|
482
506
|
"OR COALESCE(TRY_CAST(tags AS VARCHAR) = 'draft', false)"
|
|
483
507
|
: "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
508
|
await createTableFromFile(
|
|
490
509
|
connection,
|
|
491
510
|
`${prefix}entries`,
|
|
492
511
|
`SELECT *, CASE WHEN ${stub} THEN 'stub' WHEN ${draft} THEN 'draft' ` +
|
|
493
|
-
`ELSE 'full' END AS state FROM (${read})
|
|
512
|
+
`ELSE 'full' END AS state FROM (${read})`,
|
|
494
513
|
);
|
|
495
514
|
await connection.run(
|
|
496
515
|
`CREATE VIEW ${prefix}notes AS SELECT * FROM ${prefix}entries WHERE state <> 'stub'`,
|
|
@@ -533,6 +552,41 @@ async function createEventRelation(connection, file, records, prefix, audience)
|
|
|
533
552
|
await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
|
|
534
553
|
}
|
|
535
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Create authored name rows from own-note records, with audience filtering and
|
|
557
|
+
* fixed scalar types even when every optional classification is null.
|
|
558
|
+
* @param {object} connection - An open DuckDB connection.
|
|
559
|
+
* @param {string} file - Where to write the rows.
|
|
560
|
+
* @param {object[]} records - Index records, every Address a string.
|
|
561
|
+
* @param {string} prefix - A schema to qualify the relation name with.
|
|
562
|
+
* @param {"all"|"public"} audience - Whether to leave out GM notes.
|
|
563
|
+
* @returns {Promise<void>}
|
|
564
|
+
*/
|
|
565
|
+
async function createNameRelation(connection, file, records, prefix, audience) {
|
|
566
|
+
const rows = namesRows(
|
|
567
|
+
audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
|
|
568
|
+
);
|
|
569
|
+
const columnTypes = NAME_COLUMNS.map((column) => [
|
|
570
|
+
column,
|
|
571
|
+
column === "line" || column === "column" ? "BIGINT" : "VARCHAR",
|
|
572
|
+
]);
|
|
573
|
+
if (!rows.length) {
|
|
574
|
+
const columns = columnTypes.map(([column, type]) => `NULL::${type} AS "${column}"`);
|
|
575
|
+
await connection.run(
|
|
576
|
+
`CREATE VIEW ${prefix}names AS SELECT ${columns.join(", ")} WHERE false`,
|
|
577
|
+
);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
|
|
581
|
+
const schema = columnTypes.map(([column, type]) => `'${column}': '${type}'`).join(", ");
|
|
582
|
+
await createTableFromFile(
|
|
583
|
+
connection,
|
|
584
|
+
`${prefix}names`,
|
|
585
|
+
`SELECT * FROM read_json('${file.replace(/'/g, "''")}', columns={${schema}}, ` +
|
|
586
|
+
"format='newline_delimited', maximum_object_size=20000000)",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
536
590
|
/**
|
|
537
591
|
* Create a relation as a table, so the JSONL behind it is parsed once when the
|
|
538
592
|
* database opens rather than on every query. A view over `read_json_auto`
|
|
@@ -617,20 +671,68 @@ async function createWordRelations(connection, stem, records, prefix, audience)
|
|
|
617
671
|
LINEAGE_COLUMNS,
|
|
618
672
|
);
|
|
619
673
|
const { dictionary, terms } = dictionaryAndTermRows(visible, records);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
674
|
+
const dictionaryTypes = {
|
|
675
|
+
etymology:
|
|
676
|
+
"STRUCT(kind VARCHAR, language VARCHAR, language_tag VARCHAR, form VARCHAR, " +
|
|
677
|
+
"parts STRUCT(form VARCHAR, gloss VARCHAR)[], root VARCHAR, frame VARCHAR)",
|
|
678
|
+
senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
|
|
679
|
+
usage: "VARCHAR[]",
|
|
680
|
+
depth: "BIGINT",
|
|
681
|
+
verbs: `STRUCT(invariant BOOLEAN, persons STRUCT(${VERB_PERSON_FIELDS.map((person) => `"${person.name}" VARCHAR`).join(", ")}))`,
|
|
682
|
+
};
|
|
683
|
+
const dictionaryFile = `${stem}-dictionary.jsonl`;
|
|
684
|
+
if (dictionary.length) {
|
|
685
|
+
// Only the dictionary's declared schema: optional patterns must not
|
|
686
|
+
// change its nested/list types when a corpus has no stated grammar.
|
|
687
|
+
fs.writeFileSync(dictionaryFile, dictionary.map((row) => JSON.stringify(row)).join("\n"));
|
|
688
|
+
const columns = DICTIONARY_COLUMNS.map(
|
|
689
|
+
(column) =>
|
|
690
|
+
`CAST("${column}" AS ${dictionaryTypes[column] ?? "VARCHAR"}) AS "${column}"`,
|
|
691
|
+
);
|
|
692
|
+
await createTableFromFile(
|
|
693
|
+
connection,
|
|
694
|
+
`${prefix}dictionary`,
|
|
695
|
+
`SELECT ${columns.join(", ")} FROM (${readJsonAuto(dictionaryFile)})`,
|
|
696
|
+
);
|
|
697
|
+
} else {
|
|
698
|
+
await createRowRelation(
|
|
699
|
+
connection,
|
|
700
|
+
dictionaryFile,
|
|
701
|
+
dictionary,
|
|
702
|
+
`${prefix}dictionary`,
|
|
703
|
+
DICTIONARY_COLUMNS,
|
|
704
|
+
dictionaryTypes,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
const termTypes = {
|
|
708
|
+
senses: "STRUCT(text VARCHAR, bodies VARCHAR[])[]",
|
|
709
|
+
usage: "VARCHAR[]",
|
|
710
|
+
common: "BOOLEAN",
|
|
711
|
+
origins: "VARCHAR[]",
|
|
712
|
+
cultures: "VARCHAR[]",
|
|
713
|
+
};
|
|
714
|
+
const termsFile = `${stem}-terms.jsonl`;
|
|
715
|
+
if (terms.length) {
|
|
716
|
+
// Terms retain declared types even when every value of a column is null.
|
|
717
|
+
fs.writeFileSync(termsFile, terms.map((row) => JSON.stringify(row)).join("\n"));
|
|
718
|
+
const columns = TERM_COLUMNS.map(
|
|
719
|
+
(column) => `CAST("${column}" AS ${termTypes[column] ?? "VARCHAR"}) AS "${column}"`,
|
|
720
|
+
);
|
|
721
|
+
await createTableFromFile(
|
|
722
|
+
connection,
|
|
723
|
+
`${prefix}terms`,
|
|
724
|
+
`SELECT ${columns.join(", ")} FROM (${readJsonAuto(termsFile)})`,
|
|
725
|
+
);
|
|
726
|
+
} else {
|
|
727
|
+
await createRowRelation(
|
|
728
|
+
connection,
|
|
729
|
+
termsFile,
|
|
730
|
+
terms,
|
|
731
|
+
`${prefix}terms`,
|
|
732
|
+
TERM_COLUMNS,
|
|
733
|
+
termTypes,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
634
736
|
for (const [name, rowsOf, columns] of LANGUAGE_RELATIONS)
|
|
635
737
|
await createRowRelation(
|
|
636
738
|
connection,
|
|
@@ -953,6 +1055,20 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
953
1055
|
labelOf,
|
|
954
1056
|
showKey: block.showKey,
|
|
955
1057
|
pageLanguage: languageOf(source, frontmatter),
|
|
1058
|
+
pageOwner:
|
|
1059
|
+
(
|
|
1060
|
+
typeof frontmatter?.type === "string" &&
|
|
1061
|
+
typeof frontmatter.shortcode === "string" &&
|
|
1062
|
+
db.addressContext?.package
|
|
1063
|
+
) ?
|
|
1064
|
+
renderAddress({
|
|
1065
|
+
package: db.addressContext.package,
|
|
1066
|
+
system: "note",
|
|
1067
|
+
type: frontmatter.type,
|
|
1068
|
+
shortcode: frontmatter.shortcode,
|
|
1069
|
+
})
|
|
1070
|
+
: undefined,
|
|
1071
|
+
entryAnchors: false,
|
|
956
1072
|
})
|
|
957
1073
|
: renderSqlTable(result, options),
|
|
958
1074
|
rows: result.rows.length,
|
|
@@ -1108,8 +1224,11 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
1108
1224
|
* @param {Map<string, Map<string, string>>} [opts.derived] - The derived
|
|
1109
1225
|
* lists, from an earlier call over the same records, which are not derived
|
|
1110
1226
|
* again.
|
|
1227
|
+
* @param {object[]} [opts.all=records] - Complete records for lexical lineage
|
|
1228
|
+
* traversal through providers outside the surface's audience.
|
|
1111
1229
|
* @returns {Promise<{sections: Map<string, string>, views: Set<string>,
|
|
1112
|
-
* derived: Map<string, Map<string, string
|
|
1230
|
+
* derived: Map<string, Map<string, string>>,
|
|
1231
|
+
* lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}>} Note file to its sections,
|
|
1113
1232
|
* joined in order; the files whose sections hold an event view's fence; and
|
|
1114
1233
|
* the derived lists alone.
|
|
1115
1234
|
*/
|
|
@@ -1117,7 +1236,7 @@ export async function generatedSectionsFor(
|
|
|
1117
1236
|
db,
|
|
1118
1237
|
records,
|
|
1119
1238
|
contentBase,
|
|
1120
|
-
{ only, foreignIndex, config, derived: given } = {},
|
|
1239
|
+
{ only, foreignIndex, config, derived: given, all = records } = {},
|
|
1121
1240
|
) {
|
|
1122
1241
|
const notes = records.filter((record) => isNoteRecord(record));
|
|
1123
1242
|
const fileOf = (record) => noteFile(contentBase, record);
|
|
@@ -1135,7 +1254,26 @@ export async function generatedSectionsFor(
|
|
|
1135
1254
|
const sections = new Map();
|
|
1136
1255
|
for (const file of new Set([...derived.keys(), ...views.keys()]))
|
|
1137
1256
|
sections.set(file, joinSections([derived.get(file), views.get(file)]));
|
|
1138
|
-
|
|
1257
|
+
// Automatic sections follow their source's audience even in a Foundry
|
|
1258
|
+
// corpus containing both public and private packs. Explicit SQL keeps
|
|
1259
|
+
// the database audience the caller requested.
|
|
1260
|
+
const publicRecords = records.filter((record) => !isGmNote(record));
|
|
1261
|
+
const publicOwners = lexicalSectionsFor(publicRecords, all);
|
|
1262
|
+
const allOwners =
|
|
1263
|
+
publicRecords.length === records.length ? publicOwners : lexicalSectionsFor(records, all);
|
|
1264
|
+
const lexical = new Map();
|
|
1265
|
+
for (const note of notes) {
|
|
1266
|
+
const owners = isGmNote(note) ? allOwners : publicOwners;
|
|
1267
|
+
const key = renderAddress({
|
|
1268
|
+
package: note.package,
|
|
1269
|
+
system: "note",
|
|
1270
|
+
type: note.type,
|
|
1271
|
+
shortcode: note.shortcode,
|
|
1272
|
+
});
|
|
1273
|
+
if (owners.has(key) && (only === undefined || fileOf(note) === only))
|
|
1274
|
+
lexical.set(fileOf(note), owners.get(key));
|
|
1275
|
+
}
|
|
1276
|
+
return { sections, views: new Set(views.keys()), derived, lexical };
|
|
1139
1277
|
}
|
|
1140
1278
|
|
|
1141
1279
|
/**
|
|
@@ -1182,7 +1320,7 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1182
1320
|
* empty set carrying them.
|
|
1183
1321
|
*
|
|
1184
1322
|
* @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
|
|
1185
|
-
* @param {{sections: Map<string, string>}} generated - From
|
|
1323
|
+
* @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>}} generated - From
|
|
1186
1324
|
* {@link generatedSectionsFor}.
|
|
1187
1325
|
* @returns {void}
|
|
1188
1326
|
*/
|
|
@@ -1192,6 +1330,52 @@ export function attachGeneratedSections(prepared, generated) {
|
|
|
1192
1330
|
if (!results) prepared.set(absPath, (results = []));
|
|
1193
1331
|
results.generated = text;
|
|
1194
1332
|
}
|
|
1333
|
+
for (const [absPath, sections] of generated.lexical ?? []) {
|
|
1334
|
+
let results = prepared.get(absPath);
|
|
1335
|
+
if (!results) prepared.set(absPath, (results = []));
|
|
1336
|
+
results.lexical = sections;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Index the anchors the prepared bodies actually emit, including anchors in
|
|
1342
|
+
* SQL results. Foundry maps use the same expanded body the compiler splits.
|
|
1343
|
+
*
|
|
1344
|
+
* @param {Map<string, object[]>} prepared - The prepared directives.
|
|
1345
|
+
* @param {object[]} records - The visible content-index records.
|
|
1346
|
+
* @param {string} contentBase - Root of the content tree.
|
|
1347
|
+
* @param {Map<string, object>} parsed - Notes already parsed.
|
|
1348
|
+
* @param {object} [config] - The resolved build configuration.
|
|
1349
|
+
* @returns {Promise<void>}
|
|
1350
|
+
*/
|
|
1351
|
+
async function attachPreparedAnchors(prepared, records, contentBase, parsed, config, syntax) {
|
|
1352
|
+
const { entriesForNote, foundryIdentities } = await import("./foundry-entries.mjs");
|
|
1353
|
+
const identities = config ? { ...foundryIdentities(config), syntax } : undefined;
|
|
1354
|
+
for (const record of records) {
|
|
1355
|
+
if (!isNoteRecord(record)) continue;
|
|
1356
|
+
const file = noteFile(contentBase, record);
|
|
1357
|
+
const results = prepared.get(file);
|
|
1358
|
+
if (!results) continue;
|
|
1359
|
+
if (syntax) results.syntax = syntax;
|
|
1360
|
+
const { body } = parsed.get(file) ?? parseMarkdownFile(file);
|
|
1361
|
+
const expanded = expandContentTables(body, {
|
|
1362
|
+
source: file,
|
|
1363
|
+
sqlTables: results,
|
|
1364
|
+
pageLists: prepared.pageLists?.get(file),
|
|
1365
|
+
}).markdown;
|
|
1366
|
+
results.anchors = collectAnchors(expanded, 1, undefined, syntax);
|
|
1367
|
+
if (!identities || !record.address?.slug) continue;
|
|
1368
|
+
const frontmatter = authoredFrontmatter(record);
|
|
1369
|
+
const entries = entriesForNote(
|
|
1370
|
+
frontmatter,
|
|
1371
|
+
frontmatter.name?.full ?? record.file.name,
|
|
1372
|
+
record.address.slug,
|
|
1373
|
+
expanded,
|
|
1374
|
+
identities,
|
|
1375
|
+
);
|
|
1376
|
+
results.anchorUuids = entries[0]?.anchors;
|
|
1377
|
+
results.docAnchorUuids = entries[1]?.anchors;
|
|
1378
|
+
}
|
|
1195
1379
|
}
|
|
1196
1380
|
|
|
1197
1381
|
/**
|
|
@@ -1291,13 +1475,23 @@ export async function cachedDependencies(config) {
|
|
|
1291
1475
|
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
|
|
1292
1476
|
* generated sections take in, where the caller holds them already; absent,
|
|
1293
1477
|
* they are read from the cache.
|
|
1478
|
+
* @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Exact-text scanner projections.
|
|
1294
1479
|
* @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
|
|
1295
1480
|
* carrying `inline` and `pageLists` beside them, or nothing when the tree
|
|
1296
1481
|
* holds no such directive and no note is given a generated section.
|
|
1297
1482
|
*/
|
|
1298
1483
|
export async function prepareTreeSqlTables(
|
|
1299
1484
|
contentBase,
|
|
1300
|
-
{
|
|
1485
|
+
{
|
|
1486
|
+
config,
|
|
1487
|
+
skipDirectories,
|
|
1488
|
+
records,
|
|
1489
|
+
parsed,
|
|
1490
|
+
database,
|
|
1491
|
+
audience = "all",
|
|
1492
|
+
foreignIndex,
|
|
1493
|
+
syntax,
|
|
1494
|
+
} = {},
|
|
1301
1495
|
) {
|
|
1302
1496
|
// Imported here rather than at module scope: the index reaches the pack
|
|
1303
1497
|
// compilers through `manifest-emit` → `journals`, so a static import from a
|
|
@@ -1305,9 +1499,9 @@ export async function prepareTreeSqlTables(
|
|
|
1305
1499
|
// uninitialised for whichever module the runtime happened to load first.
|
|
1306
1500
|
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1307
1501
|
const notes = parsed ?? new Map();
|
|
1308
|
-
const
|
|
1309
|
-
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes })
|
|
1310
|
-
|
|
1502
|
+
const allRecords =
|
|
1503
|
+
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
|
|
1504
|
+
const indexRecords = allRecords.filter((record) => audience !== "public" || !isGmNote(record));
|
|
1311
1505
|
|
|
1312
1506
|
// Which notes carry a directive, discovered over the same corpus every
|
|
1313
1507
|
// other pass reads rather than over a walk of this one's own. The
|
|
@@ -1354,8 +1548,10 @@ export async function prepareTreeSqlTables(
|
|
|
1354
1548
|
const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
|
|
1355
1549
|
foreignIndex: foreign,
|
|
1356
1550
|
config,
|
|
1551
|
+
all: allRecords,
|
|
1357
1552
|
});
|
|
1358
|
-
if (!sources.length && !hasEvents && !derivedOnly.sections.size
|
|
1553
|
+
if (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
|
|
1554
|
+
return undefined;
|
|
1359
1555
|
|
|
1360
1556
|
// A page list is a filter over the records, so it needs no database. A tree
|
|
1361
1557
|
// whose only corpus-reading directive is one therefore opens none — which
|
|
@@ -1374,6 +1570,7 @@ export async function prepareTreeSqlTables(
|
|
|
1374
1570
|
pageOnly.inline = new Map();
|
|
1375
1571
|
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
1376
1572
|
attachGeneratedSections(pageOnly, derivedOnly);
|
|
1573
|
+
await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config, syntax);
|
|
1377
1574
|
return pageOnly;
|
|
1378
1575
|
}
|
|
1379
1576
|
// A cell links only where the address it would emit resolves, so a table
|
|
@@ -1388,7 +1585,11 @@ export async function prepareTreeSqlTables(
|
|
|
1388
1585
|
.filter((record) => !record.documents)
|
|
1389
1586
|
.map((record) => record.documentation ?? record.address?.canonical)
|
|
1390
1587
|
.map((target) => target && encodeAddresses(target))
|
|
1391
|
-
.filter(Boolean)
|
|
1588
|
+
.filter(Boolean)
|
|
1589
|
+
.flatMap((target) => [
|
|
1590
|
+
target,
|
|
1591
|
+
renderAddress({ ...readCanonicalKey(target), system: "note" }),
|
|
1592
|
+
]),
|
|
1392
1593
|
);
|
|
1393
1594
|
// Each declared dependency's published index, attached as its own schema so
|
|
1394
1595
|
// a table can read `FROM <package>.notes`. Imported here for the
|
|
@@ -1397,13 +1598,14 @@ export async function prepareTreeSqlTables(
|
|
|
1397
1598
|
// been fetched already fails earlier with a message naming the fetch.
|
|
1398
1599
|
const db =
|
|
1399
1600
|
database ??
|
|
1400
|
-
(await openNotesDatabase(
|
|
1601
|
+
(await openNotesDatabase(allRecords, {
|
|
1401
1602
|
dependencies: await cachedDependencies(config),
|
|
1402
1603
|
audience,
|
|
1403
1604
|
}));
|
|
1404
1605
|
try {
|
|
1405
1606
|
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
|
1406
1607
|
derived: derivedOnly.derived,
|
|
1608
|
+
all: allRecords,
|
|
1407
1609
|
});
|
|
1408
1610
|
joinGeneratedSections(generated, sources, notes);
|
|
1409
1611
|
const prepared = await prepareSqlTables(db, sources, {
|
|
@@ -1413,8 +1615,86 @@ export async function prepareTreeSqlTables(
|
|
|
1413
1615
|
});
|
|
1414
1616
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
1415
1617
|
attachGeneratedSections(prepared, generated);
|
|
1618
|
+
await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config, syntax);
|
|
1416
1619
|
return prepared;
|
|
1417
1620
|
} finally {
|
|
1418
1621
|
if (!database) await db.close();
|
|
1419
1622
|
}
|
|
1420
1623
|
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* Prepare authored link-check tables in each source note's audience. Public
|
|
1627
|
+
* sources share one public database; GM sources share one complete database.
|
|
1628
|
+
* Complete records remain available for authored-link checks.
|
|
1629
|
+
*
|
|
1630
|
+
* @param {string} contentBase - Root of the content tree.
|
|
1631
|
+
* @param {object} [opts]
|
|
1632
|
+
* @param {object} [opts.config] - Resolved configuration.
|
|
1633
|
+
* @param {readonly string[]} [opts.skipDirectories] - The walk's scope.
|
|
1634
|
+
* @param {object[]} [opts.records] - Complete index records already derived.
|
|
1635
|
+
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] - Parsed notes.
|
|
1636
|
+
* @param {Map<string, object>} [opts.foreignIndex] - Published dependency indexes.
|
|
1637
|
+
* @param {(audience: "all"|"public") => Promise<Map<string, object[]>|undefined>} [opts.prepareAudience] -
|
|
1638
|
+
* Context-bound preparation shared with other operations in the same session.
|
|
1639
|
+
* @returns {Promise<Map<string, object[]>|undefined>} Prepared tables by source.
|
|
1640
|
+
*/
|
|
1641
|
+
export async function prepareLinkSqlTables(
|
|
1642
|
+
contentBase,
|
|
1643
|
+
{ config, skipDirectories, records, parsed, foreignIndex, prepareAudience } = {},
|
|
1644
|
+
) {
|
|
1645
|
+
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1646
|
+
const notes = parsed ?? new Map();
|
|
1647
|
+
const allRecords =
|
|
1648
|
+
records ?? indexRecordsFor({ contentBase, config, skipDirectories, parsed: notes });
|
|
1649
|
+
const sources = new Map();
|
|
1650
|
+
const lexicalOwners = lexicalSectionsFor(allRecords);
|
|
1651
|
+
for (const record of allRecords) {
|
|
1652
|
+
if (!isNoteRecord(record)) continue;
|
|
1653
|
+
const file = noteFile(contentBase, record);
|
|
1654
|
+
let note = notes.get(file);
|
|
1655
|
+
if (!note) {
|
|
1656
|
+
note = parseMarkdownFile(file);
|
|
1657
|
+
notes.set(file, note);
|
|
1658
|
+
}
|
|
1659
|
+
const body = note.body ?? "";
|
|
1660
|
+
if (
|
|
1661
|
+
findSqlBlocks(body).length ||
|
|
1662
|
+
findPageListBlocks(body).length ||
|
|
1663
|
+
sqlQueriesInMarkdown(body, note.frontmatter).length ||
|
|
1664
|
+
lexicalOwners.has(
|
|
1665
|
+
renderAddress({
|
|
1666
|
+
package: record.package,
|
|
1667
|
+
system: "note",
|
|
1668
|
+
type: record.type,
|
|
1669
|
+
shortcode: record.shortcode,
|
|
1670
|
+
}),
|
|
1671
|
+
)
|
|
1672
|
+
)
|
|
1673
|
+
sources.set(file, isGmNote(record) ? "all" : "public");
|
|
1674
|
+
}
|
|
1675
|
+
if (!sources.size) return undefined;
|
|
1676
|
+
const selected = new Map();
|
|
1677
|
+
selected.pageLists = new Map();
|
|
1678
|
+
selected.inline = new Map();
|
|
1679
|
+
for (const audience of new Set(sources.values())) {
|
|
1680
|
+
const prepared =
|
|
1681
|
+
prepareAudience ?
|
|
1682
|
+
await prepareAudience(audience)
|
|
1683
|
+
: await prepareTreeSqlTables(contentBase, {
|
|
1684
|
+
config,
|
|
1685
|
+
skipDirectories,
|
|
1686
|
+
records: allRecords,
|
|
1687
|
+
parsed: notes,
|
|
1688
|
+
foreignIndex,
|
|
1689
|
+
audience,
|
|
1690
|
+
});
|
|
1691
|
+
for (const [file, sourceAudience] of sources) {
|
|
1692
|
+
if (sourceAudience !== audience) continue;
|
|
1693
|
+
if (prepared?.has(file)) selected.set(file, prepared.get(file));
|
|
1694
|
+
if (prepared?.pageLists?.has(file))
|
|
1695
|
+
selected.pageLists.set(file, prepared.pageLists.get(file));
|
|
1696
|
+
if (prepared?.inline?.has(file)) selected.inline.set(file, prepared.inline.get(file));
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return selected;
|
|
1700
|
+
}
|
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),
|
|
@@ -120,13 +124,14 @@ function checkNote({ fm, body, file, raw }) {
|
|
|
120
124
|
* @param {object} opts
|
|
121
125
|
* @param {readonly object[]} opts.records - Index records the caller derived.
|
|
122
126
|
* @param {string} [opts.contentPackage] - Named in the counts line.
|
|
127
|
+
* @param {Map<string, import("./content-session.mjs").ContentNoteSource>} [opts.parsed] - Captured sources, with the file reader as fallback.
|
|
123
128
|
* @returns {{findings: object[], counts: {full: number, draft: number,
|
|
124
129
|
* stub: number}, byType: Array<{type: string, full: number, draft: number,
|
|
125
130
|
* stub: number}>, oldestDrafts: Array<{file: string, modified: Date}>,
|
|
126
131
|
* summary: string[]}} The findings, the ladder's counts, and the lines a
|
|
127
132
|
* reader is shown as prose rather than as findings.
|
|
128
133
|
*/
|
|
129
|
-
export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
|
|
134
|
+
export function lintNoteStates(contentBase, { records, contentPackage, parsed } = {}) {
|
|
130
135
|
const findings = [];
|
|
131
136
|
const counts = { full: 0, draft: 0, stub: 0 };
|
|
132
137
|
/** @type {Map<string, {full: number, draft: number, stub: number}>} */
|
|
@@ -145,8 +150,8 @@ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
|
|
|
145
150
|
const absPath = noteFile(contentBase, record);
|
|
146
151
|
const file = path.relative(process.cwd(), absPath);
|
|
147
152
|
const fm = authoredFrontmatter(record);
|
|
148
|
-
const
|
|
149
|
-
const raw =
|
|
153
|
+
const source = parsed?.get(absPath) ?? parseMarkdownFile(absPath);
|
|
154
|
+
const { body, raw } = source;
|
|
150
155
|
|
|
151
156
|
// The ladder, read exactly as the `entries` view reads it: the absent
|
|
152
157
|
// address is the state, not a second body test beside it. A note with
|
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 },
|