@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
@@ -39,10 +39,9 @@ import { scanAlerts } from "./content-alerts.mjs";
39
39
  import { scanBlocks } from "./content-blocks.mjs";
40
40
  import { scanPoetry } from "./content-poetry.mjs";
41
41
  import { scanSpans } from "./content-spans.mjs";
42
- import { codeRegions } from "./code-fences.mjs";
43
42
  import { slugify } from "./content-slug.mjs";
44
43
  import { scanFigures } from "./content-figures.mjs";
45
- import { HEADING_LINE, splitHeadingAttributes } from "./heading-attributes.mjs";
44
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
46
45
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
47
46
 
48
47
  /**
@@ -66,30 +65,19 @@ export const ANCHOR_KINDS = Object.freeze(["prose", "event"]);
66
65
  * picture's address to the role its asset declares, so a figure anchor's
67
66
  * `name` reads `Map 1` rather than `Figure 1` where it is due — see
68
67
  * {@link module:engine/content-figures.scanFigures}.
68
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
69
69
  * @returns {Array<{slug: string, name: string, level: number, line: number,
70
70
  * kind: string}>} In document order.
71
71
  */
72
- export function collectAnchors(body, bodyLine = 1, resolveRole) {
72
+ export function collectAnchors(body, bodyLine = 1, resolveRole, syntax) {
73
73
  const anchors = [];
74
- const literal = codeRegions(body, { spans: false, poetryAsProse: false });
75
- let offset = 0;
76
- const lines = String(body ?? "").split("\n");
77
-
78
- for (let i = 0; i < lines.length; i++) {
79
- const lineOffset = offset;
80
- offset += lines[i].length + 1;
81
- if (literal.some((region) => lineOffset >= region.start && lineOffset < region.end))
82
- continue;
83
-
84
- const heading = HEADING_LINE.exec(lines[i]);
85
- if (!heading) continue;
86
- const attributes = splitHeadingAttributes(heading[2]);
87
- if (!attributes.id) continue;
74
+ for (const heading of scanHeadingAttributes(body, bodyLine).headings) {
75
+ if (!heading.id) continue;
88
76
  anchors.push({
89
- slug: attributes.id,
90
- name: attributes.text,
91
- level: heading[1].length,
92
- line: bodyLine + i,
77
+ slug: heading.id,
78
+ name: heading.text,
79
+ level: heading.level,
80
+ line: heading.line,
93
81
  kind: "prose",
94
82
  });
95
83
  }
@@ -105,7 +93,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
105
93
  }
106
94
  const blockAnchors = (source, firstLine) => {
107
95
  const sourceLines = source.split("\n");
108
- for (const block of scanBlocks(source).blocks) {
96
+ for (const block of (syntax ? syntax(source).blocks : scanBlocks(source)).blocks) {
109
97
  if (block.id)
110
98
  anchors.push({
111
99
  slug: slugify(block.id),
@@ -121,7 +109,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
121
109
  }
122
110
  };
123
111
  blockAnchors(String(body ?? ""), bodyLine);
124
- for (const alert of scanAlerts(body).blocks) {
112
+ for (const alert of (syntax ? syntax(body).alerts : scanAlerts(body)).blocks) {
125
113
  if (alert.id)
126
114
  anchors.push({
127
115
  slug: slugify(alert.id),
@@ -141,7 +129,7 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
141
129
  kind: "prose",
142
130
  });
143
131
  }
144
- for (const span of scanSpans(body).spans) {
132
+ for (const span of (syntax ? syntax(body).spans : scanSpans(body)).spans) {
145
133
  if (span.id)
146
134
  anchors.push({
147
135
  slug: slugify(span.id),
@@ -157,11 +145,12 @@ export function collectAnchors(body, bodyLine = 1, resolveRole) {
157
145
  /** Collisions involving a div, verse or inline-span anchor.
158
146
  * Heading and caption collisions are diagnosed by their existing scanners.
159
147
  * @param {string} body - Authored Markdown.
148
+ * @param {import("./content-session.mjs").ContentSyntax} [syntax] - Exact-text scanner projections.
160
149
  * @returns {Array<{line: number, column: number, message: string}>} Located collisions.
161
150
  */
162
- export function markupAnchorFindings(body) {
151
+ export function markupAnchorFindings(body, syntax) {
163
152
  const groups = new Map();
164
- for (const anchor of collectAnchors(body)) {
153
+ for (const anchor of collectAnchors(body, 1, undefined, syntax)) {
165
154
  const slug = slugify(anchor.slug);
166
155
  if (!groups.has(slug)) groups.set(slug, []);
167
156
  groups.get(slug).push(anchor);
@@ -686,11 +686,13 @@ export class BasePackCompiler {
686
686
  // it made — `errors` or `findings`, never just the first — so nothing
687
687
  // here re-scans; it only stops taking `[0]`.
688
688
  const findings = [];
689
+ const syntax = this.corpus?.syntax;
690
+ const scanned = syntax?.(body);
689
691
  for (const secretError of [
690
- ...scanBlocks(body).errors,
691
- ...scanAlerts(body).errors,
692
- ...scanSpans(body).errors,
693
- ...markupAnchorFindings(body),
692
+ ...(scanned?.blocks ?? scanBlocks(body)).errors,
693
+ ...(scanned?.alerts ?? scanAlerts(body)).errors,
694
+ ...(scanned?.spans ?? scanSpans(body)).errors,
695
+ ...markupAnchorFindings(body, syntax),
694
696
  ]) {
695
697
  findings.push({
696
698
  message: secretError.message,
@@ -46,33 +46,37 @@ import {
46
46
  const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
47
47
 
48
48
  /**
49
- * A labelled block's own `**Label**` line, split from everything after it.
49
+ * A label, its complete opening paragraph and its remaining items.
50
50
  *
51
51
  * @param {string} blockText - One block, as {@link topLevelBlocks} collects
52
52
  * it — its first line is the label line for a labelled block.
53
- * @returns {{labelLine: string, rest: string, hadBlankLine: boolean}} `rest`
54
- * has its leading blank line dropped; a block with nothing but the label
55
- * line yields `""`. `hadBlankLine` says whether the label line was
56
- * followed by a blank line in `blockText` — false for a bold sentence a
57
- * hard-wrapped paragraph continues directly beneath, with no blank line
58
- * of its own.
53
+ * @returns {{labelLine: string, introduction: string, rest: string}} The
54
+ * introduction retains hard wrapping and stops before a bullet or code.
59
55
  */
60
- function splitLabelLine(blockText) {
61
- const nl = blockText.indexOf("\n");
62
- if (nl === -1) return { labelLine: blockText, rest: "", hadBlankLine: false };
63
- const after = blockText.slice(nl + 1);
56
+ function splitLabelBlock(blockText) {
57
+ const lines = blockText.replace(/\s+$/, "").split("\n");
58
+ const labelLine = /^\*\*[^*]+\*\*/.exec(lines[0])[0];
59
+ const inline = lines[0].slice(labelLine.length).trimStart();
60
+ const introduction = inline ? [inline] : [];
61
+ const codeLines = codeLineSet(blockText);
62
+ let cursor = 1;
63
+ while (
64
+ cursor < lines.length &&
65
+ lines[cursor].trim() !== "" &&
66
+ !/^-\s/.test(lines[cursor]) &&
67
+ !codeLines.has(cursor + 1)
68
+ )
69
+ introduction.push(lines[cursor++]);
64
70
  return {
65
- labelLine: blockText.slice(0, nl),
66
- rest: after.replace(/^\n+/, ""),
67
- hadBlankLine: after.startsWith("\n"),
71
+ labelLine,
72
+ introduction: introduction.join("\n"),
73
+ rest: lines.slice(cursor).join("\n").replace(/^\n+/, ""),
68
74
  };
69
75
  }
70
76
 
71
77
  /**
72
- * A block's top-level items: its bullets, or — when it carries none — the
73
- * whole thing as one paragraph item. This is what merging concatenates and
74
- * deduplicates, so a bulleted `**Compendiums**` block and a prose one merge
75
- * on the same footing.
78
+ * A block's top-level bullets and the prose or code between them, in source
79
+ * order. A bullet retains its indented continuation and nested items.
76
80
  *
77
81
  * @param {string} text - A block's content, label line already stripped (or
78
82
  * a lead block's whole text).
@@ -83,7 +87,26 @@ function itemsOf(text) {
83
87
  if (!trimmed) return [];
84
88
  const bullets = topLevelBullets(trimmed, codeLineSet(trimmed));
85
89
  if (bullets.length === 0) return [trimmed];
86
- return bullets.map((bullet) => bullet.text.replace(/\s+$/, ""));
90
+ const starts = [];
91
+ let offset = 0;
92
+ for (const line of trimmed.split("\n")) {
93
+ starts.push(offset);
94
+ offset += line.length + 1;
95
+ }
96
+ const items = [];
97
+ const append = (part) => {
98
+ const item = part.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
99
+ if (item) items.push(item);
100
+ };
101
+ let cursor = 0;
102
+ for (const bullet of bullets) {
103
+ const start = starts[bullet.startLine - 1];
104
+ append(trimmed.slice(cursor, start));
105
+ append(bullet.text);
106
+ cursor = start + bullet.text.length;
107
+ }
108
+ append(trimmed.slice(cursor));
109
+ return items;
87
110
  }
88
111
 
89
112
  /**
@@ -124,13 +147,9 @@ function joinItems(items) {
124
147
  * One label's blocks (or every unlabelled one), folded into the single
125
148
  * block `group` writes for it.
126
149
  *
127
- * A single, unmerged block — the common case, one changeset writing one
128
- * label once — keeps its own label-to-body separator rather than forcing a
129
- * blank line: a bold sentence a hard-wrapped paragraph continues directly
130
- * beneath must round-trip unchanged, not gain a blank line it never had.
131
- * Merging two or more blocks, or a label whose lone block carries several
132
- * items (bullets), still separates the label from its body with a blank
133
- * line — the convention every existing multi-item block already uses.
150
+ * A single block with no duplicate items retains its own spacing. Merged
151
+ * opening paragraphs form one hard-wrapped introduction before the items,
152
+ * so paragraph boundaries cannot turn labelled text into unlabelled lead.
134
153
  *
135
154
  * @param {Array<{label: string|null, text: string}>} blocks - Every block
136
155
  * sharing one label, in file order. All carry the same `label`.
@@ -140,13 +159,15 @@ function mergeGroup(blocks) {
140
159
  if (blocks[0].label === null) {
141
160
  return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
142
161
  }
143
- const { labelLine, hadBlankLine } = splitLabelLine(blocks[0].text);
144
- const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
145
- if (items.length === 0) return labelLine;
146
- if (blocks.length === 1 && items.length === 1) {
147
- return `${labelLine}${hadBlankLine ? "\n\n" : "\n"}${items[0]}`;
148
- }
149
- return `${labelLine}\n\n${joinItems(items)}`;
162
+ const parts = blocks.map((block) => splitLabelBlock(block.text));
163
+ const introductions = dedupeItems(parts.map((part) => part.introduction).filter(Boolean));
164
+ const originalItems = parts.flatMap((part) => itemsOf(part.rest));
165
+ const items = dedupeItems(originalItems);
166
+ if (blocks.length === 1 && items.length === originalItems.length)
167
+ return blocks[0].text.replace(/\s+$/, "");
168
+ const heading =
169
+ parts[0].labelLine + (introductions.length ? ` ${introductions.join("\n")}` : "");
170
+ return heading + (items.length ? `\n\n${joinItems(items)}` : "");
150
171
  }
151
172
 
152
173
  /**
@@ -245,7 +266,10 @@ export function groupChangelogText(text, { labels = null } = {}) {
245
266
  if (!range) return { text, findings: [] };
246
267
 
247
268
  const section = text.slice(range.start, range.end);
248
- const headings = [...section.matchAll(CHANGES_HEADING_RE)];
269
+ const codeLines = codeLineSet(section);
270
+ const headings = [...section.matchAll(CHANGES_HEADING_RE)].filter(
271
+ (heading) => !codeLines.has(lineColOf(section, heading.index).line),
272
+ );
249
273
  if (headings.length === 0) return { text, findings: [] };
250
274
 
251
275
  let rebuilt = section.slice(0, headings[0].index);
@@ -277,7 +277,8 @@ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
277
277
  const raw = lines[i];
278
278
 
279
279
  if (codeLines.has(lineNo)) {
280
- if (current) current.parts.push(raw);
280
+ current ??= { startLine: lineNo, label: null, parts: [] };
281
+ current.parts.push(raw);
281
282
  afterBlank = raw.trim() === "";
282
283
  continue;
283
284
  }
@@ -800,7 +801,10 @@ export function lintReleaseText(text, { labels = null } = {}) {
800
801
  * of the file.
801
802
  */
802
803
  export function releaseSectionRange(text) {
803
- const matches = [...text.matchAll(/^## /gm)];
804
+ const codeLines = codeLineSet(text);
805
+ const matches = [...text.matchAll(/^## /gm)].filter(
806
+ (heading) => !codeLines.has(lineColOf(text, heading.index).line),
807
+ );
804
808
  if (matches.length === 0) return null;
805
809
  const start = matches[0].index;
806
810
  const end = matches.length > 1 ? matches[1].index : text.length;
@@ -70,6 +70,8 @@ import { loadPackConfig } from "./pack-config.mjs";
70
70
  * @param {string} [opts.stageDest] Directory the LevelDB packs are built
71
71
  * into, one subdirectory per pack. Defaults to the configured stage.
72
72
  * @param {string} [opts.packName] Restrict the run to a single pack.
73
+ * @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
74
+ * @param {boolean} [opts.reportPreparation=true] - Emit shared preparation findings unless the caller already reported them.
73
75
  * @throws {Error} If pack JSON generation reported any error. Packs compiled
74
76
  * from incomplete or empty JSON ship blank or short compendiums, and the
75
77
  * omission is invisible until a player looks for content that is not there
@@ -86,7 +88,14 @@ export async function compilePacks({
86
88
  sourcePacks = config.packDirectories,
87
89
  stageDest = config.paths.stage,
88
90
  packName,
91
+ session,
92
+ reportPreparation = true,
89
93
  } = {}) {
94
+ session?.assertContext({
95
+ config,
96
+ contentBase: config.paths.content,
97
+ skipDirectories: config.skipDirectories,
98
+ });
90
99
  const packNames = sourcePacks.filter((name) => !packName || name === packName);
91
100
 
92
101
  // A prebuilt pack's JSON is checked in rather than generated, so it is
@@ -105,7 +114,12 @@ export async function compilePacks({
105
114
  // content tree, and a package that ships only prebuilt packs has none at
106
115
  // all — so running it would fail the build over a tree nothing reads.
107
116
  if (needsGeneration) {
108
- const errors = await generatePacksJson({ only: packName, config });
117
+ const errors = await generatePacksJson({
118
+ only: packName,
119
+ config,
120
+ session,
121
+ reportPreparation,
122
+ });
109
123
  if (errors > 0) {
110
124
  throw new Error(
111
125
  `Pack JSON generation reported ${errors} error(s); refusing to ` +
@@ -60,7 +60,8 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
60
60
  * created when none is passed, and returned on the corpus either way — a note
61
61
  * the index refuses is a note the compile must still *report*, exactly as the
62
62
  * compile loop reported it when the loop was the first to see it.
63
- * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
63
+ * @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
64
+ * @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
64
65
  * its indexes, and the notes it could not record.
65
66
  */
66
67
  export async function buildCompileCorpus({
@@ -70,8 +71,20 @@ export async function buildCompileCorpus({
70
71
  router,
71
72
  config,
72
73
  problems,
74
+ session,
73
75
  }) {
74
76
  const resolved = config ?? loadPackConfig();
77
+ if (session) {
78
+ session.assertContext({
79
+ config: resolved,
80
+ contentBase,
81
+ assetsBase,
82
+ skipDirectories,
83
+ router,
84
+ });
85
+ if (problems && problems !== session.problems) problems.push(...session.problems);
86
+ return session.compileCorpus();
87
+ }
75
88
  const collected = problems ?? [];
76
89
  const parsed = new Map();
77
90
  const records = indexRecordsFor({
@@ -84,11 +97,12 @@ export async function buildCompileCorpus({
84
97
  });
85
98
  const corpusOptions = { config: resolved, records };
86
99
  const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
100
+ const sqlTables = await prepareTreeSqlTables(contentBase, sqlOptions);
87
101
  return {
88
102
  records,
89
103
  parsed,
90
104
  problems: collected,
91
- linkIndex: buildContentLinkIndex(contentBase, router, corpusOptions),
92
- sqlTables: await prepareTreeSqlTables(contentBase, sqlOptions),
105
+ linkIndex: buildContentLinkIndex(contentBase, router, { ...corpusOptions, sqlTables }),
106
+ sqlTables,
93
107
  };
94
108
  }
@@ -85,6 +85,7 @@
85
85
 
86
86
  import fs from "node:fs";
87
87
  import path from "node:path";
88
+ import { capturedSources } from "./content-sources.mjs";
88
89
 
89
90
  /**
90
91
  * Tier 1 — the letters, and the two whitespace characters a file is made of.
@@ -407,16 +408,27 @@ export function checkText(text, file) {
407
408
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
408
409
  * in addition to the dot-directories always skipped.
409
410
  * @param {readonly string[]} [opts.extensions] - File extensions to read.
411
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
410
412
  * @returns {{findings: Array<{file: string, line: number, column: number,
411
413
  * severity: "warning", message: string}>, files: number}} The findings, and how
412
414
  * many files produced them.
413
415
  */
414
- export function lintContentCharset(contentBase, { skipDirectories = [], extensions } = {}) {
416
+ export function lintContentCharset(
417
+ contentBase,
418
+ { skipDirectories = [], extensions, sources } = {},
419
+ ) {
415
420
  const exts = new Set(extensions ?? [".md", ".markdown", ".yaml", ".yml", ".json"]);
416
421
  const skip = new Set(skipDirectories);
417
422
  /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
418
423
  const findings = [];
419
424
  let files = 0;
425
+ if (sources) {
426
+ for (const [full, text] of capturedSources(contentBase, sources, skipDirectories, exts)) {
427
+ files++;
428
+ findings.push(...checkText(text, path.relative(process.cwd(), full)));
429
+ }
430
+ return { findings, files };
431
+ }
420
432
 
421
433
  /** @param {string} dir - Directory to descend into. */
422
434
  const walk = (dir) => {
@@ -201,6 +201,7 @@ types:
201
201
  - other
202
202
  skill:
203
203
  data:
204
+ - grammar
204
205
  - templatePriority
205
206
  - masteryLevel
206
207
  - parentSkill
@@ -52,9 +52,10 @@
52
52
 
53
53
  import fs from "node:fs";
54
54
  import path from "node:path";
55
+ import { capturedSources } from "./content-sources.mjs";
55
56
 
56
57
  import { matchAllOutsideCode } from "./code-fences.mjs";
57
- import { positionInBody } from "./diagnostics.mjs";
58
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
58
59
 
59
60
  /**
60
61
  * A raw HTML tag, opening, closing or self-closing.
@@ -133,15 +134,28 @@ export function checkHtml(body, file, { bodyLine = 1, bodyColumn = 1 } = {}) {
133
134
  * @param {object} [opts]
134
135
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore
135
136
  * in addition to the dot-directories always skipped.
137
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
136
138
  * @returns {{findings: Array<{file: string, line: number, column: number,
137
139
  * severity: "warning", message: string}>, files: number}} The findings, and
138
140
  * how many files were read.
139
141
  */
140
- export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
142
+ export function lintContentHtml(contentBase, { skipDirectories = [], sources } = {}) {
141
143
  const skip = new Set(skipDirectories);
142
144
  /** @type {Array<{file: string, line: number, column: number, severity: "warning", message: string}>} */
143
145
  const findings = [];
144
146
  let files = 0;
147
+ if (sources) {
148
+ for (const [full, content] of capturedSources(
149
+ contentBase,
150
+ sources,
151
+ skipDirectories,
152
+ new Set([".md", ".markdown"]),
153
+ )) {
154
+ files++;
155
+ findings.push(...checkHtml(...bodyOf(content, path.relative(process.cwd(), full))));
156
+ }
157
+ return { findings, files };
158
+ }
145
159
 
146
160
  /** @param {string} dir - Directory to descend into. */
147
161
  const walk = (dir) => {
@@ -188,10 +202,10 @@ export function lintContentHtml(contentBase, { skipDirectories = [] } = {}) {
188
202
  * arguments {@link checkHtml} takes.
189
203
  */
190
204
  function bodyOf(content, file) {
191
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
205
+ const match = splitFrontmatter(content);
192
206
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
193
207
 
194
- const raw = match[2];
208
+ const raw = match.body;
195
209
  const body = raw.trim();
196
210
  const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
197
211
  const before = content.slice(0, bodyStart);
@@ -27,6 +27,7 @@
27
27
 
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
+ import { capturedSources } from "./content-sources.mjs";
30
31
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
31
32
 
32
33
  /**
@@ -524,13 +525,26 @@ export function checkIconRegistry(registry, where = "icons") {
524
525
  * @param {object} [opts]
525
526
  * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
526
527
  * @param {IconRegistry} [opts.registry] - The package's registry.
528
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
527
529
  * @returns {{findings: Array<{file: string, line: number, column: number,
528
530
  * severity: "warning", message: string}>, files: number}} What it found.
529
531
  */
530
- export function lintContentIcons(contentBase, { skipDirectories = [], registry } = {}) {
532
+ export function lintContentIcons(contentBase, { skipDirectories = [], registry, sources } = {}) {
531
533
  const skip = new Set(skipDirectories);
532
534
  const findings = [];
533
535
  let files = 0;
536
+ if (sources) {
537
+ for (const [full, text] of capturedSources(
538
+ contentBase,
539
+ sources,
540
+ skipDirectories,
541
+ new Set([".md", ".markdown"]),
542
+ )) {
543
+ files++;
544
+ findings.push(...lintIcons(text, path.relative(process.cwd(), full), registry));
545
+ }
546
+ return { findings, files };
547
+ }
534
548
 
535
549
  /** @param {string} dir - Directory to descend into. */
536
550
  const walk = (dir) => {
@@ -80,10 +80,11 @@
80
80
 
81
81
  import fs from "node:fs";
82
82
  import path from "node:path";
83
+ import { capturedSources } from "./content-sources.mjs";
83
84
 
84
85
  import { matchAllOutsideCode } from "./code-fences.mjs";
85
86
  import { parseExtensionAttributes } from "./extension-attributes.mjs";
86
- import { positionInBody } from "./diagnostics.mjs";
87
+ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
87
88
  import { FOOTNOTE_REFERENCE } from "./content-footnotes.mjs";
88
89
  import { foundryAddressProblem, pathnameProblem, servesFoundry } from "./pathnames.mjs";
89
90
  import { ASSET_ROLES } from "./asset-index.mjs";
@@ -431,16 +432,20 @@ export function standsAlone(text, start, end) {
431
432
  const lineEnd = src.indexOf("\n", end);
432
433
  const afterMatch = src.slice(end, lineEnd === -1 ? src.length : lineEnd);
433
434
  const reference = TRAILING_FOOTNOTE_REFERENCE.exec(afterMatch);
434
- if (!/^[ \t]*$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
435
+ if (!/^[ \t]*\r?$/.test(reference ? afterMatch.slice(reference[0].length) : afterMatch))
435
436
  return false;
436
437
 
437
438
  const before = src.slice(0, lineStart);
438
- const previous = before.replace(/\n$/, "").split("\n").pop() ?? "";
439
- if (before.trim() && !/\n[ \t]*\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
439
+ const previous =
440
+ before
441
+ .replace(/\r?\n$/, "")
442
+ .split(/\r?\n/)
443
+ .pop() ?? "";
444
+ if (before.trim() && !/\n[ \t]*\r?\n[ \t]*$/.test(before) && !DELIMITER.test(previous))
440
445
  return false;
441
446
  const after = lineEnd === -1 ? "" : src.slice(lineEnd);
442
- const next = after.replace(/^\n/, "").split("\n")[0] ?? "";
443
- if (after.trim() && !/^\n[ \t]*\n/.test(after) && !DELIMITER.test(next)) return false;
447
+ const next = after.replace(/^\n/, "").split(/\r?\n/)[0] ?? "";
448
+ if (after.trim() && !/^\n[ \t]*\r?\n/.test(after) && !DELIMITER.test(next)) return false;
444
449
  return true;
445
450
  }
446
451
 
@@ -586,10 +591,10 @@ export function checkImages(
586
591
  * arguments {@link checkImages} takes.
587
592
  */
588
593
  function bodyOf(content, file) {
589
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
594
+ const match = splitFrontmatter(content);
590
595
  if (!match) return [content, file, { bodyLine: 1, bodyColumn: 1 }];
591
596
 
592
- const raw = match[2];
597
+ const raw = match.body;
593
598
  const body = raw.trim();
594
599
  const bodyStart = content.length - raw.length + (raw.length - raw.trimStart().length);
595
600
  const before = content.slice(0, bodyStart);
@@ -619,15 +624,29 @@ function bodyOf(content, file) {
619
624
  * in addition to the dot-directories always skipped.
620
625
  * @param {object} [opts.config] - The resolved build configuration, passed to
621
626
  * {@link checkImages} so an address is held to the Foundry surface too.
627
+ * @param {Map<string, {raw: string}>} [opts.sources] - Exact captured source strings; omitted, walks and reads the tree.
622
628
  * @returns {{findings: Array<{file: string, line: number, column: number|undefined,
623
629
  * severity: "error", message: string}>, files: number}} The findings, and how
624
630
  * many files were read.
625
631
  */
626
- export function lintContentImages(contentBase, { skipDirectories = [], config } = {}) {
632
+ export function lintContentImages(contentBase, { skipDirectories = [], config, sources } = {}) {
627
633
  const skip = new Set(skipDirectories);
628
634
  /** @type {Array<{file: string, line: number, column: number|undefined, severity: "error", message: string}>} */
629
635
  const findings = [];
630
636
  let files = 0;
637
+ if (sources) {
638
+ for (const [full, content] of capturedSources(
639
+ contentBase,
640
+ sources,
641
+ skipDirectories,
642
+ new Set([".md", ".markdown"]),
643
+ )) {
644
+ files++;
645
+ const [body, rel, at] = bodyOf(content, path.relative(process.cwd(), full));
646
+ findings.push(...checkImages(body, rel, { ...at, config }));
647
+ }
648
+ return { findings, files };
649
+ }
631
650
 
632
651
  /** @param {string} dir - Directory to descend into. */
633
652
  const walk = (dir) => {