@heroiclands/package-build 8.1.0 → 10.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -59,6 +59,7 @@
59
59
  import fs from "node:fs";
60
60
  import path from "node:path";
61
61
 
62
+ import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
62
63
  import { positionInFrontmatter } from "./diagnostics.mjs";
63
64
  import { walkMarkdownTree } from "./helpers.mjs";
64
65
  import { checkHomepageCount, isHomepage } from "./homepage.mjs";
@@ -66,6 +67,12 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
66
67
  /**
67
68
  * The shape every `shortcode` must match: ASCII letters and digits only.
68
69
  *
70
+ * This is {@link ADDRESS_SEGMENT_PATTERN}, not a second copy of it. A shortcode
71
+ * is the last segment of a canonical address, and the rule it is held to is the
72
+ * rule *every* segment is held to — so the two are one constant rather than two
73
+ * free to drift apart (#59). The name survives because this is where the rule
74
+ * is applied to a note.
75
+ *
69
76
  * Case is deliberately **not** constrained: hundreds of authored shortcodes are
70
77
  * mixed-case and collide with nothing, so tightening that is a separate
71
78
  * decision from this one.
@@ -74,7 +81,7 @@ import { checkHomepageCount, isHomepage } from "./homepage.mjs";
74
81
  * import a build-time dependency into shipped code — and is expected to pin the
75
82
  * two together with a test rather than trust that they still agree.
76
83
  */
77
- export const SHORTCODE_PATTERN = /^[A-Za-z0-9]+$/;
84
+ export const SHORTCODE_PATTERN = ADDRESS_SEGMENT_PATTERN;
78
85
 
79
86
  /**
80
87
  * Whether a value is a well-formed shortcode.
@@ -106,10 +113,7 @@ export function isValidShortcode(value) {
106
113
  function collectNotes(contentBase, { skipDirectories } = {}) {
107
114
  const notes = [];
108
115
  const walkOpts = skipDirectories ? { skipDirectories } : undefined;
109
- for (const { frontmatter: fm, absPath } of walkMarkdownTree(
110
- contentBase,
111
- walkOpts,
112
- )) {
116
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(contentBase, walkOpts)) {
113
117
  if (!fm || !fm.type) continue;
114
118
  notes.push({
115
119
  fm,
@@ -134,10 +138,7 @@ function collectNotes(contentBase, { skipDirectories } = {}) {
134
138
  * severity: "error"|"warning", message: string}>, notes: number,
135
139
  * keys: number}} The findings, and what was inspected to produce them.
136
140
  */
137
- export function lintContentTree(
138
- contentBase,
139
- { skipDirectories, contentPackage } = {},
140
- ) {
141
+ export function lintContentTree(contentBase, { skipDirectories, contentPackage } = {}) {
141
142
  const findings = [];
142
143
  const notes = collectNotes(contentBase, { skipDirectories });
143
144
 
@@ -211,15 +212,10 @@ export function lintContentTree(
211
212
  // is a place an author has to go and edit, and a finding naming only
212
213
  // the key sends them hunting for the other one.
213
214
  for (const { file, absPath } of files) {
214
- const others = files
215
- .filter((f) => f.file !== file)
216
- .map((f) => f.file);
215
+ const others = files.filter((f) => f.file !== file).map((f) => f.file);
217
216
  findings.push({
218
217
  file,
219
- ...positionInFrontmatter(
220
- fs.readFileSync(absPath, "utf8"),
221
- "shortcode",
222
- ),
218
+ ...positionInFrontmatter(fs.readFileSync(absPath, "utf8"), "shortcode"),
223
219
  severity: "error",
224
220
  message:
225
221
  `duplicate address "${key}", also declared by ` +
@@ -133,13 +133,9 @@ export function contentSlug(name) {
133
133
  throw new Error("content note has no name, so it has no URL");
134
134
  }
135
135
  const normalised = slugify(raw);
136
- const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join(
137
- "-",
138
- );
136
+ const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join("-");
139
137
  if (!slug) {
140
- throw new Error(
141
- `name "${raw}" has no URL-safe characters, so it cannot address a page`,
142
- );
138
+ throw new Error(`name "${raw}" has no URL-safe characters, so it cannot address a page`);
143
139
  }
144
140
  return slug;
145
141
  }
@@ -199,13 +199,7 @@ function tokenize(source) {
199
199
  const CLAUSE_RANK = { TABLE: 0, FROM: 1, WHERE: 2, SORT: 3, LIMIT: 4 };
200
200
 
201
201
  /** Query types and data commands that are recognised only to be refused. */
202
- const UNSUPPORTED_CLAUSES = new Set([
203
- "LIST",
204
- "TASK",
205
- "CALENDAR",
206
- "GROUP",
207
- "FLATTEN",
208
- ]);
202
+ const UNSUPPORTED_CLAUSES = new Set(["LIST", "TASK", "CALENDAR", "GROUP", "FLATTEN"]);
209
203
 
210
204
  /**
211
205
  * Group a query's tokens by clause.
@@ -227,21 +221,15 @@ function splitClauses(tokens) {
227
221
  if (token.value === "(" || token.value === "[") depth++;
228
222
  else if (token.value === ")" || token.value === "]") depth--;
229
223
  }
230
- const word =
231
- token.kind === "ident" ? token.value.toUpperCase() : undefined;
224
+ const word = token.kind === "ident" ? token.value.toUpperCase() : undefined;
232
225
  if (depth === 0 && word && UNSUPPORTED_CLAUSES.has(word)) {
233
226
  // `GROUP BY` is two words; report it the way an author wrote it.
234
227
  const label =
235
- (
236
- word === "GROUP" &&
237
- tokens[i + 1]?.value?.toUpperCase?.() === "BY"
238
- ) ?
228
+ word === "GROUP" && tokens[i + 1]?.value?.toUpperCase?.() === "BY" ?
239
229
  "GROUP BY"
240
230
  : word;
241
231
  if (current === null) {
242
- throw new Error(
243
- `only TABLE queries are supported; this block is a ${label} query`,
244
- );
232
+ throw new Error(`only TABLE queries are supported; this block is a ${label} query`);
245
233
  }
246
234
  throw new Error(`the ${label} clause is not supported`);
247
235
  }
@@ -333,25 +321,19 @@ class ExprParser {
333
321
  /** Is the next token this operator/punctuation? */
334
322
  at(value) {
335
323
  const t = this.peek();
336
- return (
337
- (t.kind === "op" || t.kind === "punct") && t.value === String(value)
338
- );
324
+ return (t.kind === "op" || t.kind === "punct") && t.value === String(value);
339
325
  }
340
326
 
341
327
  /** Is the next token this (case-insensitive) bare word? */
342
328
  atWord(word) {
343
329
  const t = this.peek();
344
- return (
345
- t.kind === "ident" && t.value.toLowerCase() === word.toLowerCase()
346
- );
330
+ return t.kind === "ident" && t.value.toLowerCase() === word.toLowerCase();
347
331
  }
348
332
 
349
333
  /** Consume the next token if it matches; report otherwise. */
350
334
  expect(value) {
351
335
  if (!this.at(value)) {
352
- throw new Error(
353
- `expected "${value}", got "${this.peek().text || "end of query"}"`,
354
- );
336
+ throw new Error(`expected "${value}", got "${this.peek().text || "end of query"}"`);
355
337
  }
356
338
  return this.next();
357
339
  }
@@ -394,10 +376,7 @@ class ExprParser {
394
376
  parseComparison() {
395
377
  const left = this.parseUnary();
396
378
  const t = this.peek();
397
- if (
398
- t.kind === "op" &&
399
- ["=", "==", "!=", ">", ">=", "<", "<="].includes(t.value)
400
- ) {
379
+ if (t.kind === "op" && ["=", "==", "!=", ">", ">=", "<", "<="].includes(t.value)) {
401
380
  this.next();
402
381
  return {
403
382
  kind: "compare",
@@ -502,9 +481,7 @@ class ExprParser {
502
481
  }
503
482
  return { kind: "field", path: t.value };
504
483
  }
505
- throw new Error(
506
- `unexpected "${t.text || "end of query"}" in expression`,
507
- );
484
+ throw new Error(`unexpected "${t.text || "end of query"}" in expression`);
508
485
  }
509
486
 
510
487
  /**
@@ -544,9 +521,7 @@ export function parseDataviewQuery(source) {
544
521
  if (parser.atWord("without")) {
545
522
  parser.next();
546
523
  if (!parser.atWord("id")) {
547
- throw new Error(
548
- `expected "WITHOUT ID", got "WITHOUT ${parser.peek().text}"`,
549
- );
524
+ throw new Error(`expected "WITHOUT ID", got "WITHOUT ${parser.peek().text}"`);
550
525
  }
551
526
  parser.next();
552
527
  withoutId = true;
@@ -586,9 +561,7 @@ export function parseDataviewQuery(source) {
586
561
  const fromParser = new ExprParser(clauses.get("FROM"), text);
587
562
  from = fromParser.parseExpression();
588
563
  if (!fromParser.done) {
589
- throw new Error(
590
- `unexpected "${fromParser.peek().text}" in the FROM clause`,
591
- );
564
+ throw new Error(`unexpected "${fromParser.peek().text}" in the FROM clause`);
592
565
  }
593
566
  }
594
567
 
@@ -598,9 +571,7 @@ export function parseDataviewQuery(source) {
598
571
  const whereParser = new ExprParser(clauses.get("WHERE"), text);
599
572
  where = whereParser.parseExpression();
600
573
  if (!whereParser.done) {
601
- throw new Error(
602
- `unexpected "${whereParser.peek().text}" in the WHERE clause`,
603
- );
574
+ throw new Error(`unexpected "${whereParser.peek().text}" in the WHERE clause`);
604
575
  }
605
576
  }
606
577
 
@@ -614,10 +585,7 @@ export function parseDataviewQuery(source) {
614
585
  if (sortParser.atWord("desc") || sortParser.atWord("descending")) {
615
586
  sortParser.next();
616
587
  descending = true;
617
- } else if (
618
- sortParser.atWord("asc") ||
619
- sortParser.atWord("ascending")
620
- ) {
588
+ } else if (sortParser.atWord("asc") || sortParser.atWord("ascending")) {
621
589
  sortParser.next();
622
590
  }
623
591
  sort.push({ expr, descending });
@@ -648,8 +616,7 @@ const makeLink = (display) => ({ __link: true, display: String(display) });
648
616
 
649
617
  const isLink = (v) => Boolean(v) && typeof v === "object" && v.__link === true;
650
618
 
651
- const isEmpty = (v) =>
652
- v == null || v === "" || (Array.isArray(v) && v.length === 0);
619
+ const isEmpty = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);
653
620
 
654
621
  /** Dataview treats an absent, empty, zero, or false value as false. */
655
622
  const truthy = (v) => {
@@ -813,9 +780,7 @@ function containsValue(haystack, needle, mode) {
813
780
  if (haystack == null) return false;
814
781
  if (Array.isArray(haystack)) {
815
782
  return haystack.some((v) =>
816
- mode === "exact" ?
817
- looseEquals(v, needle)
818
- : containsValue(v, needle, mode),
783
+ mode === "exact" ? looseEquals(v, needle) : containsValue(v, needle, mode),
819
784
  );
820
785
  }
821
786
  if (typeof haystack === "object" && !isLink(haystack)) {
@@ -842,8 +807,7 @@ function toRegExp(pattern, anchored) {
842
807
 
843
808
  /** The implementations behind {@link FUNCTIONS}. */
844
809
  const CALLS = {
845
- link: (args) =>
846
- makeLink(args.length > 1 && !isEmpty(args[1]) ? asText(args[1]) : ""),
810
+ link: (args) => makeLink(args.length > 1 && !isEmpty(args[1]) ? asText(args[1]) : ""),
847
811
  contains: ([a, b]) => containsValue(a, b, "loose"),
848
812
  icontains: ([a, b]) => containsValue(a, b, "insensitive"),
849
813
  econtains: ([a, b]) => containsValue(a, b, "exact"),
@@ -867,10 +831,8 @@ const CALLS = {
867
831
  .filter((v) => !isEmpty(v))
868
832
  .map(asText)
869
833
  .join(sep === undefined ? ", " : asText(sep)),
870
- regexmatch: ([pattern, value]) =>
871
- toRegExp(pattern, true).test(asText(value)),
872
- regextest: ([pattern, value]) =>
873
- toRegExp(pattern, false).test(asText(value)),
834
+ regexmatch: ([pattern, value]) => toRegExp(pattern, true).test(asText(value)),
835
+ regextest: ([pattern, value]) => toRegExp(pattern, false).test(asText(value)),
874
836
  };
875
837
 
876
838
  /**
@@ -980,11 +942,7 @@ function matchesSource(node, doc) {
980
942
  const folder = value.replace(/\/+$/, "");
981
943
  const path = doc.path ?? "";
982
944
  if (folder === "" || folder === "/") return true;
983
- return (
984
- path === folder ||
985
- path === `${folder}.md` ||
986
- path.startsWith(`${folder}/`)
987
- );
945
+ return path === folder || path === `${folder}.md` || path.startsWith(`${folder}/`);
988
946
  }
989
947
  default:
990
948
  throw new Error(
@@ -1015,10 +973,7 @@ export function selectRows(spec, docs, self) {
1015
973
  });
1016
974
  matched.sort((a, b) => {
1017
975
  for (const { expr, descending } of spec.sort) {
1018
- const order = compareValues(
1019
- evaluate(expr, a, self),
1020
- evaluate(expr, b, self),
1021
- );
976
+ const order = compareValues(evaluate(expr, a, self), evaluate(expr, b, self));
1022
977
  if (order !== 0) return descending ? -order : order;
1023
978
  }
1024
979
  const byPath = compareValues(a.path, b.path);
@@ -1032,8 +987,7 @@ export function selectRows(spec, docs, self) {
1032
987
  /* ------------------------------------------------------------------------ */
1033
988
 
1034
989
  /** Escape the characters that would break out of a markdown table cell. */
1035
- const escapeCell = (text) =>
1036
- text.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
990
+ const escapeCell = (text) => text.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
1037
991
 
1038
992
  /**
1039
993
  * Render one evaluated value as table text.
@@ -1074,8 +1028,7 @@ export function renderContentTable(spec, rows, linkable, self) {
1074
1028
  spec.columns.map((column) => {
1075
1029
  const value = evaluate(column.expr, doc, self);
1076
1030
  if (!isLink(value)) return formatValue(value, column.header);
1077
- const display =
1078
- value.display || asText(FILE_FIELDS.name(doc)) || EMPTY_CELL;
1031
+ const display = value.display || asText(FILE_FIELDS.name(doc)) || EMPTY_CELL;
1079
1032
  const text = escapeCell(display);
1080
1033
  if (!linkable(doc)) return text;
1081
1034
  // A wikilink's own separator is a literal `|`, written `\|` inside a
@@ -1087,12 +1040,9 @@ export function renderContentTable(spec, rows, linkable, self) {
1087
1040
  );
1088
1041
 
1089
1042
  const align = spec.columns.map((_column, i) => {
1090
- const shown = cells
1091
- .map((row) => row[i])
1092
- .filter((c) => c !== EMPTY_CELL);
1043
+ const shown = cells.map((row) => row[i]).filter((c) => c !== EMPTY_CELL);
1093
1044
  const numeric =
1094
- shown.length > 0 &&
1095
- shown.every((c) => Number.isFinite(Number(c)) && c.trim() !== "");
1045
+ shown.length > 0 && shown.every((c) => Number.isFinite(Number(c)) && c.trim() !== "");
1096
1046
  return numeric ? "---:" : "---";
1097
1047
  });
1098
1048
 
@@ -1172,9 +1122,7 @@ export function expandContentTables(
1172
1122
  // Find this fence's closing line: the same marker character, at least
1173
1123
  // as long, with nothing after it.
1174
1124
  let close = i + 1;
1175
- const closer = new RegExp(
1176
- `^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`,
1177
- );
1125
+ const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
1178
1126
  while (close < lines.length && !closer.test(lines[close])) close++;
1179
1127
  const isQuery = /^dataview\b/i.test(info.trim());
1180
1128
  const block = lines.slice(i, Math.min(close + 1, lines.length));
@@ -1204,8 +1152,7 @@ export function expandContentTables(
1204
1152
  }
1205
1153
  // A markdown table must be its own block: keep one blank line on each
1206
1154
  // side of it, without inventing a leading or trailing one.
1207
- if (out.length > 0 && out[out.length - 1].trim() !== "")
1208
- emit("", i, true);
1155
+ if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
1209
1156
  for (const row of table.split("\n")) emit(`${indent}${row}`, i, true);
1210
1157
  if (close + 1 < lines.length && lines[close + 1].trim() !== "") {
1211
1158
  emit("", i, true);
@@ -145,11 +145,7 @@ export function emitDiagnostic(d) {
145
145
  * {@link expandContentTables}.
146
146
  * @returns {{line: number, column: number|undefined, generated: boolean}}
147
147
  */
148
- export function positionInBody(
149
- body,
150
- offset,
151
- { bodyLine = 1, bodyColumn = 1, lineMap } = {},
152
- ) {
148
+ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lineMap } = {}) {
153
149
  const upTo = String(body ?? "").slice(0, Math.max(0, offset));
154
150
  const nl = upTo.lastIndexOf("\n");
155
151
  const scannedLine = upTo.length === 0 ? 0 : upTo.split("\n").length - 1;
@@ -209,10 +205,7 @@ export function positionInFrontmatter(raw, key, value = undefined) {
209
205
  keyLine = i;
210
206
  break;
211
207
  }
212
- if (
213
- keyLine === -1 &&
214
- new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])
215
- ) {
208
+ if (keyLine === -1 && new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])) {
216
209
  keyLine = i;
217
210
  if (wanted == null) break;
218
211
  }
@@ -321,13 +314,9 @@ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
321
314
  if (key && node !== undefined) {
322
315
  const last = keyPath[keyPath.length - 1];
323
316
  const parent =
324
- keyPath.length === 1 ?
325
- doc.contents
326
- : doc.getIn(keyPath.slice(0, -1), true);
317
+ keyPath.length === 1 ? doc.contents : doc.getIn(keyPath.slice(0, -1), true);
327
318
  const pair = parent?.items?.find?.(
328
- (item) =>
329
- item?.key != null &&
330
- String(item.key.value) === String(last),
319
+ (item) => item?.key != null && String(item.key.value) === String(last),
331
320
  );
332
321
  if (pair?.key?.range) node = pair.key;
333
322
  }
@@ -374,3 +363,36 @@ export function yamlKeyPath(field) {
374
363
  }
375
364
  return segments;
376
365
  }
366
+
367
+ /**
368
+ * Where a key sits **inside a note's frontmatter fence**, addressed by path.
369
+ *
370
+ * {@link positionInFrontmatter} searches the fence for a key by name, which is
371
+ * the right tool while a note's vocabulary is flat: a key appears once and the
372
+ * first line matching it is the one. It stops being the right tool the moment
373
+ * the same name is legal in two regions — `data.weight` and a top-level
374
+ * `weight` are different keys, and a search finds whichever comes first.
375
+ *
376
+ * So this is the located form for the closed regions (#128). It reuses
377
+ * {@link positionOfYamlPath} rather than repeating its parse, which is what
378
+ * keeps a finding about `data.weight` pointing at `data.weight`.
379
+ *
380
+ * @param {string} raw - The note's full contents, frontmatter included.
381
+ * @param {ReadonlyArray<string|number>} keyPath - Path to the node, from the
382
+ * top of the frontmatter.
383
+ * @param {object} [opts]
384
+ * @param {boolean} [opts.key=false] - Report where the last segment is
385
+ * *declared* rather than where its value sits.
386
+ * @returns {{line?: number, column?: number}} Spreadable position fields, empty
387
+ * when there is no fence or the path resolves to nothing — dropped rather
388
+ * than guessed.
389
+ */
390
+ export function positionOfFrontmatterPath(raw, keyPath, { key = false } = {}) {
391
+ if (typeof raw !== "string") return {};
392
+ const fence = raw.match(/^---\n([\s\S]*?)\n---/);
393
+ if (!fence) return {};
394
+ const position = positionOfYamlPath(fence[1], keyPath, { key });
395
+ // +1: the fence's line 1 is the file's line 2, the opening `---` being the
396
+ // first. `positionOfYamlPath` already counts from 1 within the block.
397
+ return position.line === undefined ? {} : { ...position, line: position.line + 1 };
398
+ }