@heroiclands/package-build 4.0.0 → 6.0.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.
@@ -33,7 +33,7 @@
33
33
  * assets:
34
34
  * - { from: assets/icons, to: assets/icons }
35
35
  * publish:
36
- * site: true
36
+ * site: content
37
37
  * manifests: { publish: true, consume: true }
38
38
  * ```
39
39
  *
@@ -153,6 +153,62 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
153
153
  landing: "readme",
154
154
  });
155
155
 
156
+ /**
157
+ * How much of a package reaches the web.
158
+ *
159
+ * Every HeroicLands package publishes something: a top-level, human-authored
160
+ * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
161
+ * module is, which system it needs and how to install it (#50). So there is no
162
+ * value here meaning *no web presence at all* — homepage-only is the **floor**,
163
+ * and the default.
164
+ *
165
+ * - `homepage` — the authored homepage, and **no other page**. The content tree
166
+ * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
167
+ * emit nothing, and link-manifest entries carry no web `path`.
168
+ * - `content` — the homepage *plus* every page the content tree publishes: the
169
+ * knowledgebase, the extra trees, the section landings.
170
+ *
171
+ * **Homepage-only is a first-class mode, not an accommodation.**
172
+ * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
173
+ * Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
174
+ * terms) must each publish a homepage and nothing beneath it — two packages
175
+ * under two different fan-content licences. The boundary is **published
176
+ * content**: journal text, artwork, item descriptions, compiled notes. A
177
+ * human-authored page announcing the module discloses none of it. Because the
178
+ * failure mode is silent — a `site:` block added later ships licensed content
179
+ * with nobody noticing — the mode fences the content surfaces off rather than
180
+ * trusting a configuration to stay empty.
181
+ *
182
+ * This was a boolean until 5.0.0, and `false` read as "no web presence", which
183
+ * no longer describes any package. Both spellings are refused rather than
184
+ * mapped: a value silently reinterpreted reads to its author as though it still
185
+ * means what it said.
186
+ *
187
+ * @typedef {"homepage" | "content"} SiteMode
188
+ */
189
+
190
+ /**
191
+ * The publishing modes {@link PublishSwitches.site} may name, floor first.
192
+ *
193
+ * @satisfies {readonly SiteMode[]}
194
+ */
195
+ export const SITE_MODES = /** @type {const} */ (["homepage", "content"]);
196
+
197
+ /**
198
+ * Whether this package publishes the pages its content tree compiles to.
199
+ *
200
+ * The one question every reader of the mode actually asks — the site build, to
201
+ * decide whether to walk the tree at all, and the link-manifest emitter, to
202
+ * decide whether an entry carries a web `path`. Written once here so the two
203
+ * cannot come to disagree about what a mode means.
204
+ *
205
+ * @param {{publish: {site: SiteMode}}} config - A resolved configuration.
206
+ * @returns {boolean} Whether content pages are published.
207
+ */
208
+ export function publishesContentPages(config) {
209
+ return config.publish.site === "content";
210
+ }
211
+
156
212
  /**
157
213
  * @typedef {"systems" | "modules"} PackageKind
158
214
  */
@@ -300,7 +356,8 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
300
356
 
301
357
  /**
302
358
  * @typedef {object} PublishSwitches
303
- * @property {boolean} site Render this package's knowledgebase/site pages.
359
+ * @property {SiteMode} site How much of this package reaches the web.
360
+ * See {@link SITE_MODES}.
304
361
  * @property {ManifestSwitches} manifests
305
362
  */
306
363
 
@@ -387,8 +444,15 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
387
444
 
388
445
  /**
389
446
  * @typedef {object} PublishSwitchesInput
390
- * @property {boolean} [site]
447
+ * @property {SiteMode} [site]
391
448
  * @property {ManifestSwitchesInput} [manifests]
449
+ * @property {AddressSchemeInput} [address]
450
+ */
451
+
452
+ /**
453
+ * @typedef {object} AddressSchemeInput
454
+ * @property {string} [prefix] Where the content tree mounts inside the package.
455
+ * @property {string} [landing] Which note addresses a whole section.
392
456
  */
393
457
 
394
458
  /**
@@ -454,7 +518,9 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
454
518
  * which has none to invent.
455
519
  * @property {Relationships} [relationships] What this package declares about
456
520
  * others, in Foundry's own shape.
457
- * @property {PublishSwitchesInput} [publish] Publishing switches. Each defaults to off.
521
+ * @property {PublishSwitchesInput} [publish] Publishing switches. The manifest
522
+ * switches default to off; `site`
523
+ * defaults to `homepage`, the floor.
458
524
  */
459
525
 
460
526
  /**
@@ -1272,6 +1338,43 @@ function normalizeItemBuilders(value) {
1272
1338
  };
1273
1339
  }
1274
1340
 
1341
+ /**
1342
+ * The publishing mode, refusing the boolean this setting used to be.
1343
+ *
1344
+ * A boolean is refused rather than mapped onto the nearest mode, because the
1345
+ * reading `false` invited — *this package has no web presence* — is exactly the
1346
+ * belief the change exists to correct, and a value quietly reinterpreted reads
1347
+ * to its author as though it still means what it said. So the message names the
1348
+ * mode to write instead of the value to fix.
1349
+ *
1350
+ * @param {unknown} value - The authored `publish.site`.
1351
+ * @returns {SiteMode} The mode.
1352
+ */
1353
+ function normalizeSiteMode(value) {
1354
+ if (value === undefined) return "homepage";
1355
+ if (typeof value === "boolean") {
1356
+ fail(
1357
+ "publish.site",
1358
+ `is no longer a boolean — write \`site: ${value ? "content" : "homepage"}\`. ` +
1359
+ `Every package publishes an authored homepage at ` +
1360
+ `/<contentPackage>/, so no value means "no web presence": ` +
1361
+ `\`homepage\` publishes that page and nothing else, and ` +
1362
+ `\`content\` publishes it plus every page the content tree ` +
1363
+ `compiles to`,
1364
+ );
1365
+ }
1366
+ if (
1367
+ typeof value !== "string" ||
1368
+ !(/** @type {readonly string[]} */ (SITE_MODES).includes(value))
1369
+ ) {
1370
+ fail(
1371
+ "publish.site",
1372
+ `must be one of ${SITE_MODES.join(", ")} (got ${JSON.stringify(value)})`,
1373
+ );
1374
+ }
1375
+ return /** @type {SiteMode} */ (value);
1376
+ }
1377
+
1275
1378
  /**
1276
1379
  * @param {unknown} value
1277
1380
  * @returns {Readonly<PublishSwitches>}
@@ -1279,7 +1382,7 @@ function normalizeItemBuilders(value) {
1279
1382
  function normalizePublish(value) {
1280
1383
  if (value === undefined) {
1281
1384
  return Object.freeze({
1282
- site: false,
1385
+ site: "homepage",
1283
1386
  manifests: Object.freeze({ publish: false, consume: false }),
1284
1387
  address: Object.freeze({ ...DEFAULT_ADDRESS_SCHEME }),
1285
1388
  });
@@ -1332,7 +1435,7 @@ function normalizePublish(value) {
1332
1435
  }
1333
1436
 
1334
1437
  return Object.freeze({
1335
- site: optionalBoolean(publish.site, "publish.site", false),
1438
+ site: normalizeSiteMode(publish.site),
1336
1439
  address: Object.freeze({ prefix, landing }),
1337
1440
  manifests: Object.freeze({
1338
1441
  publish: optionalBoolean(
@@ -34,9 +34,11 @@
34
34
  * | {@link BasePackCompiler#finish} | Work that needs every note first. |
35
35
  * | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
36
36
  *
37
- * plus two static switches — `requiresId` (a note with no id is fatal, or
38
- * merely skipped) and `convertsWikilinks` (whether the body reaching
39
- * `buildEntry` is converted or exactly as authored).
37
+ * plus three static switches — `requiresId` (a note with no id is fatal, or
38
+ * merely skipped), `convertsWikilinks` (whether the body reaching
39
+ * `buildEntry` is converted or exactly as authored) and `readsPackOutputOf`
40
+ * (the document types whose compiled output this pass reads, which is what the
41
+ * generator derives the compile order from).
40
42
  *
41
43
  * `selects` answers *which document type* a pass claims, and it is the same
42
44
  * answer for every pack of that type. Which **pack of that type** a claimed
@@ -141,6 +143,29 @@ export class BasePackCompiler {
141
143
  */
142
144
  static convertsWikilinks = true;
143
145
 
146
+ /**
147
+ * The document types whose **compiled output** this pass reads.
148
+ *
149
+ * Empty for every pass that reads only the content tree. The actors pass
150
+ * is the exception: a being names its embedded items by
151
+ * `(type, shortcode)`, and it resolves them against the JSON the item
152
+ * passes wrote — so an Actor pass must run after every Item pass, and it
153
+ * says so here.
154
+ *
155
+ * The generator derives the compile order from this (#73), so the order
156
+ * `packs:` declares is presentation only — it is the manifest's `packs`
157
+ * array as well, and a consumer orders that for a reader. A pass that
158
+ * reads another's output states the dependency once, in the class that
159
+ * does the reading, instead of every consuming repository having to know
160
+ * it when writing its pack list.
161
+ *
162
+ * A consumer registering a compiler of its own declares its dependencies
163
+ * the same way; a type no pack declares is simply not waited for.
164
+ *
165
+ * @type {readonly string[]}
166
+ */
167
+ static readsPackOutputOf = Object.freeze([]);
168
+
144
169
  /** @type {string} */
145
170
  contentBase;
146
171
  /** @type {string} */
@@ -61,6 +61,8 @@ import {
61
61
  readCanonicalKey,
62
62
  } from "./kb-manifest.mjs";
63
63
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
64
+ import { homepageAddresses, isHomepage } from "./homepage.mjs";
65
+ import { RETIRED_TYPES } from "./ids.mjs";
64
66
  import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
65
67
  import { readQualifier } from "./wikilinks.mjs";
66
68
 
@@ -305,6 +307,12 @@ export function buildLinkIndex(
305
307
  anchors,
306
308
  types,
307
309
  packages,
310
+ /**
311
+ * The one package this tree publishes. Distinct from `packages`, which
312
+ * is the set an address may name and which a homepage-only tree leaves
313
+ * this package out of, having no keyed note to put it there.
314
+ */
315
+ contentPackage: pkg,
308
316
  foreign,
309
317
  manifests: manifestsComplete(localPackages, foreign.packages),
310
318
  linksOf,
@@ -315,13 +323,223 @@ export function buildLinkIndex(
315
323
  };
316
324
  }
317
325
 
326
+ /**
327
+ * The site this project publishes on, as a host pattern.
328
+ *
329
+ * Hardcoded, as it is in {@link module:engine/homepage} already: every package's
330
+ * address is `https://www.heroiclands.org/<contentPackage>/`, and the whole
331
+ * point of the rule below is that an author *should not* be writing that host
332
+ * into a page. A configurable host would be a second place to write down the
333
+ * thing being discouraged.
334
+ *
335
+ * @type {RegExp}
336
+ */
337
+ const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
338
+
339
+ /**
340
+ * How an authored address resolves, or `null` for one nothing here can judge.
341
+ *
342
+ * Three shapes reach the site and one does not, and the distinction is the
343
+ * whole of what is checkable. An address into this site can be reasoned about
344
+ * from the package roster alone; an address to `github.com`, `kelestia.com` or
345
+ * `discord.gg` cannot be reasoned about at all without fetching it, and a build
346
+ * must not depend on a third party being up.
347
+ *
348
+ * @param {string} url - The authored address.
349
+ * @param {ReadonlySet<string>} packages - Package prefixes this build can name.
350
+ * @returns {{shape: string, segments: string[], prefix: string|null}|null} The
351
+ * shape, the path segments, and the package prefix the address starts with.
352
+ */
353
+ function readAddress(url, packages) {
354
+ const value = String(url ?? "").trim();
355
+ if (!value || value.startsWith("#")) return null;
356
+
357
+ let segments;
358
+ let shape;
359
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
360
+ let parsed;
361
+ try {
362
+ parsed = new URL(value);
363
+ } catch {
364
+ return null;
365
+ }
366
+ if (!/^https?:$/.test(parsed.protocol)) return null;
367
+ if (!SITE_HOST.test(parsed.hostname)) return null;
368
+ shape = "absolute";
369
+ segments = parsed.pathname.split("/").filter(Boolean);
370
+ } else if (value.startsWith("/")) {
371
+ shape = "rooted";
372
+ segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
373
+ } else {
374
+ shape = "relative";
375
+ segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
376
+ }
377
+
378
+ const prefix =
379
+ shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
380
+ return { shape, segments, prefix };
381
+ }
382
+
383
+ /**
384
+ * Every defect in the addresses a package homepage carries.
385
+ *
386
+ * **Why the homepage needs its own audit at all.** Every other note addresses
387
+ * the corpus with wikilinks, which {@link auditLinks} resolves. A homepage does
388
+ * not and cannot: it is published *verbatim* by every publishing mode, including
389
+ * the homepage-only mode two fan-licensed packages ship under, where the content
390
+ * tree is never walked and there is no index for a wikilink to resolve against.
391
+ * So a landing addresses the web the way the web does — markdown links and
392
+ * `url:` fields — and nothing was looking at those. SoHL's landing pointed at
393
+ * `kb/creature/` and `kb/character/` from the day those types merged into
394
+ * `being`: two 404s on the package's front page, through every build.
395
+ *
396
+ * **What is checkable, stated plainly.** Only an address into this site is, and
397
+ * only against facts this build already holds:
398
+ *
399
+ * - A **retired content type** in the path. The engine knows what used to exist
400
+ * and what replaced it, so this is a fact rather than a guess — and it is
401
+ * exactly the SoHL defect.
402
+ * - A **hardcoded absolute URL** into this package's own prefix, or into one a
403
+ * vendored manifest names. Both have a better form to write, which is why they
404
+ * are reported; a bare `/<package>/` is left alone, because a package
405
+ * homepage is in no manifest and there is nothing better to write.
406
+ * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
407
+ * time. `href:` means "already resolved, use verbatim", so the same leading
408
+ * slash is correct there and is not reported.
409
+ * - A **wikilink**, which nothing on this page will ever resolve.
410
+ *
411
+ * **What is not checkable, and is not attempted.** Whether an external URL
412
+ * answers — there is no network at build time, and a build must not fail because
413
+ * a third party is down. And whether a live in-site address names a page that
414
+ * exists: several of the surfaces a landing routes to are produced by other
415
+ * tools entirely (generated API documentation, hand-authored Hugo sections), so
416
+ * this build does not hold the set of published pages and would report a working
417
+ * link as dead.
418
+ *
419
+ * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
420
+ * @returns {Array<{note: object, field: string, url: string, text: string,
421
+ * occurrence: number, message: string}>} One finding per defect, `text` and
422
+ * `occurrence` locating it in the note's raw source.
423
+ */
424
+ export function auditHomepageLinks(index) {
425
+ const findings = [];
426
+ const packages = new Set([index.contentPackage, ...index.packages]);
427
+
428
+ for (const note of index.notes) {
429
+ if (!isHomepage(note.fm)) continue;
430
+
431
+ // How many times each literal has been seen, so two identical
432
+ // addresses are located at their own positions.
433
+ const seen = new Map();
434
+ const at = (text) => {
435
+ const occurrence = (seen.get(text) ?? 0) + 1;
436
+ seen.set(text, occurrence);
437
+ return occurrence;
438
+ };
439
+ const report = (field, url, text, occurrence, message) =>
440
+ findings.push({ note, field, url, text, occurrence, message });
441
+
442
+ for (const [all, rawInner] of matchAllOutsideCode(
443
+ note.body,
444
+ new RegExp(WIKILINK.source, "g"),
445
+ )) {
446
+ const { target } = parseWikilink(rawInner);
447
+ report(
448
+ "body",
449
+ target,
450
+ all,
451
+ at(all),
452
+ `wikilink ${all} on the package homepage — a homepage is ` +
453
+ `published verbatim in every publishing mode, so nothing ` +
454
+ `resolves it; write a markdown link, package-relative`,
455
+ );
456
+ }
457
+
458
+ for (const { field, url, kind } of homepageAddresses(
459
+ note.fm,
460
+ note.body,
461
+ )) {
462
+ // Counted for every address, checked or not, so the count is
463
+ // the literal's nth appearance in the file rather than the nth
464
+ // *finding* about it — two rules can fire on one address.
465
+ const occurrence = at(url);
466
+ const address = readAddress(url, packages);
467
+ if (!address) continue;
468
+ const { shape, segments, prefix } = address;
469
+
470
+ if (shape === "absolute" && prefix) {
471
+ // A bare `/<package>/` is a package's homepage, which is in no
472
+ // link manifest and has no relative form from another package.
473
+ // A finding with no fix is noise.
474
+ const rest = segments.slice(1).join("/");
475
+ if (rest) {
476
+ report(
477
+ field,
478
+ url,
479
+ url,
480
+ occurrence,
481
+ prefix === index.contentPackage ?
482
+ `hardcoded absolute URL into this package's own ` +
483
+ `address — write the package-relative ` +
484
+ `"${rest}/", which the landing resolves ` +
485
+ `against the site so the page follows the mount`
486
+ : `hardcoded absolute URL into package "${prefix}" ` +
487
+ `— resolve it through that package's link ` +
488
+ `manifest, whose entries carry the address, so a ` +
489
+ `relocation does not leave this page behind`,
490
+ );
491
+ }
492
+ } else if (shape === "rooted" && kind === "url") {
493
+ const rest =
494
+ prefix ? segments.slice(1).join("/") : segments.join("/");
495
+ report(
496
+ field,
497
+ url,
498
+ url,
499
+ occurrence,
500
+ `url "${url}" is root-relative, but a landing's url: is ` +
501
+ `resolved against the site — write "${rest}/", or ` +
502
+ `href: for an address that is already resolved`,
503
+ );
504
+ }
505
+
506
+ // The retired-type rule reads the path *inside* the package, so an
507
+ // address that named one is fixed the same way wherever it was
508
+ // written.
509
+ const inPackage = prefix ? segments.slice(1) : segments;
510
+ for (const [i, segment] of inPackage.entries()) {
511
+ // `hasOwn`, not a plain lookup: a path segment spelled
512
+ // `constructor` would otherwise inherit a truthy answer from
513
+ // `Object.prototype` and be reported as retired.
514
+ if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
515
+ const replacement = RETIRED_TYPES[segment];
516
+ const fixed = [...inPackage];
517
+ fixed[i] = replacement;
518
+ report(
519
+ field,
520
+ url,
521
+ url,
522
+ occurrence,
523
+ `address "${url}" names content type "${segment}", ` +
524
+ `retired in favour of "${replacement}" — both ` +
525
+ `compiled to the same document, so the fix is ` +
526
+ `mechanical: "${fixed.join("/")}/"`,
527
+ );
528
+ }
529
+ }
530
+ }
531
+
532
+ return findings;
533
+ }
534
+
318
535
  /**
319
536
  * Every link in a tree that lands nowhere.
320
537
  *
321
538
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
322
539
  * @returns {{deadAnchors: object[], deadAddresses: object[],
323
- * frontmatterLinks: object[], usedManifest: Set<string>}} The findings, and
324
- * which addresses a foreign manifest answered.
540
+ * frontmatterLinks: object[], homepageLinks: object[],
541
+ * usedManifest: Set<string>}} The findings, and which addresses a foreign
542
+ * manifest answered.
325
543
  */
326
544
  export function auditLinks(index) {
327
545
  const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
@@ -370,6 +588,7 @@ export function auditLinks(index) {
370
588
  deadAnchors,
371
589
  deadAddresses,
372
590
  frontmatterLinks: index.frontmatterLinks,
591
+ homepageLinks: auditHomepageLinks(index),
373
592
  usedManifest,
374
593
  };
375
594
  }
@@ -164,15 +164,24 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
164
164
  // "Every one of nothing is unique" is a vacuous pass, and it is exactly
165
165
  // what a tree that failed to check out produces — so the lint would go
166
166
  // green on the one state it most needs to catch.
167
- if (byKey.size === 0) {
167
+ //
168
+ // The state that catches is an **empty walk**, not an empty key set (#77).
169
+ // A note may be keyless by design: a homepage carries no `shortcode`
170
+ // because it is addressed by the package rather than by a slug, so a
171
+ // package in `publish.site: homepage` mode has a content tree that is
172
+ // populated, correct, and permanently unkeyed. Reporting that as a missing
173
+ // checkout trains its author to stop reading the output — the one thing
174
+ // this guard needs them to do. A tree holding notes is therefore a tree;
175
+ // only a tree holding none is the absent one.
176
+ if (notes.length === 0) {
168
177
  findings.push({
169
178
  file: path.relative(process.cwd(), contentBase) || contentBase,
170
179
  severity: "error",
171
180
  message:
172
- "holds no keyed content, so every rule here is vacuous — " +
181
+ "holds no content notes, so every rule here is vacuous — " +
173
182
  "check that the content tree is present and that this is its root",
174
183
  });
175
- return { findings, notes: notes.length, keys: 0 };
184
+ return { findings, notes: 0, keys: 0 };
176
185
  }
177
186
 
178
187
  for (const [key, files] of byKey) {
@@ -44,6 +44,7 @@
44
44
  */
45
45
 
46
46
  import path from "node:path";
47
+ import YAML, { LineCounter } from "yaml";
47
48
 
48
49
  /**
49
50
  * The `file:line:column` locator, with whatever is known.
@@ -268,3 +269,48 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
268
269
  column: at - before.lastIndexOf("\n"),
269
270
  };
270
271
  }
272
+
273
+ /**
274
+ * Where a **key or value in a YAML document** sits, addressed by its path.
275
+ *
276
+ * {@link positionOfLiteral} is the plain search, and it is the wrong tool for a
277
+ * configuration file: a pack name like `items` or `actors` appears in the
278
+ * `packs:` block, in a folder's list, in a path, and often in prose, so the
279
+ * first occurrence is routinely not the one the finding is about — which is the
280
+ * one thing the located form exists to prevent.
281
+ *
282
+ * A path resolves the exact node instead. The document is re-parsed here rather
283
+ * than threaded from the loader because the loader returns plain data: `yaml`
284
+ * discards ranges once a document is materialised, and carrying a parallel
285
+ * position tree through configuration resolution would be a second
286
+ * representation of the same file to keep in step.
287
+ *
288
+ * Every failure — unparseable text, an `.mjs` configuration, a path that
289
+ * resolves to nothing — yields `{}`, so a caller spreads the result and the
290
+ * position is dropped rather than guessed.
291
+ *
292
+ * @param {string} text - The document's contents.
293
+ * @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
294
+ * strings, sequence entries as numbers.
295
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
296
+ */
297
+ export function positionOfYamlPath(text, keyPath) {
298
+ if (typeof text !== "string" || !text) return {};
299
+ if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
300
+
301
+ let node;
302
+ const counter = new LineCounter();
303
+ try {
304
+ const doc = YAML.parseDocument(text, { lineCounter: counter });
305
+ // `keepScalar` returns the Scalar node rather than its value, which is
306
+ // the only form carrying a range.
307
+ node = doc.getIn(keyPath, true);
308
+ } catch {
309
+ return {};
310
+ }
311
+
312
+ const start = node?.range?.[0];
313
+ if (!Number.isFinite(start)) return {};
314
+ const { line, col } = counter.linePos(start);
315
+ return { line, column: col };
316
+ }