@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +197 -68
  4. package/docs/api.md +365 -70
  5. package/docs/authoring/first-note.md +10 -0
  6. package/docs/authoring/frontmatter.md +1 -1
  7. package/docs/authoring/links-and-markup.md +186 -49
  8. package/docs/commands.md +92 -3
  9. package/docs/content-format.md +10 -3
  10. package/docs/design.md +18 -0
  11. package/docs/guides/book.md +7 -0
  12. package/docs/guides/site.md +7 -0
  13. package/docs/reference/format-details.md +186 -15
  14. package/docs/reference/note-types.md +4 -4
  15. package/engine/anchors.mjs +43 -9
  16. package/engine/base-compiler.mjs +6 -4
  17. package/engine/changelog-group.mjs +58 -34
  18. package/engine/changelog-lint.mjs +6 -2
  19. package/engine/compendiums.mjs +15 -1
  20. package/engine/compile-corpus.mjs +14 -1
  21. package/engine/content-charset.mjs +13 -1
  22. package/engine/content-html.mjs +18 -4
  23. package/engine/content-icons.mjs +15 -1
  24. package/engine/content-images.mjs +28 -9
  25. package/engine/content-index.mjs +102 -23
  26. package/engine/content-links.mjs +68 -30
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +118 -14
  30. package/engine/content-tables.mjs +1 -0
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/diagnostics.mjs +17 -6
  33. package/engine/extension-attributes.mjs +28 -5
  34. package/engine/first-use.mjs +371 -0
  35. package/engine/foundry-entries.mjs +12 -9
  36. package/engine/generate.mjs +13 -4
  37. package/engine/helpers.mjs +47 -16
  38. package/engine/index-records.mjs +1 -0
  39. package/engine/index.mjs +3 -0
  40. package/engine/journals.mjs +9 -5
  41. package/engine/language-data.mjs +2 -2
  42. package/engine/language-rows.mjs +4 -4
  43. package/engine/language-terms.mjs +4 -4
  44. package/engine/lexical-sections.mjs +5 -5
  45. package/engine/markup-validation.mjs +7 -2
  46. package/engine/metadata-index.mjs +1 -0
  47. package/engine/name-markup.mjs +114 -0
  48. package/engine/name-rows.mjs +70 -0
  49. package/engine/names.mjs +460 -0
  50. package/engine/note-addresses.mjs +25 -4
  51. package/engine/path-charset.mjs +24 -1
  52. package/engine/pdf-render.mjs +15 -1
  53. package/engine/site-index.mjs +25 -2
  54. package/engine/sql-tables.mjs +76 -15
  55. package/engine/stub-lint.mjs +4 -3
  56. package/engine/web-wikilinks.mjs +33 -12
  57. package/engine/wikilink-syntax.mjs +65 -8
  58. package/engine/wikilinks.mjs +29 -11
  59. package/engine/yaml-lint.mjs +3 -5
  60. package/hugo-theme/layouts/partials/footer.html +2 -0
  61. package/hugo-theme/static/css/style.css +20 -0
  62. package/hugo-theme/static/js/inline-spoilers.js +23 -0
  63. package/package.json +1 -1
  64. package/types/engine/anchors.d.mts +6 -3
  65. package/types/engine/compendiums.d.mts +5 -1
  66. package/types/engine/compile-corpus.d.mts +5 -2
  67. package/types/engine/content-charset.d.mts +5 -1
  68. package/types/engine/content-html.d.mts +5 -1
  69. package/types/engine/content-icons.d.mts +5 -1
  70. package/types/engine/content-images.d.mts +5 -1
  71. package/types/engine/content-index.d.mts +39 -19
  72. package/types/engine/content-links.d.mts +22 -8
  73. package/types/engine/content-session.d.mts +108 -0
  74. package/types/engine/content-sources.d.mts +32 -0
  75. package/types/engine/content-spans.d.mts +8 -6
  76. package/types/engine/content-tasklists.d.mts +5 -1
  77. package/types/engine/diagnostics.d.mts +10 -0
  78. package/types/engine/extension-attributes.d.mts +16 -2
  79. package/types/engine/first-use.d.mts +31 -0
  80. package/types/engine/foundry-entries.d.mts +11 -4
  81. package/types/engine/generate.d.mts +5 -1
  82. package/types/engine/helpers.d.mts +30 -7
  83. package/types/engine/index.d.mts +1 -0
  84. package/types/engine/journals.d.mts +5 -2
  85. package/types/engine/lexical-sections.d.mts +3 -2
  86. package/types/engine/markup-validation.d.mts +2 -1
  87. package/types/engine/name-markup.d.mts +176 -0
  88. package/types/engine/name-rows.d.mts +35 -0
  89. package/types/engine/names.d.mts +356 -0
  90. package/types/engine/note-addresses.d.mts +3 -3
  91. package/types/engine/path-charset.d.mts +16 -1
  92. package/types/engine/site-build.d.mts +2 -2
  93. package/types/engine/site-index.d.mts +3 -1
  94. package/types/engine/sql-tables.d.mts +7 -2
  95. package/types/engine/stub-lint.d.mts +3 -1
  96. package/types/engine/wikilink-syntax.d.mts +40 -3
@@ -70,7 +70,7 @@ import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
70
70
  import { linkFindingMessage } from "./wikilink-syntax.mjs";
71
71
  // The declared tag vocabulary, which is where `draft` is stated. Read
72
72
  // from there rather than respelt, so the tag and its one reader cannot drift.
73
- import { isDraftNote } from "./note-vocabulary.mjs";
73
+ import { isGmNote, isDraftNote } from "./note-vocabulary.mjs";
74
74
  import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
75
75
  import { expandContentTables } from "./content-tables.mjs";
76
76
  import { spanMarkdownPlugin } from "./content-spans.mjs";
@@ -78,7 +78,7 @@ import { dictionaryEntryPlugin } from "./dictionary-layout.mjs";
78
78
  import { renderBlocks } from "./content-blocks.mjs";
79
79
  import { headingAttributesPlugin } from "./heading-attributes.mjs";
80
80
  import { renderFigureBlocks, scanFigures } from "./content-figures.mjs";
81
- import { positionInBody } from "./diagnostics.mjs";
81
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
82
82
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
83
83
  // registry can import them without reaching back through this one.
84
84
  // Re-exported here so every existing importer keeps its single import path.
@@ -108,7 +108,7 @@ let localAssetImageInfoCache;
108
108
  export const md = markdownit({ html: true })
109
109
  .use(footnotePlugin)
110
110
  .use(deflistPlugin)
111
- .use(spanMarkdownPlugin)
111
+ .use(spanMarkdownPlugin, { spoilers: true })
112
112
  .use(dictionaryEntryPlugin)
113
113
  // A heading's attribute block, written onto the heading element rather than
114
114
  // typeset into it — the reading the website's renderer does for itself.
@@ -233,7 +233,7 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
233
233
  }
234
234
 
235
235
  /**
236
- * Parses a markdown file with YAML frontmatter.
236
+ * Parses a markdown file with LF or CRLF YAML frontmatter delimiters.
237
237
  *
238
238
  * Returns `{ frontmatter, body, description, bodyLine, bodyColumn, raw }` where
239
239
  * `body` is the trimmed raw markdown after the frontmatter block, and
@@ -245,20 +245,27 @@ export function renderFoundryMarkdown(body, figures, footnoteNumbers, docId, res
245
245
  * `{ frontmatter: null, body: "", description: "", raw }` and no
246
246
  * position: there is no body to have one.
247
247
  * @param {string} filePath - Markdown file to read.
248
- * @param {{addressContext?: object}} [opts] - Address defaults for decoded fields.
248
+ * @param {object} [opts]
249
+ * @param {object} [opts.addressContext] - Address defaults for decoded fields.
250
+ * @param {string} [opts.raw] - Exact captured source; omitted, reads the file.
251
+ * @param {boolean} [opts.renderDescription=true] - Render the body's HTML description.
252
+ * False leaves `description` empty for readers that only use authored text.
249
253
  * @returns {{frontmatter: object|null, body: string, description: string, raw: string,
250
254
  * bodyLine?: number, bodyColumn?: number, parseError?: {message: string,
251
255
  * line?: number, column?: number}}} Parsed note and body position.
252
256
  */
253
- export function parseMarkdownFile(filePath, { addressContext } = {}) {
254
- const content = fs.readFileSync(filePath, "utf8");
255
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
257
+ export function parseMarkdownFile(
258
+ filePath,
259
+ { addressContext, raw: capturedRaw, renderDescription = true } = {},
260
+ ) {
261
+ const content = capturedRaw ?? fs.readFileSync(filePath, "utf8");
262
+ const fmMatch = splitFrontmatter(content);
256
263
  if (!fmMatch) {
257
264
  return { frontmatter: null, body: "", description: "", raw: content };
258
265
  }
259
266
  let frontmatter;
260
267
  try {
261
- frontmatter = yaml.parse(fmMatch[1]) || {};
268
+ frontmatter = yaml.parse(fmMatch.yaml) || {};
262
269
  } catch (err) {
263
270
  const firstLine = String(err.message).split("\n", 1)[0];
264
271
  const message = firstLine.replace(/ at line \d+, column \d+.*$/, "");
@@ -279,7 +286,7 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
279
286
  try {
280
287
  decodeNoteAddresses(frontmatter, addressContext);
281
288
  } catch (error) {
282
- const position = positionOfYamlPath(fmMatch[1], error.keyPath ?? [], {
289
+ const position = positionOfYamlPath(fmMatch.yaml, error.keyPath ?? [], {
283
290
  key: error.addressKey,
284
291
  });
285
292
  if (position.line) position.line++;
@@ -288,9 +295,9 @@ export function parseMarkdownFile(filePath, { addressContext } = {}) {
288
295
  throw error;
289
296
  }
290
297
  }
291
- const raw = fmMatch[2];
298
+ const raw = fmMatch.body;
292
299
  const body = raw.trim();
293
- const description = body ? renderFoundryMarkdown(body) : "";
300
+ const description = body && renderDescription ? renderFoundryMarkdown(body) : "";
294
301
  // Where the trimmed body starts in the *file*, so an offset within it can
295
302
  // be reported as a file position. The frontmatter's lines and the
296
303
  // blank lines `trim()` removes both sit in between, and the trim can take
@@ -376,13 +383,19 @@ export function assertSuppliedCorpus(records, who) {
376
383
  * Required: the scope is the caller's to state, so two passes cannot
377
384
  * disagree about which files are the corpus.
378
385
  * @param {object} [opts.addressContext] - Address resolution context.
386
+ * @param {Map<string, {raw: string}>} [opts.sources] - Selected sources in walk order.
387
+ * Supplied, the walk parses these strings instead of reading the filesystem.
388
+ * @param {boolean} [opts.renderDescription=true] - Render each body's description.
379
389
  * @yields {{frontmatter: object|null, body: string, description: string,
380
390
  * file: string, absPath: string, bodyLine?: number, bodyColumn?: number}}
381
391
  * One entry per `.md` file found.
382
392
  * @throws {Error} When `skipDirectories` is not stated — see
383
393
  * {@link assertStatedScope}.
384
394
  */
385
- export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } = {}) {
395
+ export function* walkMarkdownTree(
396
+ rootDir,
397
+ { skipDirectories, addressContext, sources, renderDescription = true } = {},
398
+ ) {
386
399
  // Stated by the caller, never resolved here. A default here
387
400
  // — `loadPackConfig().skipDirectories` — read whichever configuration
388
401
  // resolved from the working directory rather than the one the caller was
@@ -395,6 +408,20 @@ export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } =
395
408
  // the ambient config rather than the passed one, which a fixture can pass
396
409
  // on indefinitely.
397
410
  assertStatedScope(skipDirectories, "walkMarkdownTree");
411
+ if (sources) {
412
+ for (const [absPath, source] of sources) {
413
+ yield {
414
+ ...parseMarkdownFile(absPath, {
415
+ addressContext,
416
+ raw: source.raw,
417
+ renderDescription,
418
+ }),
419
+ file: path.basename(absPath),
420
+ absPath,
421
+ };
422
+ }
423
+ return;
424
+ }
398
425
  if (!fs.existsSync(rootDir)) return;
399
426
  const stack = [rootDir];
400
427
  while (stack.length > 0) {
@@ -413,7 +440,7 @@ export function* walkMarkdownTree(rootDir, { skipDirectories, addressContext } =
413
440
  stack.push(absPath);
414
441
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
415
442
  yield {
416
- ...parseMarkdownFile(absPath, { addressContext }),
443
+ ...parseMarkdownFile(absPath, { addressContext, renderDescription }),
417
444
  file: entry.name,
418
445
  absPath,
419
446
  };
@@ -785,12 +812,14 @@ import { collectAnchors } from "./anchors.mjs";
785
812
  * compile and handed in. Required: see {@link assertSuppliedCorpus}.
786
813
  * @param {Map<string, object[]>} [opts.sqlTables] - Prepared directives,
787
814
  * including the anchors and Foundry page maps of their expanded bodies.
815
+ * @param {ReturnType<import("./metadata-index.mjs").loadForeignIndexes>} [opts.foreign] -
816
+ * Captured dependency address and reference views for this context.
788
817
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
789
818
  */
790
819
  export function buildContentLinkIndex(
791
820
  contentBase,
792
821
  router = packRouter(),
793
- { config, records, sqlTables } = {},
822
+ { config, records, sqlTables, foreign: capturedForeign } = {},
794
823
  ) {
795
824
  const docs = [];
796
825
  /** The files this package ships, by canonical address. */
@@ -859,6 +888,8 @@ export function buildContentLinkIndex(
859
888
  // *into* this note renders marked. It takes no part in resolution,
860
889
  // so the note is indexed, compiled and published as any other.
861
890
  draft: isDraftNote(fm),
891
+ gm: isGmNote(fm),
892
+ noteAnchors: [...(record.anchors ?? []), ...(prepared?.anchors ?? [])],
862
893
  // The anchors this note declares, carried so the *builds* can check
863
894
  // a `#section` link and not only the checker. A foreign
864
895
  // anchor has always been checked, because a fetched index
@@ -892,7 +923,7 @@ export function buildContentLinkIndex(
892
923
  index: foreign,
893
924
  references: foreignReferences,
894
925
  stale,
895
- } = loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
926
+ } = capturedForeign ?? loadForeignIndexes(resolved, [resolved.contentPackage], PACKAGE_BASE);
896
927
  if (stale.length) {
897
928
  for (const st of stale) {
898
929
  log.error(`Unusable content index for "${st.package}": ${st.reason}`);
@@ -63,6 +63,7 @@ export const DERIVED_KEYS = Object.freeze([
63
63
  "asset",
64
64
  "resolvedDates",
65
65
  "citations",
66
+ "names",
66
67
  ]);
67
68
 
68
69
  /**
package/engine/index.mjs CHANGED
@@ -187,6 +187,9 @@ export * as foundryEntries from "./foundry-entries.mjs";
187
187
  /** Publishing the note tree as a queryable JSON Lines index. */
188
188
  export * as contentIndex from "./content-index.mjs";
189
189
 
190
+ /** One invocation's captured content and context-specific prepared views. */
191
+ export * as contentSession from "./content-session.mjs";
192
+
190
193
  /** The asset types — `icon`, `image`, `audio` — and the roots they are walked from. */
191
194
  export * as assetTypes from "./asset-types.mjs";
192
195
 
@@ -103,9 +103,9 @@ const WITHHELD_OWNERSHIP = 0;
103
103
  * @param {string} markdown - A note's body, as {@link splitPages} receives it.
104
104
  * @returns {Set<number>} 0-based line indices inside a named block.
105
105
  */
106
- function namedBlockLines(markdown) {
106
+ function namedBlockLines(markdown, syntax) {
107
107
  const lines = new Set();
108
- for (const { start, end } of scanBlocks(markdown).blocks) {
108
+ for (const { start, end } of (syntax ? syntax(markdown).blocks : scanBlocks(markdown)).blocks) {
109
109
  for (let i = start; i <= end; i++) lines.add(i);
110
110
  }
111
111
  return lines;
@@ -142,17 +142,18 @@ function namedBlockLines(markdown) {
142
142
  * picture's address to the role its asset declares, so a figure page's
143
143
  * name reads `Map 1` rather than `Figure 1` where it is due — see
144
144
  * {@link module:engine/content-figures.scanFigures}.
145
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Session scanner projections.
145
146
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
146
147
  * classes: string[], figure: object|null, continues?: string,
147
148
  * markdown: string}>} Pages in document order.
148
149
  */
149
- export function splitPages(body, leadName = "Introduction", resolveRole) {
150
+ export function splitPages(body, leadName = "Introduction", resolveRole, syntax) {
150
151
  const { markdown, definitions } = separateFootnotes(body);
151
152
  const lines = markdown.split("\n");
152
153
  const figureStarts = new Map(
153
154
  scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
154
155
  );
155
- const blockLines = namedBlockLines(markdown);
156
+ const blockLines = namedBlockLines(markdown, syntax);
156
157
  const openings = pageOpenings(markdown, blockLines);
157
158
  const pages = [];
158
159
  const beforeFirstH1 = [];
@@ -636,6 +637,7 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
636
637
  * a picture's address to the role its asset declares, so a figure page's
637
638
  * name reads `Map 1` rather than `Figure 1` where it is due — see
638
639
  * {@link module:engine/content-figures.scanFigures}.
640
+ * @param {import("./content-session.mjs").ContentSyntax} [params.syntax] - Session scanner projections.
639
641
  * @returns {object} The JournalEntry document, keyed for the pack.
640
642
  */
641
643
  export function buildJournalEntry({
@@ -648,8 +650,9 @@ export function buildJournalEntry({
648
650
  infoboxes = [],
649
651
  notice = "",
650
652
  resolveRole,
653
+ syntax,
651
654
  }) {
652
- const rawPages = splitPages(markdown, leadName, resolveRole);
655
+ const rawPages = splitPages(markdown, leadName, resolveRole, syntax);
653
656
  const pages = buildPages(
654
657
  rawPages,
655
658
  id,
@@ -827,6 +830,7 @@ export class Journals extends BasePackCompiler {
827
830
  const roles = pathnameRoles(this.linkIndex);
828
831
 
829
832
  return buildJournalEntry({
833
+ syntax: this.corpus?.syntax,
830
834
  id,
831
835
  name,
832
836
  markdown: [
@@ -44,7 +44,7 @@
44
44
  * syllables, its consonants per vowel or both, each range's `min` no
45
45
  * higher than its `max`; an edge stands once for each class of name; a kind
46
46
  * of name has one set of draws; and a held-from row takes `elements` only
47
- * where it takes an `element`, and is clipped or given a genitive only
47
+ * where it takes an `element`, and is clipped or given a joining ending only
48
48
  * where it takes a name.
49
49
  *
50
50
  * @module
@@ -627,7 +627,7 @@ export function checkLanguageData(note, { index, types } = {}) {
627
627
  ),
628
628
  );
629
629
  if (!HELD_NAMES.includes(row.takes))
630
- for (const key of ["clip", "genitive"])
630
+ for (const key of ["clip", "joining"])
631
631
  if (row[key] != null)
632
632
  findings.push(
633
633
  finding(
@@ -122,7 +122,7 @@ export const HELD_FROM_COLUMNS = Object.freeze([
122
122
  "source",
123
123
  "takes",
124
124
  "clip",
125
- "genitive",
125
+ "joining",
126
126
  "elements",
127
127
  ]);
128
128
  /** Every column of the `near_names` relation, in order. */
@@ -232,7 +232,7 @@ export const LANGUAGE_COLUMN_TYPES = Object.freeze({
232
232
  },
233
233
  name_edges: { position: "BIGINT", sounds: LIST, vowel: "BOOLEAN" },
234
234
  name_draws: { position: "BIGINT", elements: LIST },
235
- held_from: { position: "BIGINT", genitive: LIST, elements: LIST },
235
+ held_from: { position: "BIGINT", joining: LIST, elements: LIST },
236
236
  near_names: { position: "BIGINT", min_syllables: "BIGINT" },
237
237
  stress: {},
238
238
  finals: { position: "BIGINT" },
@@ -653,8 +653,8 @@ export function heldFromRows(records) {
653
653
  return listRows(
654
654
  records,
655
655
  (data) => namingOf(data).heldFrom,
656
- ["source", "takes", "clip", "genitive", "elements"],
657
- { lists: ["genitive", "elements"] },
656
+ ["source", "takes", "clip", "joining", "elements"],
657
+ { lists: ["joining", "elements"] },
658
658
  );
659
659
  }
660
660
 
@@ -1142,10 +1142,10 @@ export const ELEMENT_FIELDS = Object.freeze([
1142
1142
  "ancestor.",
1143
1143
  }),
1144
1144
  Object.freeze({
1145
- name: "genitive",
1145
+ name: "joining",
1146
1146
  kind: "string",
1147
1147
  shape: "the element as written, such as `-borgar`",
1148
- describe: "The element in the genitive, where a name governing another word takes it.",
1148
+ describe: "The element's joining form, as written when it qualifies another word.",
1149
1149
  }),
1150
1150
  ]);
1151
1151
 
@@ -1472,11 +1472,11 @@ export const HELD_FROM_FIELDS = Object.freeze([
1472
1472
  describe: "How the name is clipped: `first`, to its first element; whole where absent.",
1473
1473
  }),
1474
1474
  Object.freeze({
1475
- name: "genitive",
1475
+ name: "joining",
1476
1476
  kind: "list",
1477
1477
  shape: "a list of endings",
1478
1478
  entries: STRINGS,
1479
- describe: "The genitive endings, one of which stands at the seam after the name.",
1479
+ describe: "The joining endings, one of which stands at the seam after the name.",
1480
1480
  }),
1481
1481
  Object.freeze({
1482
1482
  name: "elements",
@@ -162,8 +162,8 @@ export function lexicalSectionsFor(records, all = records) {
162
162
  }
163
163
 
164
164
  /** Authored section headings outside literal content and named containers. */
165
- function headings(markdown) {
166
- const containers = scanBlocks(markdown).blocks;
165
+ function headings(markdown, syntax) {
166
+ const containers = (syntax ? syntax(markdown).blocks : scanBlocks(markdown)).blocks;
167
167
  return scanHeadingAttributes(markdown)
168
168
  .headings.filter(
169
169
  (heading) =>
@@ -181,7 +181,7 @@ function headings(markdown) {
181
181
  *
182
182
  * @param {string} markdown - The body with authored directives expanded.
183
183
  * @param {LexicalSection[]} [sections=[]] - From {@link lexicalSectionsFor}.
184
- * @param {{authored?: string, lineMap?: object[], generatedFrom?: number}} [context] -
184
+ * @param {{authored?: string, lineMap?: object[], generatedFrom?: number, syntax?: import("./content-session.mjs").ContentSyntax}} [context] -
185
185
  * The original authored body, source mappings and the start of appended
186
186
  * nonlexical generated sections. Without `authored`, `markdown` is the source.
187
187
  * @returns {{markdown: string, lineMap: object[], generatedFrom?: number}}
@@ -189,7 +189,7 @@ function headings(markdown) {
189
189
  export function insertLexicalSections(
190
190
  markdown,
191
191
  sections = [],
192
- { authored = markdown, lineMap, generatedFrom } = {},
192
+ { authored = markdown, lineMap, generatedFrom, syntax } = {},
193
193
  ) {
194
194
  const lines = String(markdown ?? "").split("\n");
195
195
  const locations = lineMap ? [...lineMap] : lines.map((_, line) => ({ line, generated: false }));
@@ -200,7 +200,7 @@ export function insertLexicalSections(
200
200
  ...(generatedFrom === undefined ? {} : { generatedFrom }),
201
201
  };
202
202
  const edits = [];
203
- const realHeadings = headings(String(authored ?? ""))
203
+ const realHeadings = headings(String(authored ?? ""), syntax)
204
204
  .map((heading) => ({
205
205
  ...heading,
206
206
  sourceLine: heading.line,
@@ -19,14 +19,19 @@ import { scanAlerts } from "./content-alerts.mjs";
19
19
  * Frontmatter is masked so literal YAML values cannot become body markup.
20
20
  * @param {string} source - Complete Markdown source, optionally with frontmatter.
21
21
  * @param {string} file - Diagnostic filename.
22
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
22
23
  * @returns {Array<{file: string, line: number, column: number, severity: string, message: string}>} Syntax errors.
23
24
  */
24
- export function markupFindings(source, file) {
25
+ export function markupFindings(source, file, syntax) {
25
26
  const body = String(source ?? "").replace(
26
27
  /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,
27
28
  (frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
28
29
  );
29
- return [...scanBlocks(body).errors, ...scanAlerts(body).errors].map((error) => ({
30
+ const scanned = syntax?.(body);
31
+ return [
32
+ ...(scanned?.blocks ?? scanBlocks(body)).errors,
33
+ ...(scanned?.alerts ?? scanAlerts(body)).errors,
34
+ ].map((error) => ({
30
35
  file,
31
36
  ...error,
32
37
  severity: "error",
@@ -445,6 +445,7 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
445
445
  const foundry = record.foundry?.[parts.system];
446
446
  index.set(key, {
447
447
  name: record.name?.full ?? record.name,
448
+ aliases: record.name?.aliases,
448
449
  // Absent where the package publishes no page for it. A consumer
449
450
  // must tolerate that rather than invent an href, exactly as it
450
451
  // already tolerates an entry with no `uuid`.
@@ -0,0 +1,114 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /** Pure projections of parsed inline text and original marked-name positions. */
15
+
16
+ /**
17
+ * An original source range with its opening file position.
18
+ * @typedef {object} SpanSourceSite
19
+ * @property {number} start - Inclusive UTF-16 source offset.
20
+ * @property {number} end - Exclusive UTF-16 source offset.
21
+ * @property {number} line - Original 1-based source line.
22
+ * @property {number} column - Original 1-based UTF-16 source column.
23
+ */
24
+
25
+ /**
26
+ * The retained attribute key and value at their authored positions.
27
+ * @typedef {object} SpanAttributePosition
28
+ * @property {SpanSourceSite} key - Attribute name, without surrounding whitespace.
29
+ * @property {SpanSourceSite} value - Authored value, without its surrounding quotes.
30
+ */
31
+
32
+ /**
33
+ * One actual bracketed span and its renderer-derived visible text.
34
+ * @typedef {object} ContentSpan
35
+ * @property {string} id - Normalized anchor, or an empty string.
36
+ * @property {number} start - Inclusive UTF-16 offset of the opening bracket.
37
+ * @property {number} end - Exclusive UTF-16 offset after the complete span.
38
+ * @property {number} line - Original 1-based line of the opening bracket.
39
+ * @property {number} column - Original 1-based UTF-16 column of the bracket.
40
+ * @property {string} text - Inline label source passed to the existing parser.
41
+ * @property {number} contentEnd - Original UTF-16 offset of the closing label bracket.
42
+ * @property {string} visible - Visible text from the actual parsed children.
43
+ * @property {ReadonlyArray<readonly [string, string]>} attrs - Retained permitted HTML attributes.
44
+ * @property {Record<string, SpanAttributePosition>} attributePositions - Authored key/value sites by retained attribute name.
45
+ */
46
+
47
+ /**
48
+ * One located span-grammar or refused-attribute finding.
49
+ * @typedef {object} SpanFinding
50
+ * @property {number} line - Original 1-based source line.
51
+ * @property {number} column - Original 1-based UTF-16 source column.
52
+ * @property {string} message - The existing grammar or attribute diagnostic.
53
+ */
54
+
55
+ /**
56
+ * A captured authored span inventory.
57
+ * @typedef {object} SpanScan
58
+ * @property {ContentSpan[]} spans - All actual parsed bracketed spans in source order.
59
+ * @property {ContentSpan[]} names - The same span objects carrying the exact name class.
60
+ * @property {SpanFinding[]} errors - Located diagnostics in source order.
61
+ */
62
+
63
+ /**
64
+ * Visible name text from the renderer's actual parsed children. Footnote
65
+ * markers and images supply no name text; references have already resolved.
66
+ * @param {readonly object[]} tokens - Parsed inline tokens.
67
+ * @returns {string} Text without inline formatting or annotation markers.
68
+ */
69
+ export function inlineText(tokens) {
70
+ return tokens
71
+ .map((token) => {
72
+ if (token.children && token.type !== "image") return inlineText(token.children);
73
+ if (token.type === "text" || token.type === "code_inline") return token.content;
74
+ if (token.type === "softbreak" || token.type === "hardbreak") return " ";
75
+ return "";
76
+ })
77
+ .join("");
78
+ }
79
+
80
+ /**
81
+ * Rebase an authored body's captured names to its original raw file.
82
+ * The inventory is read only; no source parsing or second marker scan occurs.
83
+ * @template {ContentSpan} Span
84
+ * @param {readonly Span[]} spans - The captured body's name spans.
85
+ * @param {{raw: string, bodyLine?: number, bodyColumn?: number}} source - Original body origin.
86
+ * @returns {Span[]} Copies retaining the span metadata with raw offsets and file coordinates.
87
+ */
88
+ export function rebaseNameSpans(spans, { raw, bodyLine = 1, bodyColumn = 1 }) {
89
+ let origin = 0;
90
+ for (let line = 1; line < bodyLine; line++) origin = raw.indexOf("\n", origin) + 1;
91
+ origin += bodyColumn - 1;
92
+ /**
93
+ * @template {SpanSourceSite} Site
94
+ * @param {Site} position - Captured relative range and opening position.
95
+ * @returns {Site} The same metadata at original file coordinates.
96
+ */
97
+ const rebase = (position) => ({
98
+ ...position,
99
+ start: position.start + origin,
100
+ end: position.end + origin,
101
+ line: bodyLine + position.line - 1,
102
+ column: position.column + (position.line === 1 ? bodyColumn - 1 : 0),
103
+ });
104
+ return spans.map((span) => ({
105
+ ...rebase(span),
106
+ contentEnd: span.contentEnd + origin,
107
+ attributePositions: Object.fromEntries(
108
+ Object.entries(span.attributePositions ?? {}).map(([key, positions]) => [
109
+ key,
110
+ { key: rebase(positions.key), value: rebase(positions.value) },
111
+ ]),
112
+ ),
113
+ }));
114
+ }
@@ -0,0 +1,70 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Authored `.name` occurrences projected from own-note index records into the
16
+ * SQL `names` relation. Documentation records and assets contribute no rows.
17
+ * @module
18
+ */
19
+
20
+ import { canonicalKey, readCanonicalKey } from "./address.mjs";
21
+ import { encodeAddresses } from "./address-values.mjs";
22
+ import { isNoteRecord } from "./index-records.mjs";
23
+
24
+ /** Every column of the `names` relation, in order. */
25
+ export const NAME_COLUMNS = Object.freeze(["owner", "name", "type", "subType", "line", "column"]);
26
+
27
+ /**
28
+ * @typedef {object} NameRow
29
+ * @property {string|null} owner - Containing authored page's canonical NOTE Address.
30
+ * @property {string} name - Rendered visible text of the occurrence.
31
+ * @property {string|null} type - Supplied note type, or null.
32
+ * @property {string|null} subType - Supplied subtype, or null.
33
+ * @property {number} line - Original 1-based source line at the opening bracket.
34
+ * @property {number} column - Original 1-based UTF-16 source column at that bracket.
35
+ */
36
+
37
+ /**
38
+ * The containing page's NOTE identity, including for a native Item source.
39
+ * @param {Record<string, any>} record - An own-note index record.
40
+ * @returns {string|null} The page Address, or null for an unaddressable record.
41
+ */
42
+ function ownerOf(record) {
43
+ const parts = readCanonicalKey(encodeAddresses(record.address?.canonical));
44
+ return parts ? canonicalKey(parts.package, "note", parts.type, parts.shortcode) : null;
45
+ }
46
+
47
+ /**
48
+ * Project captured occurrences without reading sources or changing records.
49
+ * @param {Iterable<Record<string, any>>} records - Index records, already narrowed
50
+ * to the audience; canonical Addresses may be typed or encoded strings.
51
+ * @returns {NameRow[]} One row per occurrence, in record then source order.
52
+ */
53
+ export function namesRows(records) {
54
+ const rows = [];
55
+ for (const record of records) {
56
+ if (!record || !isNoteRecord(record) || !Array.isArray(record.names)) continue;
57
+ const owner = ownerOf(record);
58
+ for (const entry of record.names) {
59
+ rows.push({
60
+ owner,
61
+ name: entry.name,
62
+ type: entry.type ?? null,
63
+ subType: entry.subType ?? null,
64
+ line: entry.line,
65
+ column: entry.column,
66
+ });
67
+ }
68
+ }
69
+ return rows;
70
+ }