@heroiclands/package-build 23.9.0 → 23.11.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 +33 -0
- package/README.md +2 -0
- package/bin/content-commands.mjs +197 -68
- package/docs/api.md +365 -70
- package/docs/authoring/first-note.md +10 -0
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +186 -49
- package/docs/commands.md +92 -3
- package/docs/content-format.md +10 -3
- package/docs/design.md +18 -0
- package/docs/guides/book.md +7 -0
- package/docs/guides/site.md +7 -0
- package/docs/reference/format-details.md +186 -15
- package/docs/reference/note-types.md +4 -4
- package/engine/anchors.mjs +43 -9
- 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 +14 -1
- package/engine/content-charset.mjs +13 -1
- 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 +102 -23
- package/engine/content-links.mjs +68 -30
- package/engine/content-session.mjs +247 -0
- package/engine/content-sources.mjs +110 -0
- package/engine/content-spans.mjs +118 -14
- package/engine/content-tables.mjs +1 -0
- package/engine/content-tasklists.mjs +20 -4
- package/engine/diagnostics.mjs +17 -6
- package/engine/extension-attributes.mjs +28 -5
- package/engine/first-use.mjs +371 -0
- package/engine/foundry-entries.mjs +12 -9
- package/engine/generate.mjs +13 -4
- package/engine/helpers.mjs +47 -16
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +3 -0
- package/engine/journals.mjs +9 -5
- package/engine/language-data.mjs +2 -2
- package/engine/language-rows.mjs +4 -4
- package/engine/language-terms.mjs +4 -4
- package/engine/lexical-sections.mjs +5 -5
- 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/path-charset.mjs +24 -1
- package/engine/pdf-render.mjs +15 -1
- package/engine/site-index.mjs +25 -2
- package/engine/sql-tables.mjs +76 -15
- package/engine/stub-lint.mjs +4 -3
- package/engine/web-wikilinks.mjs +33 -12
- package/engine/wikilink-syntax.mjs +65 -8
- package/engine/wikilinks.mjs +29 -11
- package/engine/yaml-lint.mjs +3 -5
- package/hugo-theme/layouts/partials/footer.html +2 -0
- package/hugo-theme/static/css/style.css +20 -0
- package/hugo-theme/static/js/inline-spoilers.js +23 -0
- package/package.json +1 -1
- package/types/engine/anchors.d.mts +6 -3
- 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 +39 -19
- 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 +8 -6
- package/types/engine/content-tasklists.d.mts +5 -1
- package/types/engine/diagnostics.d.mts +10 -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 +11 -4
- package/types/engine/generate.d.mts +5 -1
- package/types/engine/helpers.d.mts +30 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/journals.d.mts +5 -2
- package/types/engine/lexical-sections.d.mts +3 -2
- 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/path-charset.d.mts +16 -1
- package/types/engine/site-build.d.mts +2 -2
- package/types/engine/site-index.d.mts +3 -1
- package/types/engine/sql-tables.d.mts +7 -2
- package/types/engine/stub-lint.d.mts +3 -1
- package/types/engine/wikilink-syntax.d.mts +40 -3
package/engine/sql-tables.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { expandContentTables } from "./content-tables.mjs";
|
|
|
32
32
|
import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
|
|
33
33
|
import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
|
|
34
34
|
import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
|
|
35
|
+
import { NAME_COLUMNS, namesRows } from "./name-rows.mjs";
|
|
35
36
|
import {
|
|
36
37
|
GLOSSARY_COLUMNS,
|
|
37
38
|
AFFIX_COLUMNS,
|
|
@@ -272,6 +273,7 @@ export async function openNotesDatabase(
|
|
|
272
273
|
await connection.run("SET threads=1");
|
|
273
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
|
|
@@ -301,6 +303,13 @@ export async function openNotesDatabase(
|
|
|
301
303
|
`${schema}.`,
|
|
302
304
|
audience,
|
|
303
305
|
);
|
|
306
|
+
await createNameRelation(
|
|
307
|
+
connection,
|
|
308
|
+
flat.replace(/\.jsonl$/, "-names.jsonl"),
|
|
309
|
+
depRecords,
|
|
310
|
+
`${schema}.`,
|
|
311
|
+
audience,
|
|
312
|
+
);
|
|
304
313
|
await createWordRelations(
|
|
305
314
|
connection,
|
|
306
315
|
flat.replace(/\.jsonl$/, ""),
|
|
@@ -334,6 +343,7 @@ export async function openNotesDatabase(
|
|
|
334
343
|
await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
|
|
335
344
|
for (const relation of [
|
|
336
345
|
"events",
|
|
346
|
+
"names",
|
|
337
347
|
"words",
|
|
338
348
|
"affixes",
|
|
339
349
|
"fields",
|
|
@@ -542,6 +552,41 @@ async function createEventRelation(connection, file, records, prefix, audience)
|
|
|
542
552
|
await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
|
|
543
553
|
}
|
|
544
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
|
+
|
|
545
590
|
/**
|
|
546
591
|
* Create a relation as a table, so the JSONL behind it is parsed once when the
|
|
547
592
|
* database opens rather than on every query. A view over `read_json_auto`
|
|
@@ -1303,21 +1348,22 @@ export function attachGeneratedSections(prepared, generated) {
|
|
|
1303
1348
|
* @param {object} [config] - The resolved build configuration.
|
|
1304
1349
|
* @returns {Promise<void>}
|
|
1305
1350
|
*/
|
|
1306
|
-
async function attachPreparedAnchors(prepared, records, contentBase, parsed, config) {
|
|
1351
|
+
async function attachPreparedAnchors(prepared, records, contentBase, parsed, config, syntax) {
|
|
1307
1352
|
const { entriesForNote, foundryIdentities } = await import("./foundry-entries.mjs");
|
|
1308
|
-
const identities = config ? foundryIdentities(config) : undefined;
|
|
1353
|
+
const identities = config ? { ...foundryIdentities(config), syntax } : undefined;
|
|
1309
1354
|
for (const record of records) {
|
|
1310
1355
|
if (!isNoteRecord(record)) continue;
|
|
1311
1356
|
const file = noteFile(contentBase, record);
|
|
1312
1357
|
const results = prepared.get(file);
|
|
1313
1358
|
if (!results) continue;
|
|
1359
|
+
if (syntax) results.syntax = syntax;
|
|
1314
1360
|
const { body } = parsed.get(file) ?? parseMarkdownFile(file);
|
|
1315
1361
|
const expanded = expandContentTables(body, {
|
|
1316
1362
|
source: file,
|
|
1317
1363
|
sqlTables: results,
|
|
1318
1364
|
pageLists: prepared.pageLists?.get(file),
|
|
1319
1365
|
}).markdown;
|
|
1320
|
-
results.anchors = collectAnchors(expanded);
|
|
1366
|
+
results.anchors = collectAnchors(expanded, 1, undefined, syntax);
|
|
1321
1367
|
if (!identities || !record.address?.slug) continue;
|
|
1322
1368
|
const frontmatter = authoredFrontmatter(record);
|
|
1323
1369
|
const entries = entriesForNote(
|
|
@@ -1429,13 +1475,23 @@ export async function cachedDependencies(config) {
|
|
|
1429
1475
|
* @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
|
|
1430
1476
|
* generated sections take in, where the caller holds them already; absent,
|
|
1431
1477
|
* they are read from the cache.
|
|
1478
|
+
* @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Exact-text scanner projections.
|
|
1432
1479
|
* @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
|
|
1433
1480
|
* carrying `inline` and `pageLists` beside them, or nothing when the tree
|
|
1434
1481
|
* holds no such directive and no note is given a generated section.
|
|
1435
1482
|
*/
|
|
1436
1483
|
export async function prepareTreeSqlTables(
|
|
1437
1484
|
contentBase,
|
|
1438
|
-
{
|
|
1485
|
+
{
|
|
1486
|
+
config,
|
|
1487
|
+
skipDirectories,
|
|
1488
|
+
records,
|
|
1489
|
+
parsed,
|
|
1490
|
+
database,
|
|
1491
|
+
audience = "all",
|
|
1492
|
+
foreignIndex,
|
|
1493
|
+
syntax,
|
|
1494
|
+
} = {},
|
|
1439
1495
|
) {
|
|
1440
1496
|
// Imported here rather than at module scope: the index reaches the pack
|
|
1441
1497
|
// compilers through `manifest-emit` → `journals`, so a static import from a
|
|
@@ -1514,7 +1570,7 @@ export async function prepareTreeSqlTables(
|
|
|
1514
1570
|
pageOnly.inline = new Map();
|
|
1515
1571
|
pageOnly.pageLists = preparePageLists(indexRecords, sources);
|
|
1516
1572
|
attachGeneratedSections(pageOnly, derivedOnly);
|
|
1517
|
-
await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config);
|
|
1573
|
+
await attachPreparedAnchors(pageOnly, indexRecords, contentBase, notes, config, syntax);
|
|
1518
1574
|
return pageOnly;
|
|
1519
1575
|
}
|
|
1520
1576
|
// A cell links only where the address it would emit resolves, so a table
|
|
@@ -1559,7 +1615,7 @@ export async function prepareTreeSqlTables(
|
|
|
1559
1615
|
});
|
|
1560
1616
|
prepared.inline = await prepareInlineSqlExpressions(db, sources);
|
|
1561
1617
|
attachGeneratedSections(prepared, generated);
|
|
1562
|
-
await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config);
|
|
1618
|
+
await attachPreparedAnchors(prepared, indexRecords, contentBase, notes, config, syntax);
|
|
1563
1619
|
return prepared;
|
|
1564
1620
|
} finally {
|
|
1565
1621
|
if (!database) await db.close();
|
|
@@ -1578,11 +1634,13 @@ export async function prepareTreeSqlTables(
|
|
|
1578
1634
|
* @param {object[]} [opts.records] - Complete index records already derived.
|
|
1579
1635
|
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] - Parsed notes.
|
|
1580
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.
|
|
1581
1639
|
* @returns {Promise<Map<string, object[]>|undefined>} Prepared tables by source.
|
|
1582
1640
|
*/
|
|
1583
1641
|
export async function prepareLinkSqlTables(
|
|
1584
1642
|
contentBase,
|
|
1585
|
-
{ config, skipDirectories, records, parsed, foreignIndex } = {},
|
|
1643
|
+
{ config, skipDirectories, records, parsed, foreignIndex, prepareAudience } = {},
|
|
1586
1644
|
) {
|
|
1587
1645
|
const { indexRecordsFor } = await import("./content-index.mjs");
|
|
1588
1646
|
const notes = parsed ?? new Map();
|
|
@@ -1619,14 +1677,17 @@ export async function prepareLinkSqlTables(
|
|
|
1619
1677
|
selected.pageLists = new Map();
|
|
1620
1678
|
selected.inline = new Map();
|
|
1621
1679
|
for (const audience of new Set(sources.values())) {
|
|
1622
|
-
const prepared =
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
+
});
|
|
1630
1691
|
for (const [file, sourceAudience] of sources) {
|
|
1631
1692
|
if (sourceAudience !== audience) continue;
|
|
1632
1693
|
if (prepared?.has(file)) selected.set(file, prepared.get(file));
|
package/engine/stub-lint.mjs
CHANGED
|
@@ -124,13 +124,14 @@ function checkNote({ fm, body, file, raw }) {
|
|
|
124
124
|
* @param {object} opts
|
|
125
125
|
* @param {readonly object[]} opts.records - Index records the caller derived.
|
|
126
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.
|
|
127
128
|
* @returns {{findings: object[], counts: {full: number, draft: number,
|
|
128
129
|
* stub: number}, byType: Array<{type: string, full: number, draft: number,
|
|
129
130
|
* stub: number}>, oldestDrafts: Array<{file: string, modified: Date}>,
|
|
130
131
|
* summary: string[]}} The findings, the ladder's counts, and the lines a
|
|
131
132
|
* reader is shown as prose rather than as findings.
|
|
132
133
|
*/
|
|
133
|
-
export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
|
|
134
|
+
export function lintNoteStates(contentBase, { records, contentPackage, parsed } = {}) {
|
|
134
135
|
const findings = [];
|
|
135
136
|
const counts = { full: 0, draft: 0, stub: 0 };
|
|
136
137
|
/** @type {Map<string, {full: number, draft: number, stub: number}>} */
|
|
@@ -149,8 +150,8 @@ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
|
|
|
149
150
|
const absPath = noteFile(contentBase, record);
|
|
150
151
|
const file = path.relative(process.cwd(), absPath);
|
|
151
152
|
const fm = authoredFrontmatter(record);
|
|
152
|
-
const
|
|
153
|
-
const raw =
|
|
153
|
+
const source = parsed?.get(absPath) ?? parseMarkdownFile(absPath);
|
|
154
|
+
const { body, raw } = source;
|
|
154
155
|
|
|
155
156
|
// The ladder, read exactly as the `entries` view reads it: the absent
|
|
156
157
|
// address is the state, not a second body test beside it. A note with
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
// cannot drift apart on it: they disagreed once over the unlabelled hyphen
|
|
42
42
|
// form, which the packs showed as a raw shortcode and the knowledgebase as a
|
|
43
43
|
// name.
|
|
44
|
-
import {
|
|
44
|
+
import { collectAnchors } from "./anchors.mjs";
|
|
45
|
+
import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
|
|
45
46
|
import { readQualifier } from "./wikilinks.mjs";
|
|
46
47
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
47
48
|
// The canonical `package-system-type-shortcode` key, so a package-qualified
|
|
@@ -354,6 +355,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
354
355
|
// same counting the checker does, and what turns a finding into a
|
|
355
356
|
// `file:line:column:` diagnostic rather than a note-wide one.
|
|
356
357
|
const seen = new Map();
|
|
358
|
+
let ownAnchors = ctx.noteAnchors;
|
|
357
359
|
/**
|
|
358
360
|
* Records a finding against one authored literal.
|
|
359
361
|
*
|
|
@@ -404,18 +406,28 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
404
406
|
// Code is verbatim: a `[[…]]` inside a code fence, an indented block or an
|
|
405
407
|
// inline span is source text, not a link.
|
|
406
408
|
return replaceOutsideCode(embedded.markdown, WIKILINK, (all, rawInner) => {
|
|
407
|
-
const parsed = readWikilink(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
409
|
+
const parsed = readWikilink(
|
|
410
|
+
rawInner,
|
|
411
|
+
{
|
|
412
|
+
package: ctx.contentPackage,
|
|
413
|
+
system: "note",
|
|
414
|
+
types: ctx.contentTypes ?? new Set(),
|
|
415
|
+
packages: ctx.packages,
|
|
416
|
+
noIndexPackages: ctx.noIndexPackages,
|
|
417
|
+
},
|
|
418
|
+
all,
|
|
419
|
+
);
|
|
414
420
|
const { rawTarget: target, anchor, display } = parsed;
|
|
415
421
|
// An empty label is not a label: `[[x|]]` addresses the target and
|
|
416
422
|
// shows its name, so `""` falls through to the same place `null` does.
|
|
417
423
|
// One reading, from {@link authoredLabel}.
|
|
418
424
|
const label = authoredLabel({ display });
|
|
425
|
+
if (parsed.attributes?.problems.length)
|
|
426
|
+
return report(
|
|
427
|
+
all,
|
|
428
|
+
{ target, reason: "invalid-attributes", problems: parsed.attributes.problems },
|
|
429
|
+
label ?? target,
|
|
430
|
+
);
|
|
419
431
|
|
|
420
432
|
// **Every link carries a label**. Without one there is nothing
|
|
421
433
|
// to resolve against, and nothing to show either, a shortcode being an
|
|
@@ -445,7 +457,11 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
445
457
|
label ?? anchor,
|
|
446
458
|
);
|
|
447
459
|
}
|
|
448
|
-
return
|
|
460
|
+
return presentWikilink(
|
|
461
|
+
`[${label ?? ctx.captionLabels?.get(anchor) ?? anchor}](#${slugify(anchor)})`,
|
|
462
|
+
parsed,
|
|
463
|
+
{ noteAnchors: (ownAnchors ??= collectAnchors(body)) },
|
|
464
|
+
);
|
|
449
465
|
}
|
|
450
466
|
|
|
451
467
|
// The canonical separator has to be resolved, not merely
|
|
@@ -480,7 +496,7 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
480
496
|
label ?? hit.name ?? target,
|
|
481
497
|
);
|
|
482
498
|
}
|
|
483
|
-
if (anchor &&
|
|
499
|
+
if (anchor && !hasAnchor(hit.noteAnchors ?? hit.anchors, anchor)) {
|
|
484
500
|
return report(
|
|
485
501
|
all,
|
|
486
502
|
{ target, reason: "unknown-anchor", anchor, addressed: true },
|
|
@@ -506,8 +522,13 @@ export function resolveWebWikilinks(body, ctx) {
|
|
|
506
522
|
// Presentation only — the href above is unchanged, and a
|
|
507
523
|
// `[[#anchor]]` self-link is not marked because the reader is
|
|
508
524
|
// already on the page it would be telling them about.
|
|
509
|
-
|
|
510
|
-
|
|
525
|
+
return presentWikilink(
|
|
526
|
+
!hit.draft ? link
|
|
527
|
+
: ctx.output === "book" ? `${link} (draft)`
|
|
528
|
+
: draftLink(link),
|
|
529
|
+
parsed,
|
|
530
|
+
hit,
|
|
531
|
+
);
|
|
511
532
|
}
|
|
512
533
|
|
|
513
534
|
// **An address resolving nowhere is a failure, unconditionally**.
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
15
|
+
import { isGmNote } from "./note-vocabulary.mjs";
|
|
16
|
+
|
|
14
17
|
import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -67,7 +70,7 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
|
67
70
|
* copies of the exclusion is three places for it to be forgotten. An embed's
|
|
68
71
|
* own grammar is {@link module:engine/content-embeds}.
|
|
69
72
|
*/
|
|
70
|
-
export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]
|
|
73
|
+
export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
76
|
* The parts of a wikilink's interior.
|
|
@@ -80,6 +83,7 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
|
|
|
80
83
|
* @property {string} anchor - The `#section` slug, `""` when there is none.
|
|
81
84
|
* @property {string|null} display - The text after `|`, or `null` when the link
|
|
82
85
|
* is unlabelled. `null` and `""` differ: an author may write `[[x|]]`.
|
|
86
|
+
* @property {ReturnType<typeof parseExtensionAttributes>} [attributes] - Trailing extension attributes.
|
|
83
87
|
* @property {boolean} labelled - Whether a `|` was present at all. A link
|
|
84
88
|
* without one addresses nothing and is a finding — see
|
|
85
89
|
* {@link unlabelledLinkMessage} — so every reader has to be able to ask.
|
|
@@ -92,9 +96,10 @@ export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\]/g;
|
|
|
92
96
|
* not the whole link, so a caller that has already matched does not re-match.
|
|
93
97
|
*
|
|
94
98
|
* @param {string} rawInner - The text between `[[` and `]]`.
|
|
99
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
95
100
|
* @returns {ParsedWikilink} The parts, each trimmed.
|
|
96
101
|
*/
|
|
97
|
-
export function parseWikilink(rawInner) {
|
|
102
|
+
export function parseWikilink(rawInner, literal = "") {
|
|
98
103
|
// A pipe inside a markdown table cell is escaped as `\|`; undo that before
|
|
99
104
|
// looking for the label separator, or the cell's own escape reads as one.
|
|
100
105
|
const inner = String(rawInner ?? "").replace(/\\\|/g, "|");
|
|
@@ -108,7 +113,18 @@ export function parseWikilink(rawInner) {
|
|
|
108
113
|
// link and a field cannot disagree about where the anchor starts.
|
|
109
114
|
const { address: target, anchor = "" } = splitAnchor(linkPart);
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
const suffix = literal.slice(literal.indexOf("]]") + 2);
|
|
117
|
+
const attributes =
|
|
118
|
+
suffix.startsWith("{") ? parseExtensionAttributes(suffix.slice(1, -1)) : null;
|
|
119
|
+
if (attributes) attributes.problems.push(...refusedAttributes(attributes.values));
|
|
120
|
+
return {
|
|
121
|
+
inner: inner.trim(),
|
|
122
|
+
target,
|
|
123
|
+
anchor,
|
|
124
|
+
display,
|
|
125
|
+
labelled,
|
|
126
|
+
...(attributes ? { attributes } : {}),
|
|
127
|
+
};
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
/**
|
|
@@ -193,6 +209,7 @@ export function unlabelledLinkMessage(target) {
|
|
|
193
209
|
*/
|
|
194
210
|
export const LINK_FINDING_REASONS = Object.freeze(
|
|
195
211
|
new Set([
|
|
212
|
+
"invalid-attributes",
|
|
196
213
|
"unlabelled",
|
|
197
214
|
"not-an-address",
|
|
198
215
|
"not-lowercase",
|
|
@@ -201,7 +218,6 @@ export const LINK_FINDING_REASONS = Object.freeze(
|
|
|
201
218
|
"not-an-asset",
|
|
202
219
|
"unresolved",
|
|
203
220
|
"stub",
|
|
204
|
-
"gm",
|
|
205
221
|
"ambiguous",
|
|
206
222
|
"unknown-anchor",
|
|
207
223
|
"event-anchor",
|
|
@@ -323,6 +339,7 @@ export function ambiguousAddressMessage(target, packages = []) {
|
|
|
323
339
|
* @param {string} [finding.stub] - For `stub`, where the stub is.
|
|
324
340
|
* @param {string} [finding.shortcode] - For `shortcode-not-lowercase`, the
|
|
325
341
|
* shortcode as written.
|
|
342
|
+
* @param {string[]} [finding.problems] - Invalid attribute diagnostics.
|
|
326
343
|
* @param {string} [finding.declared] - For `shortcode-not-lowercase`, the
|
|
327
344
|
* lowercase spelling, when it is a declared shortcode.
|
|
328
345
|
* @returns {string} The message.
|
|
@@ -338,8 +355,11 @@ export function linkFindingMessage({
|
|
|
338
355
|
stub,
|
|
339
356
|
shortcode,
|
|
340
357
|
declared,
|
|
358
|
+
problems,
|
|
341
359
|
}) {
|
|
342
360
|
switch (reason) {
|
|
361
|
+
case "invalid-attributes":
|
|
362
|
+
return `wikilink [[${target}]] has invalid attributes: ${(problems ?? []).join("; ")}`;
|
|
343
363
|
case "unlabelled":
|
|
344
364
|
return unlabelledLinkMessage(target);
|
|
345
365
|
case "not-an-address":
|
|
@@ -386,8 +406,6 @@ export function linkFindingMessage({
|
|
|
386
406
|
return unresolvedAddressMessage(target);
|
|
387
407
|
case "stub":
|
|
388
408
|
return stubAddressMessage(target, stub);
|
|
389
|
-
case "gm":
|
|
390
|
-
return `address [[${target}]] names a GM-only note from a player-facing note`;
|
|
391
409
|
case "no-content-index":
|
|
392
410
|
return (
|
|
393
411
|
`address [[${target}]] names a package declared \`contentIndex: false\` — ` +
|
|
@@ -419,10 +437,11 @@ export function isSamePage({ target, anchor }) {
|
|
|
419
437
|
* Read an operational link target in its enclosing Address context.
|
|
420
438
|
* @param {string} inner - Lexical link contents.
|
|
421
439
|
* @param {object} context - Builder defaults and type vocabulary.
|
|
440
|
+
* @param {string} [literal] - Whole matched link, including trailing attributes.
|
|
422
441
|
* @returns {object} Typed target and diagnostic source spelling.
|
|
423
442
|
*/
|
|
424
|
-
export function readWikilink(inner, context) {
|
|
425
|
-
const parsed = parseWikilink(inner);
|
|
443
|
+
export function readWikilink(inner, context, literal = "") {
|
|
444
|
+
const parsed = parseWikilink(inner, literal);
|
|
426
445
|
const rawTarget = parsed.target;
|
|
427
446
|
if (!rawTarget) return { ...parsed, rawTarget, target: { kind: "same-page" } };
|
|
428
447
|
const target = parseAddress(rawTarget, context);
|
|
@@ -433,3 +452,41 @@ export function readWikilink(inner, context) {
|
|
|
433
452
|
{ ...parsed, rawTarget, target: { kind: "literal", text: rawTarget }, problem: target }
|
|
434
453
|
: { ...parsed, rawTarget, target };
|
|
435
454
|
}
|
|
455
|
+
|
|
456
|
+
/** Whether the actual referenced destination needs spoiler presentation.
|
|
457
|
+
* @param {object} destination - Resolved note metadata.
|
|
458
|
+
* @param {string} [anchor] - Referenced prose anchor.
|
|
459
|
+
* @returns {boolean} Whole-note or section audience.
|
|
460
|
+
*/
|
|
461
|
+
export function secretDestination(destination, anchor = "") {
|
|
462
|
+
return Boolean(
|
|
463
|
+
destination?.gm ||
|
|
464
|
+
isGmNote(destination?.fm ?? destination) ||
|
|
465
|
+
(anchor && destination?.noteAnchors?.some((one) => one.slug === anchor && one.secret)),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Apply destination-derived presentation without changing its address.
|
|
470
|
+
* @param {string} reference - Rendered reference Markdown.
|
|
471
|
+
* @param {object} parsed - Shared parsed wikilink.
|
|
472
|
+
* @param {object} destination - Resolved target metadata.
|
|
473
|
+
* @returns {string} Inline spoiler or visible reference.
|
|
474
|
+
*/
|
|
475
|
+
export function presentWikilink(reference, parsed, destination) {
|
|
476
|
+
const attrs = parsed.attributes;
|
|
477
|
+
const classes = (attrs?.classes ?? []).filter((value) => value !== "show");
|
|
478
|
+
if (
|
|
479
|
+
!attrs?.classes.includes("show") &&
|
|
480
|
+
secretDestination(destination, parsed.anchor) &&
|
|
481
|
+
!classes.includes("secret")
|
|
482
|
+
)
|
|
483
|
+
classes.push("secret");
|
|
484
|
+
const fields = [
|
|
485
|
+
attrs?.id ? `#${attrs.id}` : "",
|
|
486
|
+
...classes.map((value) => `.${value}`),
|
|
487
|
+
...Object.entries(attrs?.values ?? {}).map(
|
|
488
|
+
([key, value]) => `${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
|
|
489
|
+
),
|
|
490
|
+
].filter(Boolean);
|
|
491
|
+
return fields.length ? `[${reference}]{${fields.join(" ")}}` : reference;
|
|
492
|
+
}
|
package/engine/wikilinks.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { resolveShortcodeReference } from "./shortcode-references.mjs";
|
|
29
29
|
|
|
30
|
-
import { readWikilink } from "./wikilink-syntax.mjs";
|
|
30
|
+
import { presentWikilink, readWikilink } from "./wikilink-syntax.mjs";
|
|
31
31
|
import crypto from "crypto";
|
|
32
32
|
|
|
33
33
|
import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
|
|
@@ -407,15 +407,29 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
407
407
|
// one capture group. It is what makes two identical unresolved links on
|
|
408
408
|
// one note tellable apart, and a position reportable at all.
|
|
409
409
|
const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
|
|
410
|
-
const parsed = readWikilink(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
410
|
+
const parsed = readWikilink(
|
|
411
|
+
rawInner,
|
|
412
|
+
{
|
|
413
|
+
package: index.contentPackage,
|
|
414
|
+
system: "note",
|
|
415
|
+
types: index.types,
|
|
416
|
+
packages: index.packages,
|
|
417
|
+
noIndexPackages: index.noIndexPackages,
|
|
418
|
+
},
|
|
419
|
+
all,
|
|
420
|
+
);
|
|
417
421
|
const target = parsed.rawTarget;
|
|
418
422
|
const slug = parsed.anchor || null;
|
|
423
|
+
if (parsed.attributes?.problems.length) {
|
|
424
|
+
unresolved.push({
|
|
425
|
+
link: all,
|
|
426
|
+
target,
|
|
427
|
+
offset,
|
|
428
|
+
reason: "invalid-attributes",
|
|
429
|
+
problems: parsed.attributes.problems,
|
|
430
|
+
});
|
|
431
|
+
return unresolvedLink(authoredLabel(parsed) ?? target, target);
|
|
432
|
+
}
|
|
419
433
|
|
|
420
434
|
// **Every link carries a label**. Without one there is nothing
|
|
421
435
|
// to resolve against: a shortcode is an address rather than prose, so
|
|
@@ -512,7 +526,11 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
512
526
|
if (hit) {
|
|
513
527
|
const uuid = slug ? hit.anchors?.[slug] : hit.uuid;
|
|
514
528
|
if (uuid) {
|
|
515
|
-
return
|
|
529
|
+
return presentWikilink(
|
|
530
|
+
`@UUID[${uuid}]{${text || hit.name || target}}`,
|
|
531
|
+
parsed,
|
|
532
|
+
hit,
|
|
533
|
+
);
|
|
516
534
|
}
|
|
517
535
|
unresolved.push({
|
|
518
536
|
link: all,
|
|
@@ -559,7 +577,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
559
577
|
const entryUuid = itemDoc ? addresses.docUuid : addresses.uuid;
|
|
560
578
|
// The target has no single Foundry document. Its address is real, but
|
|
561
579
|
// a compendium has nothing to open, so the reader gets the label.
|
|
562
|
-
if (entryUuid === undefined) return text;
|
|
580
|
+
if (entryUuid === undefined) return presentWikilink(text, parsed, doc);
|
|
563
581
|
const entryId = itemDoc ? itemDocEntryId(doc.id) : doc.id;
|
|
564
582
|
const isJournal = itemDoc || packForType(doc.type).docType === "JournalEntry";
|
|
565
583
|
// A JournalEntry link opens a journal — at its first page, or at the
|
|
@@ -614,7 +632,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index, cap
|
|
|
614
632
|
// Presentation only — the UUID above is unchanged, and a
|
|
615
633
|
// `[[#slug]]` self-link is not marked because the reader is already in
|
|
616
634
|
// the note it would be telling them about.
|
|
617
|
-
return doc.draft ? draftLink(link) : link;
|
|
635
|
+
return presentWikilink(doc.draft ? draftLink(link) : link, parsed, doc);
|
|
618
636
|
});
|
|
619
637
|
|
|
620
638
|
return { markdown: out, unresolved };
|
package/engine/yaml-lint.mjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { execFileSync } from "node:child_process";
|
|
30
30
|
import fs from "node:fs";
|
|
31
31
|
import path from "node:path";
|
|
32
|
+
import { splitFrontmatter } from "./diagnostics.mjs";
|
|
32
33
|
|
|
33
34
|
/** File extensions that carry YAML, either wholly or in a frontmatter fence. */
|
|
34
35
|
export const YAML_GLOBS = Object.freeze(["*.md", "*.yaml", "*.yml"]);
|
|
@@ -42,9 +43,6 @@ export const YAML_GLOBS = Object.freeze(["*.md", "*.yaml", "*.yml"]);
|
|
|
42
43
|
*/
|
|
43
44
|
const ALWAYS_SKIP = Object.freeze([".git/", ".claude/"]);
|
|
44
45
|
|
|
45
|
-
/** A markdown file's leading frontmatter fence. Mirrors `parseMarkdownFile`. */
|
|
46
|
-
const FENCE = /^---\n([\s\S]*?)\n---/;
|
|
47
|
-
|
|
48
46
|
/**
|
|
49
47
|
* Present a markdown file to ESLint as the YAML it begins with.
|
|
50
48
|
*
|
|
@@ -68,8 +66,8 @@ export const frontmatterProcessor = {
|
|
|
68
66
|
// container file. Nothing is gained by claiming support for it.
|
|
69
67
|
supportsAutofix: false,
|
|
70
68
|
preprocess(text) {
|
|
71
|
-
const fence =
|
|
72
|
-
return fence ? [{ text: fence
|
|
69
|
+
const fence = splitFrontmatter(text);
|
|
70
|
+
return fence ? [{ text: fence.yaml, filename: "0.yaml" }] : [];
|
|
73
71
|
},
|
|
74
72
|
postprocess(messages) {
|
|
75
73
|
return (messages[0] ?? []).map((message) => ({
|
|
@@ -86,6 +86,10 @@ body {
|
|
|
86
86
|
-webkit-font-smoothing: antialiased;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
.name {
|
|
90
|
+
font-weight: 700;
|
|
91
|
+
}
|
|
92
|
+
|
|
89
93
|
a {
|
|
90
94
|
color: var(--accent);
|
|
91
95
|
text-decoration: none;
|
|
@@ -2988,3 +2992,19 @@ summary.info-sidebar-title::after {
|
|
|
2988
2992
|
.single-content .alert-warning {
|
|
2989
2993
|
--pb-alert-color: #d29922;
|
|
2990
2994
|
}
|
|
2995
|
+
|
|
2996
|
+
/* Inline spoiler controls remain inline, with links outside the button. */
|
|
2997
|
+
[data-inline-secret] .inline-secret-content[hidden] { display: none !important; }
|
|
2998
|
+
.inline-secret-toggle {
|
|
2999
|
+
display: inline;
|
|
3000
|
+
padding: 0.05em 0.35em;
|
|
3001
|
+
border: 1px solid currentColor;
|
|
3002
|
+
border-radius: 0.2em;
|
|
3003
|
+
color: inherit;
|
|
3004
|
+
background: transparent;
|
|
3005
|
+
font: inherit;
|
|
3006
|
+
font-size: 0.85em;
|
|
3007
|
+
cursor: pointer;
|
|
3008
|
+
}
|
|
3009
|
+
.inline-secret-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
3010
|
+
.inline-secret-content { margin-inline-start: 0.25em; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// A native button provides pointer, Enter and Space activation without taking
|
|
9
|
+
// keyboard events away from links within the independently revealed content.
|
|
10
|
+
document.addEventListener("click", (event) => {
|
|
11
|
+
const button = event.target.closest?.(".inline-secret-toggle");
|
|
12
|
+
if (!button) return;
|
|
13
|
+
const wrapper = button.parentElement;
|
|
14
|
+
const content = button.nextElementSibling;
|
|
15
|
+
if (!wrapper?.hasAttribute("data-inline-secret") || !content?.classList.contains("inline-secret-content")) return;
|
|
16
|
+
const expanded = button.getAttribute("aria-expanded") !== "true";
|
|
17
|
+
if (!expanded && content.contains(document.activeElement)) button.focus();
|
|
18
|
+
content.hidden = !expanded;
|
|
19
|
+
button.setAttribute("aria-expanded", String(expanded));
|
|
20
|
+
const label = expanded ? "Hide spoiler" : "Show spoiler";
|
|
21
|
+
button.setAttribute("aria-label", label);
|
|
22
|
+
button.textContent = label;
|
|
23
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.11.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",
|