@heroiclands/package-build 23.1.0 → 23.2.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.
- package/CHANGELOG.md +71 -0
- package/bin/content-commands.mjs +68 -149
- package/bin/package-build.mjs +56 -10
- package/ci/check-banners.mjs +42 -6
- package/ci/confirm-npm-version.mjs +135 -0
- package/ci/content-format-reference.mjs +14 -5
- package/config.mjs +1 -0
- package/container.mjs +185 -4
- package/content-config.mjs +333 -210
- package/deploy.mjs +116 -10
- package/docs/api.md +238 -246
- package/docs/authoring/assets.md +11 -5
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +87 -0
- package/docs/commands.md +138 -99
- package/docs/configuration.md +277 -125
- package/docs/content-format.md +2 -0
- package/docs/diagnostics.md +48 -13
- package/docs/guides/book.md +341 -21
- package/docs/guides/e2e.md +13 -2
- package/docs/guides/icons.md +14 -4
- package/docs/guides/module-project.md +18 -8
- package/docs/guides/release-and-deploy.md +54 -6
- package/docs/guides/site.md +5 -2
- package/docs/project-setup.md +30 -10
- package/docs/reference/format-details.md +373 -170
- package/docs/reference/note-types.md +128 -109
- package/docs/reference/theme-pages.md +31 -6
- package/e2e.mjs +140 -28
- package/engine/actor-compiler.mjs +22 -31
- package/engine/address-charset.mjs +4 -17
- package/engine/address-fields.mjs +1 -1
- package/engine/address-render.mjs +12 -1
- package/engine/address-values.mjs +6 -0
- package/engine/address.mjs +46 -33
- package/engine/art-fields.mjs +30 -5
- package/engine/art-slots.mjs +10 -1
- package/engine/asset-bindings.mjs +12 -1
- package/engine/base-compiler.mjs +34 -65
- package/engine/being-age.mjs +2 -7
- package/engine/being-measurements.mjs +12 -1
- package/engine/calendar-artifacts.mjs +6 -0
- package/engine/calendar-choice.mjs +12 -1
- package/engine/calendar-format.mjs +12 -1
- package/engine/calendar-human.mjs +12 -1
- package/engine/compile-corpus.mjs +3 -1
- package/engine/content-address.mjs +9 -12
- package/engine/content-alerts.mjs +13 -1
- package/engine/content-blocks.mjs +14 -3
- package/engine/content-embeds.mjs +11 -3
- package/engine/content-figures.mjs +9 -0
- package/engine/content-footnotes.mjs +12 -1
- package/engine/content-format-check.mjs +11 -262
- package/engine/content-format.yaml +5 -2
- package/engine/content-images.mjs +3 -0
- package/engine/content-index.mjs +443 -20
- package/engine/content-links.mjs +29 -35
- package/engine/content-lint.mjs +2 -3
- package/engine/content-poetry.mjs +12 -1
- package/engine/content-slug.mjs +2 -14
- package/engine/content-spans.mjs +13 -1
- package/engine/content-tables.mjs +12 -1
- package/engine/culture-choice.mjs +210 -13
- package/engine/data-keys.mjs +19 -16
- package/engine/date-conversion.mjs +12 -1
- package/engine/declaration-check.mjs +9 -0
- package/engine/derived-fields.mjs +6 -7
- package/engine/diagnostics.mjs +34 -4
- package/engine/docs-checks.mjs +9 -0
- package/engine/document-subtypes.mjs +47 -34
- package/engine/event-views.mjs +139 -14
- package/engine/exported-scene.mjs +92 -28
- package/engine/extension-attributes.mjs +12 -1
- package/engine/field-spec.mjs +11 -102
- package/engine/folder-notes.mjs +0 -64
- package/engine/foreign-catalog.mjs +2 -1
- package/engine/format-generated.mjs +9 -0
- package/engine/foundry-entries.mjs +10 -28
- package/engine/frontmatter-lint.mjs +83 -392
- package/engine/frontmatter.mjs +20 -70
- package/engine/generate.mjs +10 -15
- package/engine/heading-attributes.mjs +12 -1
- package/engine/helpers.mjs +41 -102
- package/engine/holdings.mjs +1 -1
- package/engine/homepage.mjs +3 -10
- package/engine/ids.mjs +1 -167
- package/engine/index-records.mjs +6 -4
- package/engine/index.mjs +0 -3
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +13 -14
- package/engine/item-compiler.mjs +4 -10
- package/engine/item-docs.mjs +3 -5
- package/engine/item-registry.mjs +2 -10
- package/engine/journals.mjs +1 -5
- package/engine/literature-notes.mjs +4 -12
- package/engine/macros.mjs +1 -2
- package/engine/map-graphviz.mjs +12 -1
- package/engine/markdown-expressions.mjs +12 -1
- package/engine/markdownlint-run.mjs +567 -0
- package/engine/metadata-index.mjs +42 -12
- package/engine/note-addresses.mjs +143 -35
- package/engine/note-claims.mjs +14 -28
- package/engine/note-dates.mjs +3 -3
- package/engine/note-frontmatter.mjs +6 -0
- package/engine/note-ids.mjs +8 -14
- package/engine/note-package.mjs +8 -25
- package/engine/note-state.mjs +1 -3
- package/engine/note-vocabulary.mjs +148 -84
- package/engine/number-words.mjs +12 -1
- package/engine/pack-config.mjs +15 -25
- package/engine/pack-router.mjs +10 -16
- package/engine/pathnames.mjs +8 -22
- package/engine/pdf-build.mjs +773 -121
- package/engine/pdf-coverage.mjs +188 -0
- package/engine/pdf-index.mjs +54 -7
- package/engine/pdf-pages.mjs +247 -0
- package/engine/pdf-render.mjs +114 -4
- package/engine/pdf-toc.mjs +29 -32
- package/engine/project-init.mjs +46 -6
- package/engine/prose-config.mjs +1 -1
- package/engine/prose-lint.mjs +52 -88
- package/engine/readability-lint.mjs +9 -0
- package/engine/reckoning-markers.mjs +12 -1
- package/engine/reference-targets.mjs +7 -0
- package/engine/runtime-only-fields.mjs +5 -8
- package/engine/scene-levels.mjs +2 -2
- package/engine/scenes.mjs +1 -1
- package/engine/shortcode-references.mjs +7 -0
- package/engine/site-build.mjs +30 -16
- package/engine/site-index.mjs +10 -16
- package/engine/site-preview.mjs +6 -0
- package/engine/site-root.mjs +8 -1
- package/engine/social-tie-terms.mjs +12 -1
- package/engine/social-ties.mjs +3 -0
- package/engine/sql-tables.mjs +56 -20
- package/engine/standing-terms.mjs +11 -1
- package/engine/standings.mjs +11 -27
- package/engine/system-block.mjs +39 -141
- package/engine/system-vocabulary.mjs +6 -0
- package/engine/walk-files.mjs +69 -0
- package/engine/web-wikilinks.mjs +23 -3
- package/engine/wikilink-syntax.mjs +32 -21
- package/engine/wikilinks.mjs +27 -16
- package/engine/yaml-lint.mjs +7 -4
- package/githooks/attribution.mjs +8 -0
- package/githooks/commit-message.mjs +9 -0
- package/hm3/actors.mjs +9 -48
- package/hm3/default-item-art.mjs +9 -9
- package/hm3/item-fields.mjs +2 -9
- package/hm3/template-priority.mjs +6 -7
- package/hugo-theme/data/banner-kinds.yaml +78 -0
- package/hugo-theme/data/banners.yaml +2 -3
- package/hugo-theme/layouts/404.html +3 -3
- package/hugo-theme/layouts/partials/banner-url.html +31 -7
- package/hugo-theme/layouts/partials/hero-banner.html +13 -14
- package/hugo-theme/layouts/partials/related.html +1 -1
- package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
- package/hugo-theme/static/css/style.css +1 -1
- package/manifest.mjs +11 -4
- package/markdownlint-config.mjs +1 -1
- package/package.json +6 -3
- package/release.mjs +83 -18
- package/sohl/actors.mjs +16 -27
- package/sohl/default-item-art.mjs +3 -3
- package/sohl/document-subtypes.mjs +1 -16
- package/sohl/infobox.mjs +2 -3
- package/sohl/item-builders.mjs +5 -8
- package/sohl/item-fields.mjs +5 -35
- package/sohl/items.mjs +0 -4
- package/sohl/kb-passes.mjs +3 -3
- package/types/container.d.mts +116 -5
- package/types/content-config.d.mts +83 -35
- package/types/deploy.d.mts +42 -6
- package/types/e2e.d.mts +35 -3
- package/types/engine/address-charset.d.mts +4 -17
- package/types/engine/address.d.mts +17 -8
- package/types/engine/art-fields.d.mts +7 -4
- package/types/engine/art-slots.d.mts +6 -0
- package/types/engine/base-compiler.d.mts +15 -17
- package/types/engine/content-address.d.mts +9 -12
- package/types/engine/content-embeds.d.mts +4 -2
- package/types/engine/content-format-check.d.mts +0 -64
- package/types/engine/content-index.d.mts +82 -3
- package/types/engine/content-links.d.mts +2 -4
- package/types/engine/culture-choice.d.mts +51 -8
- package/types/engine/data-keys.d.mts +2 -2
- package/types/engine/derived-fields.d.mts +1 -2
- package/types/engine/diagnostics.d.mts +27 -4
- package/types/engine/document-subtypes.d.mts +12 -31
- package/types/engine/event-views.d.mts +14 -2
- package/types/engine/exported-scene.d.mts +3 -2
- package/types/engine/field-spec.d.mts +13 -122
- package/types/engine/folder-notes.d.mts +0 -39
- package/types/engine/foreign-catalog.d.mts +2 -1
- package/types/engine/foundry-entries.d.mts +4 -5
- package/types/engine/frontmatter-lint.d.mts +2 -2
- package/types/engine/frontmatter.d.mts +19 -45
- package/types/engine/generate.d.mts +1 -1
- package/types/engine/helpers.d.mts +25 -42
- package/types/engine/homepage.d.mts +3 -10
- package/types/engine/ids.d.mts +0 -114
- package/types/engine/index-records.d.mts +6 -4
- package/types/engine/index.d.mts +0 -1
- package/types/engine/infobox-render.d.mts +7 -1
- package/types/engine/infobox.d.mts +3 -2
- package/types/engine/item-docs.d.mts +1 -1
- package/types/engine/item-registry.d.mts +2 -4
- package/types/engine/literature-notes.d.mts +1 -1
- package/types/engine/markdownlint-run.d.mts +40 -0
- package/types/engine/metadata-index.d.mts +9 -1
- package/types/engine/note-addresses.d.mts +27 -2
- package/types/engine/note-claims.d.mts +3 -5
- package/types/engine/note-ids.d.mts +2 -4
- package/types/engine/note-package.d.mts +1 -1
- package/types/engine/note-state.d.mts +1 -3
- package/types/engine/note-vocabulary.d.mts +28 -21
- package/types/engine/pack-config.d.mts +2 -6
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/pathnames.d.mts +3 -3
- package/types/engine/pdf-build.d.mts +98 -12
- package/types/engine/pdf-coverage.d.mts +60 -0
- package/types/engine/pdf-index.d.mts +34 -5
- package/types/engine/pdf-pages.d.mts +143 -0
- package/types/engine/pdf-render.d.mts +45 -1
- package/types/engine/pdf-toc.d.mts +12 -14
- package/types/engine/prose-config.d.mts +1 -1
- package/types/engine/prose-lint.d.mts +11 -10
- package/types/engine/runtime-only-fields.d.mts +2 -2
- package/types/engine/site-build.d.mts +9 -5
- package/types/engine/site-index.d.mts +4 -5
- package/types/engine/site-root.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -2
- package/types/engine/standing-terms.d.mts +2 -1
- package/types/engine/standings.d.mts +7 -9
- package/types/engine/system-block.d.mts +10 -44
- package/types/engine/walk-files.d.mts +15 -0
- package/types/engine/wikilink-syntax.d.mts +18 -19
- package/types/engine/yaml-lint.d.mts +5 -4
- package/types/hm3/actors.d.mts +1 -2
- package/types/hm3/item-fields.d.mts +2 -4
- package/types/hm3/template-priority.d.mts +6 -7
- package/types/manifest.d.mts +3 -2
- package/types/release.d.mts +12 -7
- package/types/sohl/actors.d.mts +2 -1
- package/types/sohl/document-subtypes.d.mts +1 -2
- package/types/sohl/item-fields.d.mts +3 -7
- package/engine/retired-fields.mjs +0 -631
- package/types/engine/retired-fields.d.mts +0 -354
package/engine/content-links.mjs
CHANGED
|
@@ -27,9 +27,8 @@
|
|
|
27
27
|
* written target expands to exactly one canonical address, from the
|
|
28
28
|
* position's own defaults — the package it omits is always this one — so a
|
|
29
29
|
* lookup finds one entry or none. There is no candidate set to disambiguate.
|
|
30
|
-
* 3. **An unlabelled link.** `[[x]]` addresses nothing:
|
|
31
|
-
*
|
|
32
|
-
* prose, so the link has neither a resolvable target nor text to show. The
|
|
30
|
+
* 3. **An unlabelled link.** `[[x]]` addresses nothing: a shortcode is an address
|
|
31
|
+
* rather than prose, so the link has neither a resolvable target nor text to show. The
|
|
33
32
|
* correction is always `[[type-shortcode|Text]]`.
|
|
34
33
|
* 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
|
|
35
34
|
* and copy frontmatter through verbatim, so a link written in a
|
|
@@ -47,7 +46,7 @@
|
|
|
47
46
|
* **What this deliberately does not do.** Corpus reachability — "every rules
|
|
48
47
|
* document is reachable from the book's root" — is a statement about what one
|
|
49
48
|
* package publishes, not about the note format, so it belongs with the
|
|
50
|
-
* publishing it describes; so does a
|
|
49
|
+
* publishing it describes; so does a withdrawn hostname. Both are served by the
|
|
51
50
|
* link graph returned here rather than implemented here.
|
|
52
51
|
*
|
|
53
52
|
* @module
|
|
@@ -90,7 +89,6 @@ import {
|
|
|
90
89
|
import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
|
|
91
90
|
import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
|
|
92
91
|
import { homepageAddresses, isHomepage } from "./homepage.mjs";
|
|
93
|
-
import { RETIRED_TYPES } from "./ids.mjs";
|
|
94
92
|
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
95
93
|
import { readQualifier } from "./wikilinks.mjs";
|
|
96
94
|
|
|
@@ -120,8 +118,8 @@ export function anchorsOf(body) {
|
|
|
120
118
|
* Read a content tree into the index a link resolves against.
|
|
121
119
|
*
|
|
122
120
|
* **The corpus comes from the content index, not from a walk of this module's
|
|
123
|
-
* own**.
|
|
124
|
-
*
|
|
121
|
+
* own**. Passes do not each answer "which files are the content?" for
|
|
122
|
+
* themselves; this one reads
|
|
125
123
|
* {@link module:engine/content-index.indexRecordsFor}, which is the same
|
|
126
124
|
* derivation the published artifact and the compilers are driven from. So a
|
|
127
125
|
* note the index records is a note the link check sees, and the addresses and
|
|
@@ -167,8 +165,9 @@ export function buildLinkIndex(
|
|
|
167
165
|
const frontmatterLinks = [];
|
|
168
166
|
|
|
169
167
|
// The one package every note in this tree belongs to. Taken from the
|
|
170
|
-
// configuration this build resolved — never from a note (
|
|
171
|
-
//
|
|
168
|
+
// configuration this build resolved — never from a note (a note
|
|
169
|
+
// declares no package, so there is no second source an address could
|
|
170
|
+
// disagree with)
|
|
172
171
|
// and never from the ambient one, which is a different configuration
|
|
173
172
|
// whenever a test injects one, `PACKAGE_BUILD_CONFIG` names one, or the
|
|
174
173
|
// command runs from a worktree.
|
|
@@ -379,6 +378,8 @@ export function buildLinkIndex(
|
|
|
379
378
|
target: u.target,
|
|
380
379
|
reason: u.reason,
|
|
381
380
|
...(u.type ? { type: u.type } : {}),
|
|
381
|
+
...(u.shortcode ? { shortcode: u.shortcode } : {}),
|
|
382
|
+
...(u.declared ? { declared: u.declared } : {}),
|
|
382
383
|
})),
|
|
383
384
|
...problems.map((problem) => ({
|
|
384
385
|
text: problem.link,
|
|
@@ -786,8 +787,6 @@ function readAddress(url, packages) {
|
|
|
786
787
|
* **What is checkable, stated plainly.** Only an address into this site is, and
|
|
787
788
|
* only against facts this build already holds:
|
|
788
789
|
*
|
|
789
|
-
* - A **retired content type** in the path. The engine knows the retired names
|
|
790
|
-
* and what replaced it, so this is a fact rather than a guess.
|
|
791
790
|
* - A **hardcoded absolute URL** into this package's own prefix, or into one a
|
|
792
791
|
* fetched index names. Every one of them has a better form to write, which
|
|
793
792
|
* is why every one is reported — including a bare `/<package>/`, which names
|
|
@@ -890,30 +889,6 @@ export function auditHomepageLinks(index) {
|
|
|
890
889
|
`relocation does not leave this page behind`,
|
|
891
890
|
);
|
|
892
891
|
}
|
|
893
|
-
|
|
894
|
-
// The retired-type rule reads the path *inside* the package, so an
|
|
895
|
-
// address that named one is fixed the same way wherever it was
|
|
896
|
-
// written.
|
|
897
|
-
const inPackage = prefix ? segments.slice(1) : segments;
|
|
898
|
-
for (const [i, segment] of inPackage.entries()) {
|
|
899
|
-
// `hasOwn`, not a plain lookup: a path segment spelled
|
|
900
|
-
// `constructor` would otherwise inherit a truthy answer from
|
|
901
|
-
// `Object.prototype` and be reported as retired.
|
|
902
|
-
if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
|
|
903
|
-
const replacement = RETIRED_TYPES[segment];
|
|
904
|
-
const fixed = [...inPackage];
|
|
905
|
-
fixed[i] = replacement;
|
|
906
|
-
report(
|
|
907
|
-
field,
|
|
908
|
-
url,
|
|
909
|
-
url,
|
|
910
|
-
occurrence,
|
|
911
|
-
`address "${url}" names content type "${segment}", ` +
|
|
912
|
-
`retired in favour of "${replacement}" — both ` +
|
|
913
|
-
`compiled to the same document, so the fix is ` +
|
|
914
|
-
`mechanical: "${fixed.join("/")}/"`,
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
892
|
}
|
|
918
893
|
}
|
|
919
894
|
|
|
@@ -1015,6 +990,25 @@ export function auditLinks(index) {
|
|
|
1015
990
|
deadAddresses.push({ ...at, reason: "not-an-address" });
|
|
1016
991
|
continue;
|
|
1017
992
|
}
|
|
993
|
+
// A capital in the shortcode is refused, never lowercased. The
|
|
994
|
+
// lowercase spelling is looked up only to name it in the finding.
|
|
995
|
+
const cased = readQualifier(target, index.types, index.packages, index.noIndexPackages);
|
|
996
|
+
if (cased?.reason === "shortcode-not-lowercase") {
|
|
997
|
+
const lower = `${target.slice(0, -cased.shortcode.length)}${cased.lowercase.shortcode}`;
|
|
998
|
+
const declared =
|
|
999
|
+
index.resolveAddress(lower) ?? index.stubAt?.(lower) ?? manifestHit(lower);
|
|
1000
|
+
deadAddresses.push({
|
|
1001
|
+
...at,
|
|
1002
|
+
reason: "shortcode-not-lowercase",
|
|
1003
|
+
shortcode: cased.shortcode,
|
|
1004
|
+
...(declared ? { declared: cased.lowercase.shortcode } : {}),
|
|
1005
|
+
});
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (cased?.reason === "not-lowercase") {
|
|
1009
|
+
deadAddresses.push({ ...at, reason: "not-lowercase" });
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1018
1012
|
const resolved = index.resolveAddress(target);
|
|
1019
1013
|
if (resolved) {
|
|
1020
1014
|
if ((isGmNote(resolved.fm) || resolved.gm) && !isGmNote(note.fm)) {
|
package/engine/content-lint.mjs
CHANGED
|
@@ -44,13 +44,12 @@
|
|
|
44
44
|
* **There is deliberately no third rule** requiring every note to repeat its
|
|
45
45
|
* own `type-shortcode` address in a top-level `aliases:` list. It would serve
|
|
46
46
|
* no build reader: both resolvers parse the hyphen
|
|
47
|
-
* qualifier themselves.
|
|
48
|
-
* `retired-fields.mjs`.
|
|
47
|
+
* qualifier themselves.
|
|
49
48
|
*
|
|
50
49
|
* **What is deliberately absent.** Corpus reachability — "every Rules document
|
|
51
50
|
* is reachable from the book's root" — is a statement about what one package
|
|
52
51
|
* publishes, not about the note format, and belongs with the publishing it
|
|
53
|
-
* describes. So do
|
|
52
|
+
* describes. So do withdrawn hostnames.
|
|
54
53
|
*
|
|
55
54
|
* @module
|
|
56
55
|
*/
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
2
13
|
|
|
3
14
|
/** The authored verse lines, excluding stanza separators. */
|
|
4
15
|
export function verseLines(body) {
|
package/engine/content-slug.mjs
CHANGED
|
@@ -19,25 +19,13 @@
|
|
|
19
19
|
* address, `type-shortcode`, derived in `engine/content-address.mjs` and
|
|
20
20
|
* touching no display string at all.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* derived a page's URL from `name.full`, abbreviating it through a table of 200
|
|
24
|
-
* words so the result stayed short, and a `findSlugCollisions` to catch the two
|
|
25
|
-
* notes that would then claim one URL. Its own header justified the readability
|
|
26
|
-
* cost with a rename-survival story — *"every change appends to the legacy-URL
|
|
27
|
-
* map, which emits a redirect"* — and no such map was ever written, in this
|
|
28
|
-
* package or in any consumer. So a rename silently 404'd every existing link,
|
|
29
|
-
* a display string was load-bearing, and a uniqueness check was needed to keep
|
|
30
|
-
* it correct. An address has none of those properties, so all three went.
|
|
31
|
-
*
|
|
32
|
-
* What is left is the normalisation the rest of the build still needs, in the
|
|
33
|
-
* two places it was always right for:
|
|
22
|
+
* The normalisation applies in two places:
|
|
34
23
|
*
|
|
35
24
|
* - **heading anchors**, where an author writes the matching key by hand — a map
|
|
36
25
|
* note pins `locations.stair-foot` at a heading called *Stair Foot*;
|
|
37
26
|
* - **pack filenames**, read back only by the unpacker.
|
|
38
27
|
*
|
|
39
|
-
* Neither is abbreviated
|
|
40
|
-
* shorten a name-derived URL, so it left with it.
|
|
28
|
+
* Neither is abbreviated.
|
|
41
29
|
*
|
|
42
30
|
* What the rule does, and why:
|
|
43
31
|
*
|
package/engine/content-spans.mjs
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
2
14
|
import MarkdownIt from "markdown-it";
|
|
3
15
|
import { alertMarkdownPlugin } from "./content-alerts.mjs";
|
|
4
16
|
import { slugify } from "./content-slug.mjs";
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
2
13
|
|
|
3
14
|
import { FENCE_LINE } from "./code-fences.mjs";
|
|
4
15
|
import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
|
|
@@ -1,32 +1,229 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `data.culture`: the people a note belongs to, named as a `lore` note of
|
|
16
|
+
* `subType: culture`.
|
|
17
|
+
*
|
|
18
|
+
* One declaration serves every type that takes the field. Where a type's
|
|
19
|
+
* subtypes differ on it — a setting guide requires it, other `doc` genres
|
|
20
|
+
* refuse it, and a culture note names only itself — {@link checkCultureSubType}
|
|
21
|
+
* scopes it, because
|
|
22
|
+
* `DataFieldSpec` declares the keys a type accepts and not the subType that may
|
|
23
|
+
* write them.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
canonicalKey,
|
|
30
|
+
isAddressTuple,
|
|
31
|
+
ownDocumentSystem,
|
|
32
|
+
parseAddress,
|
|
33
|
+
renderAddress,
|
|
34
|
+
} from "./address.mjs";
|
|
35
|
+
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
36
|
+
|
|
37
|
+
/** The `lore` subType a culture names, and the one whose culture is itself. */
|
|
38
|
+
const CULTURE_SUBTYPE = "culture";
|
|
39
|
+
|
|
40
|
+
/** The `doc` subType that requires a culture; every other `doc` subType refuses it. */
|
|
41
|
+
const SETTING_GUIDE_SUBTYPE = "settingguide";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Address a `data.culture` value names, read against the defaults of the
|
|
45
|
+
* position it is written in.
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} value - The value as written, or an already decoded tuple.
|
|
48
|
+
* @param {object} [index] - The link index, for the package and vocabularies.
|
|
49
|
+
* @returns {object} The tuple, or the parser's problem.
|
|
50
|
+
*/
|
|
51
|
+
function cultureAddress(value, index) {
|
|
52
|
+
return parseAddress(value, {
|
|
53
|
+
package: index?.contentPackage,
|
|
54
|
+
system: "note",
|
|
55
|
+
type: "lore",
|
|
56
|
+
types: index?.types,
|
|
57
|
+
packages: index?.packages,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
2
60
|
|
|
3
|
-
|
|
4
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Whether a `data.culture` value names the note that carries it.
|
|
63
|
+
*
|
|
64
|
+
* The comparison is between canonical Addresses, so a culture note's shortcode,
|
|
65
|
+
* any longer form of its Address and a decoded tuple all name it alike. A note
|
|
66
|
+
* with no shortcode has no Address to compare against and names nothing.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} fm - The note's frontmatter.
|
|
69
|
+
* @param {unknown} value - The `data.culture` value, as written or decoded.
|
|
70
|
+
* @param {object} [index] - The package and vocabularies a written value is
|
|
71
|
+
* read against, and the package the note's own Address is in.
|
|
72
|
+
* @returns {boolean} Whether the value is the note's own Address.
|
|
73
|
+
*/
|
|
74
|
+
export function cultureNamesItself(fm, value, index) {
|
|
75
|
+
const shortcode = fm?.shortcode;
|
|
76
|
+
const type = String(fm?.type ?? "").toLowerCase();
|
|
77
|
+
if (typeof shortcode !== "string" || !shortcode.trim() || !type) return false;
|
|
78
|
+
if (value === undefined || value === null) return false;
|
|
79
|
+
const tuple = cultureAddress(value, index);
|
|
80
|
+
if (tuple.reason) return false;
|
|
81
|
+
return (
|
|
82
|
+
renderAddress(tuple) ===
|
|
83
|
+
canonicalKey(index?.contentPackage, ownDocumentSystem(type), type, shortcode.trim())
|
|
84
|
+
);
|
|
85
|
+
}
|
|
5
86
|
|
|
6
87
|
/**
|
|
7
|
-
*
|
|
88
|
+
* Whether a note is a `lore` note of `subType: culture`.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
91
|
+
* @returns {boolean} Whether it is a culture note.
|
|
92
|
+
*/
|
|
93
|
+
function isCultureNote(note) {
|
|
94
|
+
return (
|
|
95
|
+
String(note?.type ?? note?.fm?.type ?? "").toLowerCase() === "lore" &&
|
|
96
|
+
String(note?.fm?.subType ?? "").toLowerCase() === CULTURE_SUBTYPE
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Require a note's `data.culture` to address a culture lore note.
|
|
102
|
+
*
|
|
103
|
+
* A value that is not a `lore` Address, or names nothing, is left to the
|
|
104
|
+
* reference check, which reports it in its own terms. A culture note's own
|
|
105
|
+
* value is {@link checkCultureSubType}'s to judge, since it names only the note
|
|
106
|
+
* itself.
|
|
107
|
+
*
|
|
8
108
|
* @param {object} note
|
|
9
109
|
* @param {{index?: object}} [options]
|
|
110
|
+
* @returns {object[]} One error at the value when it names lore of another subType.
|
|
10
111
|
*/
|
|
11
112
|
export function checkCultureChoice(note, { index } = {}) {
|
|
12
113
|
const value = note.fm?.data?.culture;
|
|
13
114
|
if (value === undefined || value === null || !index?.addressHit) return [];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
system: "note",
|
|
17
|
-
type: "lore",
|
|
18
|
-
types: index.types,
|
|
19
|
-
packages: index.packages,
|
|
20
|
-
});
|
|
115
|
+
if (isCultureNote(note)) return [];
|
|
116
|
+
const tuple = cultureAddress(value, index);
|
|
21
117
|
if (tuple.reason || tuple.type !== "lore") return [];
|
|
22
118
|
const target = index.addressHit(renderAddress(tuple));
|
|
23
|
-
if (!target || (target.fm ?? target).subType ===
|
|
119
|
+
if (!target || (target.fm ?? target).subType === CULTURE_SUBTYPE) return [];
|
|
120
|
+
const type = String(note.type ?? note.fm?.type ?? "note");
|
|
121
|
+
const article = /^[aeiou]/i.test(type) ? "an" : "a";
|
|
24
122
|
return [
|
|
25
123
|
{
|
|
26
124
|
file: note.file,
|
|
27
125
|
...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"]),
|
|
28
126
|
severity: "error",
|
|
29
|
-
message:
|
|
127
|
+
message:
|
|
128
|
+
`data.culture ${isAddressTuple(value) ? renderAddress(value) : value} names lore ` +
|
|
129
|
+
`whose subType is not culture; ${article} ${type}'s culture is a culture lore note`,
|
|
30
130
|
},
|
|
31
131
|
];
|
|
32
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `data.culture` field, declared once and spread into each type that takes
|
|
136
|
+
* it. A type gives its own `describe` where the field means something narrower
|
|
137
|
+
* there.
|
|
138
|
+
*
|
|
139
|
+
* @type {Readonly<object>}
|
|
140
|
+
*/
|
|
141
|
+
export const CULTURE_FIELD = Object.freeze({
|
|
142
|
+
name: "culture",
|
|
143
|
+
shape: "an Address",
|
|
144
|
+
kind: "address",
|
|
145
|
+
ref: "lore",
|
|
146
|
+
accepts: Object.freeze(["lore"]),
|
|
147
|
+
check: checkCultureChoice,
|
|
148
|
+
describe: "The people this note belongs to, as a culture lore note.",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Scope `data.culture` to the subtypes that take it.
|
|
153
|
+
*
|
|
154
|
+
* A `doc` of `subType: settingguide` introduces one culture's setting and must
|
|
155
|
+
* name it; a missing value is an error at the `subType:` line. Every other `doc`
|
|
156
|
+
* subType refuses a written value, as an error at the key.
|
|
157
|
+
*
|
|
158
|
+
* A `lore` note of `subType: culture` is itself the culture. It may omit the
|
|
159
|
+
* field or name itself, in any form the Address parser reads; a value naming any
|
|
160
|
+
* other Address is an error at the value. A note with no shortcode has no
|
|
161
|
+
* Address to compare against, and the shortcode check reports it.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
164
|
+
* @param {{index?: object}} [options] - The link index, for the package and
|
|
165
|
+
* vocabularies a written value is read against.
|
|
166
|
+
* @returns {object[]} Findings.
|
|
167
|
+
*/
|
|
168
|
+
export function checkCultureSubType(note, { index } = {}) {
|
|
169
|
+
const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
|
|
170
|
+
const subType = String(note?.fm?.subType ?? "").toLowerCase();
|
|
171
|
+
const data = note?.fm?.data;
|
|
172
|
+
const written =
|
|
173
|
+
data !== null &&
|
|
174
|
+
typeof data === "object" &&
|
|
175
|
+
!Array.isArray(data) &&
|
|
176
|
+
data.culture !== undefined &&
|
|
177
|
+
data.culture !== null;
|
|
178
|
+
const atKey = (message) => ({
|
|
179
|
+
file: note.file,
|
|
180
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"], { key: true }),
|
|
181
|
+
severity: "error",
|
|
182
|
+
message,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (type === "doc") {
|
|
186
|
+
if (subType === SETTING_GUIDE_SUBTYPE) {
|
|
187
|
+
if (written) return [];
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
file: note.file,
|
|
191
|
+
...positionInFrontmatter(note.raw ?? "", "subType", undefined, {
|
|
192
|
+
topLevel: true,
|
|
193
|
+
}),
|
|
194
|
+
severity: "error",
|
|
195
|
+
message:
|
|
196
|
+
"a setting guide introduces one culture, so this note must state " +
|
|
197
|
+
"`data.culture`, naming a culture lore note",
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
if (!written) return [];
|
|
202
|
+
return [
|
|
203
|
+
atKey(
|
|
204
|
+
`\`data.culture\` is stated by a setting guide, and this note's \`subType\` ` +
|
|
205
|
+
`is not \`${SETTING_GUIDE_SUBTYPE}\``,
|
|
206
|
+
),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (type === "lore" && subType === CULTURE_SUBTYPE && written) {
|
|
211
|
+
const shortcode = note.fm?.shortcode;
|
|
212
|
+
if (typeof shortcode !== "string" || !shortcode.trim()) return [];
|
|
213
|
+
const value = data.culture;
|
|
214
|
+
if (cultureAddress(value, index).reason) return [];
|
|
215
|
+
if (cultureNamesItself({ ...note.fm, type }, value, index)) return [];
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
file: note.file,
|
|
219
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"]),
|
|
220
|
+
severity: "error",
|
|
221
|
+
message:
|
|
222
|
+
`data.culture ${isAddressTuple(value) ? renderAddress(value) : value} ` +
|
|
223
|
+
`names another note; a culture note's data.culture names the note itself`,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return [];
|
|
229
|
+
}
|
package/engine/data-keys.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import { nearest } from "./near-miss.mjs";
|
|
|
57
57
|
* @property {string} name - The key as an author writes it.
|
|
58
58
|
* @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
|
|
59
59
|
* `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
|
|
60
|
-
*
|
|
60
|
+
* or `string-or-list` — see {@link fitsKind}. Absent, the check
|
|
61
61
|
* makes no claim about the value.
|
|
62
62
|
* @property {readonly string[]} [allowed] - The closed set a present value is
|
|
63
63
|
* one of, where the key takes a fixed word.
|
|
@@ -143,8 +143,6 @@ export function fitsKind(value, kind) {
|
|
|
143
143
|
);
|
|
144
144
|
case "scalar-or-map":
|
|
145
145
|
return isScalar(value) || fitsKind(value, "map");
|
|
146
|
-
case "list-or-map":
|
|
147
|
-
return Array.isArray(value) || fitsKind(value, "map");
|
|
148
146
|
case "string-or-list":
|
|
149
147
|
return isScalar(value) || Array.isArray(value);
|
|
150
148
|
default:
|
|
@@ -287,21 +285,25 @@ export function checkDataKeys(note, fields) {
|
|
|
287
285
|
);
|
|
288
286
|
return;
|
|
289
287
|
}
|
|
290
|
-
if (spec
|
|
291
|
-
const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
|
|
292
|
-
findings.push(
|
|
293
|
-
finding(
|
|
294
|
-
path,
|
|
295
|
-
`\`${pathLabel(path)}\` should be ${spec.allowed.map((word) => `\`${word}\``).join(" or ")}, ` +
|
|
296
|
-
`but reads ${JSON.stringify(value)}` +
|
|
297
|
-
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
298
|
-
),
|
|
299
|
-
);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
288
|
+
if (!inAllowed(spec, value, path)) return;
|
|
302
289
|
walk(spec, value, path);
|
|
303
290
|
};
|
|
304
291
|
|
|
292
|
+
/** Whether a present value is in the spec's closed set, if it has one; else a finding. */
|
|
293
|
+
const inAllowed = (spec, value, path) => {
|
|
294
|
+
if (!spec.allowed || spec.allowed.includes(value)) return true;
|
|
295
|
+
const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
|
|
296
|
+
findings.push(
|
|
297
|
+
finding(
|
|
298
|
+
path,
|
|
299
|
+
`\`${pathLabel(path)}\` should be ${spec.allowed.map((word) => `\`${word}\``).join(" or ")}, ` +
|
|
300
|
+
`but reads ${JSON.stringify(value)}` +
|
|
301
|
+
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
return false;
|
|
305
|
+
};
|
|
306
|
+
|
|
305
307
|
/** Close one map to its declared keys, and walk each that is present. */
|
|
306
308
|
const closeMap = (inner, value, path) => {
|
|
307
309
|
const names = inner.map((spec) => spec.name);
|
|
@@ -341,7 +343,8 @@ export function checkDataKeys(note, fields) {
|
|
|
341
343
|
// A dotted group's own shape is checked here: the container check
|
|
342
344
|
// reads its members' leaves and has nothing to say about the group.
|
|
343
345
|
if (spec.grouped) visit(spec, value, path);
|
|
344
|
-
else if (fitsKind(value, spec.kind)
|
|
346
|
+
else if (fitsKind(value, spec.kind) && inAllowed(spec, value, path))
|
|
347
|
+
walk(spec, value, path);
|
|
345
348
|
}
|
|
346
349
|
return findings;
|
|
347
350
|
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
2
13
|
|
|
3
14
|
import { eraYear, formatCanonicalDate, parseCanonicalDate } from "./calendars.mjs";
|
|
4
15
|
import { addressedCalendarDate } from "./calendar-human.mjs";
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
2
11
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
12
|
*/
|
|
4
13
|
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
* `system` keys a note may **never** author, because the *compiler* writes them
|
|
16
16
|
* from the note itself.
|
|
17
17
|
*
|
|
18
|
-
* The
|
|
18
|
+
* The companion of `runtime-only-fields.mjs`, and the two state different
|
|
19
|
+
* facts:
|
|
19
20
|
*
|
|
20
21
|
* | module | refuses a field | because |
|
|
21
22
|
* | --- | --- | --- |
|
|
22
|
-
* | `retired-fields.mjs` | a note may no longer declare | it has been withdrawn |
|
|
23
23
|
* | `runtime-only-fields.mjs` | a note never could | the *document* writes it in play |
|
|
24
24
|
* | this one | a note never could | the *compiler* writes it, from the note |
|
|
25
25
|
*
|
|
26
|
-
* The distinction
|
|
27
|
-
*
|
|
26
|
+
* The distinction is where the value comes from, and it decides what an author
|
|
27
|
+
* is told. A runtime-only field has no compile-time
|
|
28
28
|
* value at all, so the fix is to delete the key. A derived one **does** have a
|
|
29
29
|
* compile-time value — it is already being written, from somewhere else in the
|
|
30
30
|
* same note — so the fix is to move the content to wherever the compiler reads
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
import { getFrontmatter } from "./frontmatter.mjs";
|
|
57
|
-
import { locateFrontmatterKey } from "./
|
|
57
|
+
import { locateFrontmatterKey } from "./diagnostics.mjs";
|
|
58
58
|
import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -88,8 +88,7 @@ export function derivedMessage(key, from, file) {
|
|
|
88
88
|
/**
|
|
89
89
|
* The derived keys a note actually writes, in declaration order.
|
|
90
90
|
*
|
|
91
|
-
* **Presence is the whole test**, as it is for a
|
|
92
|
-
* field: an authored empty string is as much a claim on the key as prose is,
|
|
91
|
+
* **Presence is the whole test**, as it is for a runtime-only field: an authored empty string is as much a claim on the key as prose is,
|
|
93
92
|
* and it is the same belief the message exists to correct. So the question is
|
|
94
93
|
* whether the path resolves to anything at all, never whether the value is a
|
|
95
94
|
* usable one.
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* which would send a reader to the frontmatter every time.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import fs from "node:fs";
|
|
45
46
|
import path from "node:path";
|
|
46
47
|
import YAML, { LineCounter } from "yaml";
|
|
47
48
|
|
|
@@ -183,10 +184,8 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
|
|
|
183
184
|
* The key match tolerates leading whitespace by default, so a nested key of the
|
|
184
185
|
* same name answers when no top-level one is present — which is usually what a
|
|
185
186
|
* reader wants, the key being unique in nearly every note. Pass `topLevel` where
|
|
186
|
-
* it is not:
|
|
187
|
-
*
|
|
188
|
-
* second, which would tell an author to delete a field they are allowed to
|
|
189
|
-
* write.
|
|
187
|
+
* it is not: a finding about a top-level key must never open on a nested key
|
|
188
|
+
* of the same name.
|
|
190
189
|
*
|
|
191
190
|
* @param {string} raw - The file's full contents, frontmatter included.
|
|
192
191
|
* @param {string} key - The top-level frontmatter key.
|
|
@@ -235,6 +234,37 @@ export function positionInFrontmatter(raw, key, value = undefined, { topLevel =
|
|
|
235
234
|
};
|
|
236
235
|
}
|
|
237
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A frontmatter key's position in a note's file, or nothing.
|
|
239
|
+
*
|
|
240
|
+
* {@link positionInFrontmatter} answers the same question from the file's
|
|
241
|
+
* *text*; this reads the file to ask it.
|
|
242
|
+
*
|
|
243
|
+
* @param {string|undefined} absPath - The note's file.
|
|
244
|
+
* @param {string} key - The frontmatter key.
|
|
245
|
+
* @param {string} [value] - When given, prefer the occurrence whose line also
|
|
246
|
+
* carries this text — so a finding about one entry of a block opens on that
|
|
247
|
+
* entry rather than on the key that introduces it.
|
|
248
|
+
* @param {object} [options] - Options, forwarded to
|
|
249
|
+
* {@link positionInFrontmatter}.
|
|
250
|
+
* @param {boolean} [options.topLevel=false] - Require the key at column 1, so
|
|
251
|
+
* an identically named nested key cannot answer for it.
|
|
252
|
+
* @returns {{line?: number, column?: number}|undefined} Spreadable position
|
|
253
|
+
* fields, dropped rather than guessed when the file cannot be read or the key
|
|
254
|
+
* cannot be found — as `formatDiagnostic` requires.
|
|
255
|
+
*/
|
|
256
|
+
export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
|
|
257
|
+
if (!absPath) return undefined;
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = fs.readFileSync(absPath, "utf8");
|
|
261
|
+
} catch {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
const at = positionInFrontmatter(raw, key, value, { topLevel });
|
|
265
|
+
return at.line === undefined ? undefined : at;
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
/** Escape a literal for use inside a `RegExp`. */
|
|
239
269
|
function escape(literal) {
|
|
240
270
|
return String(literal).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|