@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/bin/content-commands.mjs +207 -70
  4. package/docs/api.md +646 -78
  5. package/docs/authoring/first-note.md +1 -1
  6. package/docs/authoring/links-and-markup.md +253 -102
  7. package/docs/commands.md +111 -13
  8. package/docs/content-format.md +7 -2
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/guides/packs.md +2 -1
  11. package/docs/guides/reference-pages.md +86 -63
  12. package/docs/reference/format-details.md +398 -137
  13. package/docs/reference/note-types.md +188 -174
  14. package/engine/anchors.mjs +15 -26
  15. package/engine/base-compiler.mjs +6 -4
  16. package/engine/changelog-group.mjs +58 -34
  17. package/engine/changelog-lint.mjs +6 -2
  18. package/engine/compendiums.mjs +15 -1
  19. package/engine/compile-corpus.mjs +17 -3
  20. package/engine/content-charset.mjs +13 -1
  21. package/engine/content-format.yaml +1 -0
  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 +496 -16
  26. package/engine/content-links.mjs +70 -27
  27. package/engine/content-session.mjs +247 -0
  28. package/engine/content-sources.mjs +110 -0
  29. package/engine/content-spans.mjs +87 -8
  30. package/engine/content-tables.mjs +9 -4
  31. package/engine/content-tasklists.mjs +20 -4
  32. package/engine/culture-reach.mjs +61 -34
  33. package/engine/data-keys.mjs +3 -1
  34. package/engine/diagnostics.mjs +17 -6
  35. package/engine/dictionary-layout.mjs +151 -19
  36. package/engine/dictionary-rows.mjs +129 -79
  37. package/engine/extension-attributes.mjs +28 -5
  38. package/engine/first-use.mjs +371 -0
  39. package/engine/foundry-entries.mjs +29 -8
  40. package/engine/generate.mjs +13 -4
  41. package/engine/glossary-entries.mjs +11 -11
  42. package/engine/heading-attributes.mjs +21 -28
  43. package/engine/helpers.mjs +54 -19
  44. package/engine/index-records.mjs +1 -0
  45. package/engine/index.mjs +3 -0
  46. package/engine/journals.mjs +13 -6
  47. package/engine/language-data.mjs +55 -2
  48. package/engine/language-rows.mjs +4 -4
  49. package/engine/language-terms.mjs +90 -4
  50. package/engine/language-words.mjs +2 -2
  51. package/engine/lexical-sections.mjs +247 -0
  52. package/engine/markup-validation.mjs +7 -2
  53. package/engine/metadata-index.mjs +1 -0
  54. package/engine/name-markup.mjs +114 -0
  55. package/engine/name-rows.mjs +70 -0
  56. package/engine/names.mjs +460 -0
  57. package/engine/note-addresses.mjs +25 -4
  58. package/engine/note-claims.mjs +2 -0
  59. package/engine/note-state.mjs +9 -3
  60. package/engine/note-vocabulary.mjs +7 -6
  61. package/engine/path-charset.mjs +24 -1
  62. package/engine/pdf-build.mjs +9 -9
  63. package/engine/pdf-render.mjs +15 -1
  64. package/engine/site-build.mjs +16 -2
  65. package/engine/site-index.mjs +5 -1
  66. package/engine/sql-tables.mjs +324 -44
  67. package/engine/stub-lint.mjs +10 -5
  68. package/engine/web-wikilinks.mjs +8 -6
  69. package/engine/word-rows.mjs +20 -12
  70. package/engine/word-terms.mjs +15 -6
  71. package/engine/yaml-lint.mjs +3 -5
  72. package/hugo-theme/static/css/style.css +4 -0
  73. package/package.json +1 -1
  74. package/types/engine/anchors.d.mts +4 -2
  75. package/types/engine/compendiums.d.mts +5 -1
  76. package/types/engine/compile-corpus.d.mts +5 -2
  77. package/types/engine/content-charset.d.mts +5 -1
  78. package/types/engine/content-html.d.mts +5 -1
  79. package/types/engine/content-icons.d.mts +5 -1
  80. package/types/engine/content-images.d.mts +5 -1
  81. package/types/engine/content-index.d.mts +102 -18
  82. package/types/engine/content-links.d.mts +22 -8
  83. package/types/engine/content-session.d.mts +108 -0
  84. package/types/engine/content-sources.d.mts +32 -0
  85. package/types/engine/content-spans.d.mts +3 -5
  86. package/types/engine/content-tables.d.mts +2 -1
  87. package/types/engine/content-tasklists.d.mts +5 -1
  88. package/types/engine/culture-reach.d.mts +22 -2
  89. package/types/engine/data-keys.d.mts +7 -0
  90. package/types/engine/diagnostics.d.mts +10 -0
  91. package/types/engine/dictionary-layout.d.mts +35 -4
  92. package/types/engine/dictionary-rows.d.mts +9 -0
  93. package/types/engine/extension-attributes.d.mts +16 -2
  94. package/types/engine/first-use.d.mts +31 -0
  95. package/types/engine/foundry-entries.d.mts +14 -5
  96. package/types/engine/generate.d.mts +5 -1
  97. package/types/engine/heading-attributes.d.mts +2 -2
  98. package/types/engine/helpers.d.mts +33 -7
  99. package/types/engine/index.d.mts +1 -0
  100. package/types/engine/journals.d.mts +5 -2
  101. package/types/engine/language-terms.d.mts +7 -0
  102. package/types/engine/lexical-sections.d.mts +96 -0
  103. package/types/engine/markup-validation.d.mts +2 -1
  104. package/types/engine/name-markup.d.mts +176 -0
  105. package/types/engine/name-rows.d.mts +35 -0
  106. package/types/engine/names.d.mts +356 -0
  107. package/types/engine/note-addresses.d.mts +3 -3
  108. package/types/engine/note-state.d.mts +2 -1
  109. package/types/engine/path-charset.d.mts +16 -1
  110. package/types/engine/site-build.d.mts +6 -3
  111. package/types/engine/site-index.d.mts +3 -1
  112. package/types/engine/sql-tables.d.mts +39 -4
  113. package/types/engine/stub-lint.d.mts +3 -1
  114. package/types/engine/word-rows.d.mts +9 -7
@@ -0,0 +1,247 @@
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
+ * Automatic dictionaries and glossaries, derived synchronously from the same
16
+ * rows their SQL layouts read. An existing anchored section receives its list
17
+ * after its authored text; otherwise the note receives a new final section.
18
+ * The insertion and the publisher read the same Markdown, on every surface.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import { renderAddress } from "./address.mjs";
24
+ import { encodeAddresses } from "./address-values.mjs";
25
+ import { scanBlocks } from "./content-blocks.mjs";
26
+ import { renderDictionary, renderGlossary } from "./dictionary-layout.mjs";
27
+ import { DICTIONARY_COLUMNS, TERM_COLUMNS, dictionaryAndTermRows } from "./dictionary-rows.mjs";
28
+ import { glossaryLanguage } from "./glossary-language.mjs";
29
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
30
+ import { isNoteRecord } from "./index-records.mjs";
31
+
32
+ /** The automatic lexical sections and the authored anchors each may reuse. */
33
+ export const LEXICAL_SECTIONS = Object.freeze(
34
+ [
35
+ {
36
+ slug: "dictionary",
37
+ heading: "Dictionary",
38
+ aliases: ["dictionary", "lexicon"],
39
+ dataKey: "words",
40
+ types: [{ type: "skill", subType: "language" }],
41
+ },
42
+ {
43
+ slug: "glossary",
44
+ heading: "Glossary",
45
+ aliases: ["glossary"],
46
+ dataKey: "glossary",
47
+ types: [{ type: "lore", subType: "culture" }, { type: "affiliation" }],
48
+ },
49
+ ].map((section) =>
50
+ Object.freeze({
51
+ ...section,
52
+ aliases: Object.freeze(section.aliases),
53
+ types: Object.freeze(section.types.map((kind) => Object.freeze(kind))),
54
+ }),
55
+ ),
56
+ );
57
+
58
+ /** The lexical section this note's own nonempty frontmatter requests. */
59
+ function sectionFor(record) {
60
+ return LEXICAL_SECTIONS.find(
61
+ (section) =>
62
+ section.types.some(
63
+ (kind) =>
64
+ record?.type === kind.type &&
65
+ (!kind.subType || record.subType === kind.subType),
66
+ ) &&
67
+ Array.isArray(record.data?.[section.dataKey]) &&
68
+ record.data[section.dataKey].length > 0,
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Whether frontmatter supplies an automatic lexical section, including on a
74
+ * note with no authored prose. Such a note has content and is not a stub.
75
+ *
76
+ * @param {object|null|undefined} frontmatter - A note or its index record.
77
+ * @returns {boolean} Whether its own nonempty words or glossary qualify.
78
+ */
79
+ export function hasLexicalEntries(frontmatter) {
80
+ return Boolean(sectionFor(frontmatter));
81
+ }
82
+
83
+ /** A note's canonical identity, including a data-only note that was a stub. */
84
+ const keyOf = (record) =>
85
+ record?.package && record.type && record.shortcode ?
86
+ renderAddress({
87
+ package: record.package,
88
+ system: "note",
89
+ type: record.type,
90
+ shortcode: record.shortcode,
91
+ })
92
+ : undefined;
93
+
94
+ /**
95
+ * One automatic section, whose rows are rendered at its containing heading's
96
+ * level when inserted.
97
+ *
98
+ * @typedef {object} LexicalSection
99
+ * @property {string} slug - The canonical section anchor and layout name.
100
+ * @property {string} heading - The heading used when none is authored.
101
+ * @property {readonly string[]} aliases - Authored heading anchors to reuse.
102
+ * @property {{columns: string[], rows: object[]}} result - The selected rows.
103
+ * @property {import("./dictionary-layout.mjs").LayoutOptions} options - Labels,
104
+ * linkability and the page's language, shared by every surface.
105
+ */
106
+
107
+ /**
108
+ * Automatic lexical sections by owning note's canonical Address. Only
109
+ * nonempty own `words` or `glossary` arrays qualify. A glossary selects only
110
+ * explicitly declared owner entries, including a rank only when named there.
111
+ *
112
+ * @param {object[]} records - The audience's visible index records.
113
+ * @param {object[]} [all=records] - Complete records for lineage traversal.
114
+ * @returns {Map<string, LexicalSection[]>} Sections by owning Address.
115
+ */
116
+ export function lexicalSectionsFor(records, all = records) {
117
+ const visible = encodeAddresses(records).filter(isNoteRecord);
118
+ const complete = encodeAddresses(all).filter(isNoteRecord);
119
+ const byKey = new Map(visible.map((record) => [keyOf(record), record]));
120
+ const addressContext = {
121
+ types: new Set(complete.map((record) => record.type)),
122
+ packages: new Set(complete.map((record) => record.package)),
123
+ system: "note",
124
+ };
125
+ const { dictionary, terms } = dictionaryAndTermRows(visible, complete);
126
+ const sections = new Map();
127
+ for (const [key, record] of byKey) {
128
+ if (!key) continue;
129
+ const spec = sectionFor(record);
130
+ if (!spec) continue;
131
+ const language = spec.slug === "dictionary";
132
+ const rows =
133
+ language ?
134
+ dictionary.filter((row) => row.language === key)
135
+ : terms.filter((row) => row.owner === key && row.origins.includes("glossary"));
136
+ const pageLanguage = glossaryLanguage(undefined, record, {
137
+ self: key,
138
+ keyOf: (value) => (typeof value === "string" ? value : undefined),
139
+ lookup: (address) => byKey.get(address),
140
+ });
141
+ sections.set(key, [
142
+ {
143
+ slug: spec.slug,
144
+ heading: spec.heading,
145
+ aliases: spec.aliases,
146
+ result: { columns: [...(language ? DICTIONARY_COLUMNS : TERM_COLUMNS)], rows },
147
+ options: {
148
+ addressContext: { ...addressContext, package: record.package },
149
+ pageLanguage,
150
+ pageOwner: key,
151
+ ...(language ? { verbs: record.data?.grammar?.verbs } : {}),
152
+ labelOf: (address) => byKey.get(address)?.name?.full,
153
+ linkable: (ref) => {
154
+ const target = byKey.get(renderAddress({ ...ref, system: "note" }));
155
+ return Boolean(target && (target.address || hasLexicalEntries(target)));
156
+ },
157
+ },
158
+ },
159
+ ]);
160
+ }
161
+ return sections;
162
+ }
163
+
164
+ /** Authored section headings outside literal content and named containers. */
165
+ function headings(markdown, syntax) {
166
+ const containers = (syntax ? syntax(markdown).blocks : scanBlocks(markdown)).blocks;
167
+ return scanHeadingAttributes(markdown)
168
+ .headings.filter(
169
+ (heading) =>
170
+ !containers.some(
171
+ (block) => heading.line - 1 >= block.start && heading.line - 1 <= block.end,
172
+ ),
173
+ )
174
+ .map((heading) => ({ line: heading.line - 1, level: heading.level, slug: heading.id }));
175
+ }
176
+
177
+ /**
178
+ * Insert lists at the end of their authored anchored sections, or append a
179
+ * new H1 section when no heading names one. Source lines after an insertion
180
+ * retain their original positions; inserted lines point to their heading.
181
+ *
182
+ * @param {string} markdown - The body with authored directives expanded.
183
+ * @param {LexicalSection[]} [sections=[]] - From {@link lexicalSectionsFor}.
184
+ * @param {{authored?: string, lineMap?: object[], generatedFrom?: number, syntax?: import("./content-session.mjs").ContentSyntax}} [context] -
185
+ * The original authored body, source mappings and the start of appended
186
+ * nonlexical generated sections. Without `authored`, `markdown` is the source.
187
+ * @returns {{markdown: string, lineMap: object[], generatedFrom?: number}}
188
+ */
189
+ export function insertLexicalSections(
190
+ markdown,
191
+ sections = [],
192
+ { authored = markdown, lineMap, generatedFrom, syntax } = {},
193
+ ) {
194
+ const lines = String(markdown ?? "").split("\n");
195
+ const locations = lineMap ? [...lineMap] : lines.map((_, line) => ({ line, generated: false }));
196
+ if (!sections.length)
197
+ return {
198
+ markdown: lines.join("\n"),
199
+ lineMap: locations,
200
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
201
+ };
202
+ const edits = [];
203
+ const realHeadings = headings(String(authored ?? ""), syntax)
204
+ .map((heading) => ({
205
+ ...heading,
206
+ sourceLine: heading.line,
207
+ line: locations.findIndex(
208
+ (location, at) =>
209
+ location.line === heading.line &&
210
+ !location.generated &&
211
+ (generatedFrom === undefined || at < generatedFrom),
212
+ ),
213
+ }))
214
+ .filter((heading) => heading.line >= 0);
215
+ for (const section of sections) {
216
+ const own = realHeadings.find((heading) => section.aliases.includes(heading.slug));
217
+ const next =
218
+ own &&
219
+ realHeadings.find((heading) => heading.line > own.line && heading.level <= own.level);
220
+ const at = next?.line ?? (own ? (generatedFrom ?? lines.length) : lines.length);
221
+ const level = own?.level ?? 1;
222
+ const render = section.slug === "dictionary" ? renderDictionary : renderGlossary;
223
+ const list = render(section.result, {
224
+ ...section.options,
225
+ sectionLevel: Math.min(6, level + 1),
226
+ });
227
+ if (!list && own) continue;
228
+ const text = `${own ? "" : `# ${section.heading} {#${section.slug}}\n\n`}${list}`;
229
+ edits.push({
230
+ at,
231
+ source: own?.sourceLine ?? Math.max(0, String(authored ?? "").split("\n").length - 1),
232
+ lines: ["", ...text.split("\n"), ""],
233
+ });
234
+ }
235
+ for (const edit of edits.sort((a, b) => b.at - a.at)) {
236
+ const location = { line: edit.source, generated: true };
237
+ lines.splice(edit.at, 0, ...edit.lines);
238
+ locations.splice(edit.at, 0, ...edit.lines.map(() => ({ ...location })));
239
+ if (generatedFrom !== undefined && edit.at <= generatedFrom)
240
+ generatedFrom += edit.lines.length;
241
+ }
242
+ return {
243
+ markdown: lines.join("\n"),
244
+ lineMap: locations,
245
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
246
+ };
247
+ }
@@ -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
+ }