@heroiclands/package-build 23.7.1 → 23.9.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 +41 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +32 -10
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +402 -58
  7. package/docs/authoring/first-note.md +1 -1
  8. package/docs/authoring/frontmatter.md +30 -1
  9. package/docs/authoring/links-and-markup.md +154 -56
  10. package/docs/commands.md +31 -12
  11. package/docs/content-format.md +25 -2
  12. package/docs/design.md +31 -0
  13. package/docs/diagnostics.md +1 -1
  14. package/docs/guides/e2e.md +37 -1
  15. package/docs/guides/packs.md +2 -1
  16. package/docs/guides/reference-pages.md +86 -63
  17. package/docs/project-setup.md +30 -15
  18. package/docs/reference/format-details.md +313 -132
  19. package/docs/reference/note-types.md +191 -174
  20. package/e2e.mjs +89 -2
  21. package/engine/anchors.mjs +7 -20
  22. package/engine/base-compiler.mjs +11 -18
  23. package/engine/compile-corpus.mjs +5 -3
  24. package/engine/content-alerts.mjs +4 -2
  25. package/engine/content-blocks.mjs +18 -36
  26. package/engine/content-format.yaml +1 -0
  27. package/engine/content-index.mjs +447 -33
  28. package/engine/content-links.mjs +43 -15
  29. package/engine/content-poetry.mjs +9 -0
  30. package/engine/content-tables.mjs +8 -4
  31. package/engine/culture-reach.mjs +61 -34
  32. package/engine/data-keys.mjs +3 -1
  33. package/engine/diagnostics.mjs +24 -11
  34. package/engine/dictionary-layout.mjs +151 -19
  35. package/engine/dictionary-rows.mjs +129 -79
  36. package/engine/foundry-entries.mjs +21 -3
  37. package/engine/frontmatter-lint.mjs +1 -1
  38. package/engine/generate.mjs +12 -22
  39. package/engine/glossary-entries.mjs +11 -11
  40. package/engine/heading-attributes.mjs +21 -28
  41. package/engine/helpers.mjs +20 -12
  42. package/engine/infobox.mjs +3 -0
  43. package/engine/journals.mjs +15 -2
  44. package/engine/language-data.mjs +53 -0
  45. package/engine/language-terms.mjs +86 -0
  46. package/engine/language-words.mjs +2 -2
  47. package/engine/lexical-sections.mjs +247 -0
  48. package/engine/markdownlint-run.mjs +12 -2
  49. package/engine/markup-validation.mjs +34 -0
  50. package/engine/note-addresses.mjs +1 -0
  51. package/engine/note-claims.mjs +21 -10
  52. package/engine/note-package.mjs +3 -5
  53. package/engine/note-state.mjs +9 -3
  54. package/engine/note-vocabulary.mjs +36 -7
  55. package/engine/pdf-build.mjs +17 -10
  56. package/engine/prose-lint.mjs +12 -6
  57. package/engine/related-pages.mjs +22 -3
  58. package/engine/see-also.mjs +150 -0
  59. package/engine/site-build.mjs +21 -3
  60. package/engine/site-preview.mjs +4 -2
  61. package/engine/sql-tables.mjs +287 -53
  62. package/engine/standings.mjs +14 -12
  63. package/engine/stub-lint.mjs +6 -2
  64. package/engine/web-wikilinks.mjs +8 -6
  65. package/engine/word-rows.mjs +20 -12
  66. package/engine/word-terms.mjs +15 -6
  67. package/hugo-theme/layouts/partials/related.html +25 -8
  68. package/package.json +2 -1
  69. package/types/container.d.mts +4 -3
  70. package/types/e2e.d.mts +28 -0
  71. package/types/engine/base-compiler.d.mts +2 -2
  72. package/types/engine/compile-corpus.d.mts +2 -1
  73. package/types/engine/content-index.d.mts +85 -8
  74. package/types/engine/content-tables.d.mts +2 -1
  75. package/types/engine/culture-reach.d.mts +22 -2
  76. package/types/engine/data-keys.d.mts +7 -0
  77. package/types/engine/diagnostics.d.mts +7 -6
  78. package/types/engine/dictionary-layout.d.mts +35 -4
  79. package/types/engine/dictionary-rows.d.mts +9 -0
  80. package/types/engine/foundry-entries.d.mts +3 -1
  81. package/types/engine/heading-attributes.d.mts +2 -2
  82. package/types/engine/helpers.d.mts +11 -5
  83. package/types/engine/language-terms.d.mts +7 -0
  84. package/types/engine/lexical-sections.d.mts +95 -0
  85. package/types/engine/markdownlint-run.d.mts +3 -1
  86. package/types/engine/markup-validation.d.mts +14 -0
  87. package/types/engine/note-addresses.d.mts +10 -0
  88. package/types/engine/note-claims.d.mts +7 -1
  89. package/types/engine/note-package.d.mts +3 -1
  90. package/types/engine/note-state.d.mts +2 -1
  91. package/types/engine/note-vocabulary.d.mts +1 -0
  92. package/types/engine/related-pages.d.mts +10 -1
  93. package/types/engine/see-also.d.mts +55 -0
  94. package/types/engine/site-build.d.mts +4 -1
  95. package/types/engine/sql-tables.d.mts +40 -6
  96. package/types/engine/word-rows.d.mts +9 -7
@@ -90,7 +90,7 @@ import {
90
90
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
91
91
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
92
92
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
93
- import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
93
+ import { isEventAnchor, parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
94
94
  import { readQualifier } from "./wikilinks.mjs";
95
95
 
96
96
  /**
@@ -243,7 +243,10 @@ export function buildLinkIndex(
243
243
  // same headings — the disagreement the anchor half removes.
244
244
  anchors.set(
245
245
  note,
246
- new Set((record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug)),
246
+ new Set([
247
+ ...(record.anchors ?? []).filter((a) => a.kind === "prose").map((a) => a.slug),
248
+ ...(sqlTables?.get(absPath)?.anchors ?? []).map((a) => a.slug),
249
+ ]),
247
250
  );
248
251
  eventAnchorsOf.set(
249
252
  note,
@@ -331,7 +334,13 @@ export function buildLinkIndex(
331
334
  */
332
335
  function expandedBody(note) {
333
336
  const body = note.body;
334
- if (!/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)) return body;
337
+ const prepared = sqlTables?.get(note.file);
338
+ if (
339
+ !prepared?.generated &&
340
+ !prepared?.lexical?.length &&
341
+ !/^[ \t]*(?:`{3,}|~{3,})[ \t]*(?:sql|pagelist)\b/im.test(body)
342
+ )
343
+ return body;
335
344
  return expandContentTables(body, {
336
345
  source: note.file,
337
346
  // A generated link is checked like an authored one, so both
@@ -940,18 +949,37 @@ export function auditLinks(index) {
940
949
  for (const note of notes) {
941
950
  for (const { target, rawTarget, anchor, text, occurrence, labelled } of linksOf(note)) {
942
951
  if (!anchor || !labelled) continue;
943
- const dest = target ? resolve(target) : note;
944
- // An unresolvable target is reported by the pass below; its anchor
945
- // has nothing to be checked against. Neither has a file: an asset
946
- // resolves but has no body, so it declares the empty set of anchors
947
- // and every `#section` on one is dead.
948
- if (!dest) continue;
949
- if (!(anchors.get(dest) ?? new Set()).has(slugify(anchor))) {
952
+ const local = target ? resolve(target) : note;
953
+ const foreign = !local && target ? manifestHit(target) : null;
954
+ // An unresolved address is reported below. A foreign sheet opens
955
+ // its system document, so only the readable note view names prose
956
+ // sections. A public note's GM target is an audience refusal below.
957
+ if (!local) {
958
+ if (!foreign || (foreign.gm && !isGmNote(note.fm))) continue;
959
+ const qualified = readQualifier(
960
+ target,
961
+ index.types,
962
+ index.packages,
963
+ index.noIndexPackages,
964
+ );
965
+ if (qualified?.system !== NOTE_SYSTEM) continue;
966
+ }
967
+ const dest = local ?? foreign;
968
+ const slug = local ? slugify(anchor) : anchor;
969
+ const event =
970
+ local ?
971
+ (index.eventAnchorsOf?.get(local) ?? new Set()).has(anchor)
972
+ : isEventAnchor(foreign, anchor);
973
+ // Fetched note anchors are the publisher's declaration, independent
974
+ // of a site URL or a Foundry UUID. An asset has no body and therefore
975
+ // declares no sections; an absent foreign anchor list does too.
976
+ const declared =
977
+ local ?
978
+ (anchors.get(local) ?? new Set()).has(slug)
979
+ : foreign.noteAnchors?.some((one) => one.slug === anchor && one.kind === "prose");
980
+ if (!declared || (!local && event)) {
950
981
  deadAnchors.push({
951
- reason:
952
- (index.eventAnchorsOf?.get(dest) ?? new Set()).has(anchor) ?
953
- "event-anchor"
954
- : "unknown-anchor",
982
+ reason: event ? "event-anchor" : "unknown-anchor",
955
983
  note,
956
984
  link: `${rawTarget}#${anchor}`,
957
985
  // The two the shared `unknown-anchor` wording reads, in the
@@ -960,7 +988,7 @@ export function auditLinks(index) {
960
988
  // declare. Carrying them is what lets one sentence serve
961
989
  // the checker and the build.
962
990
  target: rawTarget,
963
- anchor: slugify(anchor),
991
+ anchor: slug,
964
992
  dest,
965
993
  text,
966
994
  occurrence,
@@ -71,9 +71,12 @@ export function poetryMarkdown(body) {
71
71
  .join("\n\n");
72
72
  }
73
73
 
74
+ import MarkdownIt from "markdown-it";
74
75
  import { slugify } from "./content-slug.mjs";
75
76
  import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
76
77
 
78
+ const structureParser = new MarkdownIt({ html: true });
79
+
77
80
  /** Read poetry code fences; start/end are inclusive zero-based source lines.
78
81
  * @param {string} source - Authored Markdown.
79
82
  */
@@ -81,8 +84,14 @@ export function scanPoetry(source) {
81
84
  const lines = String(source ?? "").split("\n"),
82
85
  blocks = [],
83
86
  errors = [];
87
+ const literalLines = new Set();
88
+ for (const token of structureParser.parse(String(source ?? ""), {})) {
89
+ if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
90
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
91
+ }
84
92
  let opening = null;
85
93
  for (let i = 0; i < lines.length; i++) {
94
+ if (literalLines.has(i)) continue;
86
95
  const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
87
96
  if (opening) {
88
97
  if (
@@ -14,6 +14,7 @@
14
14
  import { FENCE_LINE } from "./code-fences.mjs";
15
15
  import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
16
16
  import { appendGeneratedSections } from "./generated-sections.mjs";
17
+ import { insertLexicalSections } from "./lexical-sections.mjs";
17
18
 
18
19
  /**
19
20
  * Replace prepared SQL fences and page lists with Markdown while retaining
@@ -29,7 +30,8 @@ import { appendGeneratedSections } from "./generated-sections.mjs";
29
30
  * Prepared results.
30
31
  * A note's generated sections — see {@link module:engine/generated-sections} —
31
32
  * are appended to the body first, from the `generated` text the preparation
32
- * set on its results, and expanded with it.
33
+ * set on its results, and expanded with it. Automatic lexical lists, carried
34
+ * on `lexical`, then fill their authored sections or append a new section.
33
35
  *
34
36
  * @returns {{markdown: string, errors: object[], warnings: object[], lineMap: object[],
35
37
  * generatedFrom?: number}} `generatedFrom` is the 0-based line of the
@@ -148,10 +150,12 @@ export function expandContentTables(markdown, { source = "", sqlTables, pageList
148
150
  i = close;
149
151
  }
150
152
  return {
151
- markdown: out.join("\n"),
153
+ ...insertLexicalSections(out.join("\n"), sqlTables?.lexical, {
154
+ authored,
155
+ lineMap,
156
+ generatedFrom,
157
+ }),
152
158
  errors,
153
159
  warnings,
154
- lineMap,
155
- ...(generatedFrom === undefined ? {} : { generatedFrom }),
156
160
  };
157
161
  }
@@ -15,17 +15,14 @@
15
15
  * Which terms reach a culture or an affiliation, and whether an affiliation
16
16
  * reaches a culture at all.
17
17
  *
18
- * A culture's terms are the words of its language, and the ranks, offices and
19
- * language words of every affiliation that reaches it — through its own
20
- * `data.culture`, the one the index resolves for it, or the culture of an
21
- * affiliation above it through `data.parents` —
22
- * each affiliation's language being the one its glossary resolves to
23
- * ({@link module:engine/glossary-language}). An affiliation's terms are its own
24
- * ranks and offices and the words of that language. A language's words include
25
- * the words it inherits through `data.family`, and a word is a term only where
18
+ * A culture can name the ranks and offices of every affiliation that reaches
19
+ * it through `data.culture` or `data.parents`. An affiliation can name its own
20
+ * ranks and offices. Each glossary entry can also name a word of its resolved
21
+ * language ({@link module:engine/glossary-language}). A language's words include
22
+ * those it inherits through `data.family`, and a word is a term only where
26
23
  * its class's base is a glossary base. The `dictionary` relation
27
- * ({@link module:engine/dictionary-rows}) names a glossary entry's term from
28
- * the same reach.
24
+ * ({@link module:engine/dictionary-rows}) uses the same resolved-language and
25
+ * governance sources.
29
26
  *
30
27
  * **An affiliation that reaches no culture is a warning**, at its `type`, in a
31
28
  * tree that describes peoples, which is a tree holding at least one culture
@@ -42,7 +39,7 @@ import { AddressLink } from "./address-values.mjs";
42
39
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
43
40
  import { glossaryLanguage } from "./glossary-language.mjs";
44
41
  import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
45
- import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
42
+ import { glossSenses, LANGUAGE_SUBTYPE } from "./word-terms.mjs";
46
43
 
47
44
  /** The bases whose words are glossary terms. */
48
45
  const GLOSSARY_BASES = new Set(
@@ -189,6 +186,11 @@ export function entryLanguage(index, note, entry) {
189
186
  * @property {Map<string, Map<string, string[]>>} terms - Each culture's and
190
187
  * affiliation's terms by NFC name, each with the sense texts its sources
191
188
  * give it.
189
+ * @property {Map<string, Map<string, string[]>>} governance - The ranks and
190
+ * offices each owner can name.
191
+ * @property {Map<string, Array<{form: string, gloss: unknown}>>} words - Each
192
+ * language's dictionary words.
193
+ * @property {Map<string, string|undefined>} families - Each language's parent.
192
194
  */
193
195
 
194
196
  /** @type {WeakMap<object, GlossaryCorpus>} */
@@ -239,6 +241,23 @@ function reachedCultures(index, note) {
239
241
  return [...out];
240
242
  }
241
243
 
244
+ /** Add the nearest dictionary definition of each form in a language's lineage. */
245
+ function addLanguageWords(terms, key, language, words, families) {
246
+ const seen = new Set();
247
+ const forms = new Set();
248
+ for (let current = language; current && !seen.has(current); current = families.get(current)) {
249
+ seen.add(current);
250
+ for (const word of words.get(current) ?? []) {
251
+ const form = glossaryName(word.form);
252
+ if (forms.has(form)) continue;
253
+ forms.add(form);
254
+ const senses = glossSenses(word.gloss);
255
+ if (!senses.length) addTerm(terms, key, form, undefined);
256
+ for (const sense of senses) addTerm(terms, key, form, sense);
257
+ }
258
+ }
259
+ }
260
+
242
261
  /**
243
262
  * The glossary corpus of an index.
244
263
  *
@@ -257,18 +276,7 @@ export function glossaryCorpus(index) {
257
276
  if (key) families.set(key, addressKeyOf(dataOf(note).family, index, "skill"));
258
277
  }
259
278
  const terms = new Map();
260
- // A language's words include those it inherits through `data.family`.
261
- const addWords = (key, language) => {
262
- const seen = new Set();
263
- for (
264
- let current = language;
265
- current && !seen.has(current);
266
- current = families.get(current)
267
- ) {
268
- seen.add(current);
269
- for (const word of words.get(current) ?? []) addTerm(terms, key, word.form, word.gloss);
270
- }
271
- };
279
+ const governance = new Map();
272
280
  for (const note of notes) {
273
281
  const culture = isCultureNote(note);
274
282
  if (!culture && typeOf(note) !== "affiliation") continue;
@@ -276,18 +284,22 @@ export function glossaryCorpus(index) {
276
284
  if (!key) continue;
277
285
  if (!terms.has(key)) terms.set(key, new Map());
278
286
  const data = dataOf(note);
279
- if (culture) {
280
- addWords(key, addressKeyOf(data.language, index, "skill"));
281
- continue;
282
- }
287
+ addLanguageWords(terms, key, entryLanguage(index, note, undefined), words, families);
288
+ if (culture) continue;
283
289
  addGovernance(terms, key, data);
284
- addWords(key, entryLanguage(index, note, undefined));
290
+ addGovernance(governance, key, data);
285
291
  for (const home of reachedCultures(index, note)) {
286
292
  addGovernance(terms, home, data);
287
- addWords(home, entryLanguage(index, note, undefined));
293
+ addGovernance(governance, home, data);
288
294
  }
289
295
  }
290
- const corpus = { describesPeoples: notes.some(isCultureNote), terms };
296
+ const corpus = {
297
+ describesPeoples: notes.some(isCultureNote),
298
+ terms,
299
+ governance,
300
+ words,
301
+ families,
302
+ };
291
303
  CORPUS.set(index, corpus);
292
304
  return corpus;
293
305
  }
@@ -298,16 +310,31 @@ export function glossaryCorpus(index) {
298
310
  *
299
311
  * @param {object} index - The link index.
300
312
  * @param {object} note - The culture or affiliation note.
313
+ * @param {object} [entry] - A glossary entry, whose resolved language selects
314
+ * its dictionary words. Absent, the note's own language is used.
301
315
  * @returns {Map<string, string[]>} The terms; empty for a note no source reaches.
302
316
  */
303
- export function noteTerms(index, note) {
317
+ export function noteTerms(index, note, entry) {
304
318
  const key = ownKey(note, index);
305
- return (key && glossaryCorpus(index).terms.get(key)) || new Map();
319
+ const corpus = glossaryCorpus(index);
320
+ if (!entry) return (key && corpus.terms.get(key)) || new Map();
321
+ const own = new Map(
322
+ [...(corpus.governance.get(key) ?? [])].map(([name, senses]) => [name, [...senses]]),
323
+ );
324
+ addLanguageWords(
325
+ new Map([[key, own]]),
326
+ key,
327
+ entryLanguage(index, note, entry),
328
+ corpus.words,
329
+ corpus.families,
330
+ );
331
+ return own;
306
332
  }
307
333
 
308
334
  /**
309
335
  * Warn of an affiliation that reaches no culture, its own or one it inherits,
310
- * in a tree that describes peoples.
336
+ * in a tree that describes peoples. Its ranks and offices then have no
337
+ * culture associations for term queries.
311
338
  *
312
339
  * @param {object} note - An `affiliation` note.
313
340
  * @param {{index?: object}} [context]
@@ -323,7 +350,7 @@ export function checkAffiliationReach(note, { index } = {}) {
323
350
  severity: "warning",
324
351
  message:
325
352
  "this affiliation reaches no culture, its own or one inherited through " +
326
- "`data.parents`, so its ranks, offices and language enter no culture's glossary; " +
353
+ "`data.parents`, so its ranks and offices have no culture associations for term queries; " +
327
354
  "state `data.culture`",
328
355
  },
329
356
  ];
@@ -70,6 +70,8 @@ import { nearest } from "./near-miss.mjs";
70
70
  * every surface shows it exactly as written, never recased.
71
71
  * @property {string} shape - The shape in words, for a finding and the reference.
72
72
  * @property {boolean} [required] - The key must be stated, with a value.
73
+ * @property {boolean} [allowEmpty] - A required string may be empty where
74
+ * empty text is a stated value.
73
75
  * @property {boolean} [nullable] - An explicit `null` states the key, for a
74
76
  * required key whose null has a meaning of its own.
75
77
  * @property {string} [label] - The infobox row label, where humanising the key
@@ -234,7 +236,7 @@ export function pathLabel(path) {
234
236
  function stated(value, spec) {
235
237
  if (value === undefined) return false;
236
238
  if (value === null) return Boolean(spec.nullable);
237
- if (typeof value === "string") return value.trim() !== "";
239
+ if (typeof value === "string") return Boolean(spec.allowEmpty) || value.trim() !== "";
238
240
  return true;
239
241
  }
240
242
 
@@ -249,15 +249,20 @@ export function positionInFrontmatter(raw, key, value = undefined, { topLevel =
249
249
  * {@link positionInFrontmatter}.
250
250
  * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
251
251
  * an identically named nested key cannot answer for it.
252
+ * @param {string} [options.raw] - Source text already read by the caller.
252
253
  * @returns {{line?: number, column?: number}|undefined} Spreadable position
253
254
  * fields, dropped rather than guessed when the file cannot be read or the key
254
255
  * cannot be found — as `formatDiagnostic` requires.
255
256
  */
256
- export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
257
- if (!absPath) return undefined;
258
- let raw;
257
+ export function locateFrontmatterKey(
258
+ absPath,
259
+ key,
260
+ value = undefined,
261
+ { topLevel = false, raw } = {},
262
+ ) {
263
+ if (!absPath && raw === undefined) return undefined;
259
264
  try {
260
- raw = fs.readFileSync(absPath, "utf8");
265
+ raw ??= fs.readFileSync(absPath, "utf8");
261
266
  } catch {
262
267
  return undefined;
263
268
  }
@@ -306,6 +311,9 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
306
311
  };
307
312
  }
308
313
 
314
+ /** The most recently located text; retaining one document bounds memory. */
315
+ let locatedYaml;
316
+
309
317
  /**
310
318
  * Where a **key or value in a YAML document** sits, addressed by its path.
311
319
  *
@@ -315,11 +323,10 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
315
323
  * first occurrence is routinely not the one the finding is about — which is the
316
324
  * one thing the located form exists to prevent.
317
325
  *
318
- * A path resolves the exact node instead. The document is re-parsed here rather
319
- * than threaded from the loader because the loader returns plain data: `yaml`
320
- * discards ranges once a document is materialised, and carrying a parallel
321
- * position tree through configuration resolution would be a second
322
- * representation of the same file to keep in step.
326
+ * A path resolves the exact node. The most recently located text retains its
327
+ * document and line counter, so several findings in one note share a parse.
328
+ * A different text replaces that entry, including an edited copy of the same
329
+ * file. The loader returns plain data without source ranges.
323
330
  *
324
331
  * Every failure — unparseable text, an `.mjs` configuration, a path that
325
332
  * resolves to nothing — yields `{}`, so a caller spreads the result and the
@@ -348,9 +355,15 @@ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
348
355
  if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
349
356
 
350
357
  let node;
351
- const counter = new LineCounter();
358
+ let counter;
352
359
  try {
353
- const doc = YAML.parseDocument(text, { lineCounter: counter });
360
+ if (locatedYaml?.text !== text) {
361
+ const lineCounter = new LineCounter();
362
+ const doc = YAML.parseDocument(text, { lineCounter });
363
+ locatedYaml = { text, doc, counter: lineCounter };
364
+ }
365
+ const { doc } = locatedYaml;
366
+ counter = locatedYaml.counter;
354
367
  // `keepScalar` returns the Scalar node rather than its value, which is
355
368
  // the only form carrying a range.
356
369
  node = doc.getIn(keyPath, true);