@heroiclands/package-build 13.0.0 → 15.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.
- package/CHANGELOG.md +361 -0
- package/CONTENT.md +193 -43
- package/MIGRATING.md +196 -5
- package/content-config.mjs +99 -61
- package/docs/content-format.md +28 -8
- package/engine/content-address.mjs +11 -19
- package/engine/field-reference.mjs +29 -0
- package/engine/field-spec.mjs +25 -0
- package/engine/frontmatter-lint.mjs +99 -32
- package/engine/helpers.mjs +38 -12
- package/engine/homepage.mjs +9 -4
- package/engine/item-registry.mjs +4 -1
- package/engine/macros.mjs +3 -1
- package/engine/manifest-emit.mjs +4 -7
- package/engine/note-vocabulary.mjs +34 -76
- package/engine/site-build.mjs +20 -14
- package/engine/system-block.mjs +29 -3
- package/package.json +1 -1
- package/sohl/actors.mjs +6 -3
- package/sohl/item-fields.mjs +6 -0
- package/sohl/items.mjs +4 -1
- package/types/content-config.d.mts +25 -35
- package/types/engine/content-address.d.mts +7 -14
- package/types/engine/field-spec.d.mts +53 -0
- package/types/engine/helpers.d.mts +34 -12
- package/types/engine/homepage.d.mts +8 -4
- package/types/engine/manifest-emit.d.mts +3 -9
- package/types/engine/note-vocabulary.d.mts +17 -47
- package/types/engine/site-build.d.mts +11 -6
package/content-config.mjs
CHANGED
|
@@ -68,7 +68,7 @@ import path from "node:path";
|
|
|
68
68
|
|
|
69
69
|
// Leaves with no local imports of their own, so naming them here cannot close
|
|
70
70
|
// a cycle around a consumer's config file (see `engine/pack-config.mjs`).
|
|
71
|
-
import { isAddressSegment } from "./engine/address-charset.mjs";
|
|
71
|
+
import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
|
|
72
72
|
import { MAP_TYPES, PACK_BY_TYPE } from "./engine/ids.mjs";
|
|
73
73
|
|
|
74
74
|
/**
|
|
@@ -116,44 +116,37 @@ export const PACK_DOCUMENT_TYPES = /** @type {const} */ ([
|
|
|
116
116
|
]);
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* A
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* A retired *value* is refused the way a retired *field* is (see
|
|
142
|
-
* `engine/retired-fields.mjs`): left merely unrecognized it would be reported
|
|
143
|
-
* as a bad value, which names something to correct and leaves the author to
|
|
144
|
-
* work out for themselves that the mechanism is gone. The message says the rule
|
|
145
|
-
* is retired, what lands a section instead, and what to do with the key.
|
|
119
|
+
* Address-scheme keys a configuration may no longer declare.
|
|
120
|
+
*
|
|
121
|
+
* A retired key has exactly two possible fates, and only one of them is honest
|
|
122
|
+
* — the same reasoning `engine/retired-fields.mjs` applies to a retired
|
|
123
|
+
* frontmatter field. Left honoured, it keeps doing whatever it did, which is
|
|
124
|
+
* why it was retired. Left *ignored*, it reads to its author as though it still
|
|
125
|
+
* works: the configuration says one thing and the build does another, and
|
|
126
|
+
* nothing says so. This module has no third option, because it has no warning
|
|
127
|
+
* channel — every finding goes through `fail()`, which throws. So a retired
|
|
128
|
+
* key is **refused**, at the line it was written on, with a message that says
|
|
129
|
+
* the mechanism is gone rather than naming a value to correct.
|
|
130
|
+
*
|
|
131
|
+
* **What `landing` did (#204).** It named which note addressed a whole section
|
|
132
|
+
* rather than a page within one — a *landing page*, which therefore had no slug
|
|
133
|
+
* of its own. #203 retired the second of its two rules and #204 retired the
|
|
134
|
+
* concept both rules chose between: a section is a Hugo content directory that
|
|
135
|
+
* the note format does not carry, a page's address names no directory, and so
|
|
136
|
+
* no note lands anything. The key outlived its mechanism by one release only
|
|
137
|
+
* because both publishing consumers still declared the then-true
|
|
138
|
+
* `landing: readme`, and neither breaking them over a correct statement nor
|
|
139
|
+
* accepting the key in silence was acceptable. Neither declares it now.
|
|
146
140
|
*
|
|
147
141
|
* @type {Readonly<Record<string, string>>}
|
|
148
142
|
*/
|
|
149
|
-
export const
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"the `section:` frontmatter key the rule read is retired with it",
|
|
143
|
+
export const RETIRED_ADDRESS_KEYS = Object.freeze({
|
|
144
|
+
landing:
|
|
145
|
+
"is a retired option — delete it. It named which note addressed a " +
|
|
146
|
+
"whole section rather than a page within one, and there are no " +
|
|
147
|
+
"sections to address: a section is a Hugo content directory the note " +
|
|
148
|
+
"format does not carry, so no note lands one and every page is " +
|
|
149
|
+
"addressed `<type>-<shortcode>`. Nothing replaces it",
|
|
157
150
|
});
|
|
158
151
|
|
|
159
152
|
/**
|
|
@@ -166,11 +159,12 @@ export const RETIRED_LANDING_RULES = Object.freeze({
|
|
|
166
159
|
* knowledge, held in `PACKAGE_BASE` (`engine/kb-manifest.mjs`) and prefixed at
|
|
167
160
|
* resolve time, so it is never recorded here (#1465).
|
|
168
161
|
*
|
|
169
|
-
* `landing
|
|
162
|
+
* It is the whole scheme: `landing`, the key that named which note addressed a
|
|
163
|
+
* whole section, is retired with the sections themselves — see
|
|
164
|
+
* {@link RETIRED_ADDRESS_KEYS}.
|
|
170
165
|
*/
|
|
171
166
|
export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
|
|
172
167
|
prefix: "",
|
|
173
|
-
landing: "readme",
|
|
174
168
|
});
|
|
175
169
|
|
|
176
170
|
/**
|
|
@@ -471,10 +465,7 @@ export function publishesContentPages(config) {
|
|
|
471
465
|
|
|
472
466
|
/**
|
|
473
467
|
* @typedef {object} AddressSchemeInput
|
|
474
|
-
* @property {string} [prefix]
|
|
475
|
-
* @property {string} [landing] Which note addressed a whole section. Inert
|
|
476
|
-
* since #204 retired sections from the note format — see
|
|
477
|
-
* {@link LANDING_RULES}.
|
|
468
|
+
* @property {string} [prefix] Where the content tree mounts inside the package.
|
|
478
469
|
*/
|
|
479
470
|
|
|
480
471
|
/**
|
|
@@ -658,7 +649,7 @@ const SITE_KEYS = [
|
|
|
658
649
|
"backfillSections",
|
|
659
650
|
];
|
|
660
651
|
const SITE_TREE_KEYS = ["from", "section"];
|
|
661
|
-
const SECTION_META_KEYS = ["title", "banner", "description"];
|
|
652
|
+
const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
|
|
662
653
|
const DOC_PAGE_KEYS = ["title", "out", "preamble"];
|
|
663
654
|
const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
|
|
664
655
|
const RELATIONSHIP_KEYS = ["id", "type", "manifest", "compatibility", "itemCatalog"];
|
|
@@ -695,7 +686,7 @@ const STATS_KEYS = ["lastModifiedBy"];
|
|
|
695
686
|
export const DERIVED_SYSTEM_VERSION = Symbol.for("package-build.derivedSystemVersion");
|
|
696
687
|
const PUBLISH_KEYS = ["site", "manifests", "address"];
|
|
697
688
|
const MANIFEST_KEYS = ["publish", "consume"];
|
|
698
|
-
const ADDRESS_KEYS = ["prefix"
|
|
689
|
+
const ADDRESS_KEYS = ["prefix"];
|
|
699
690
|
|
|
700
691
|
/** @param {unknown} value */
|
|
701
692
|
function isPlainObject(value) {
|
|
@@ -1134,9 +1125,36 @@ function normalizeDocs(value) {
|
|
|
1134
1125
|
* add to its title. Each is left off entirely rather than written as
|
|
1135
1126
|
* `undefined`, which is not a value YAML can carry.
|
|
1136
1127
|
*
|
|
1128
|
+
* **`listType` / `listSubType` say what the section lists**
|
|
1129
|
+
* (heroiclands-hugo-theme#50). Since #204 a section's directory holds nothing
|
|
1130
|
+
* but the `_index.md` written here, so a layout reading Hugo's `.Pages` finds
|
|
1131
|
+
* no members and renders an empty landing. The membership survives in this map
|
|
1132
|
+
* and nowhere a theme can reach it, so the landing states it and a layout
|
|
1133
|
+
* substitutes the equivalent `site.RegularPages` query — the same one `sohl`'s
|
|
1134
|
+
* catalog layouts already run, which is why `sohl`'s landings never broke.
|
|
1135
|
+
*
|
|
1136
|
+
* They are two keys of their own rather than `type` / `subType` because `type`
|
|
1137
|
+
* on an `_index.md` is **Hugo's own layout selector**: verified against Hugo
|
|
1138
|
+
* 0.165, a section landing carrying `type: doc` renders through
|
|
1139
|
+
* `layouts/doc/list.html` rather than the default list template, so spelling
|
|
1140
|
+
* the content type there would silently change which template serves the
|
|
1141
|
+
* landing. (This build already uses that behaviour deliberately, for the
|
|
1142
|
+
* mount's own landing.)
|
|
1143
|
+
*
|
|
1144
|
+
* Both are checked as **address segments**, which is the trap this came from:
|
|
1145
|
+
* a section is named for the URL a consumer chose and a subType is an address
|
|
1146
|
+
* segment, and the two need not agree — `/sohl/kb/user-guide/` is the section,
|
|
1147
|
+
* `userguide` the subType (#207). Copying the section's name into the
|
|
1148
|
+
* declaration would select no page at all, and an empty landing reported by
|
|
1149
|
+
* nobody is the failure being fixed. A `listSubType` with no `listType` is
|
|
1150
|
+
* refused for the same reason: a subType is only distinguishing *within* a
|
|
1151
|
+
* type — `rules`, `userguide` and `reference` are all `doc` — so alone it names
|
|
1152
|
+
* no query.
|
|
1153
|
+
*
|
|
1137
1154
|
* @param {unknown} value - The declared entry.
|
|
1138
1155
|
* @param {string} where - Dotted path, for the error.
|
|
1139
|
-
* @returns {Readonly<{title: string, banner?: string, description?: string
|
|
1156
|
+
* @returns {Readonly<{title: string, banner?: string, description?: string,
|
|
1157
|
+
* listType?: string, listSubType?: string}>}
|
|
1140
1158
|
*/
|
|
1141
1159
|
function normalizeSectionMeta(value, where) {
|
|
1142
1160
|
if (!isPlainObject(value)) fail(where, "must be a mapping");
|
|
@@ -1149,6 +1167,32 @@ function normalizeSectionMeta(value, where) {
|
|
|
1149
1167
|
if (input.description !== undefined) {
|
|
1150
1168
|
out.description = requireNonEmptyString(input.description, `${where}.description`);
|
|
1151
1169
|
}
|
|
1170
|
+
for (const key of ["listType", "listSubType"]) {
|
|
1171
|
+
if (input[key] === undefined) continue;
|
|
1172
|
+
const segment = requireNonEmptyString(input[key], `${where}.${key}`);
|
|
1173
|
+
if (!isAddressSegment(segment)) {
|
|
1174
|
+
fail(
|
|
1175
|
+
`${where}.${key}`,
|
|
1176
|
+
`is \`${segment}\`, which is not alphanumeric. It names a ` +
|
|
1177
|
+
"content type or subType, and those are address segments " +
|
|
1178
|
+
`(${ADDRESS_SEGMENT_PATTERN.source}) — not the section's ` +
|
|
1179
|
+
"own name, which is a URL this site chose and need not " +
|
|
1180
|
+
"match (`user-guide` is the section, `userguide` the " +
|
|
1181
|
+
"subType). A value no page carries selects nothing and " +
|
|
1182
|
+
"leaves the landing empty",
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
out[key] = segment;
|
|
1186
|
+
}
|
|
1187
|
+
if (out.listSubType !== undefined && out.listType === undefined) {
|
|
1188
|
+
fail(
|
|
1189
|
+
`${where}.listSubType`,
|
|
1190
|
+
"is declared without a `listType`. A subType tells pages apart " +
|
|
1191
|
+
"only within a type — `rules`, `userguide` and `reference` " +
|
|
1192
|
+
"are all `doc` — so on its own it names no query for a layout " +
|
|
1193
|
+
"to run",
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1152
1196
|
return Object.freeze(out);
|
|
1153
1197
|
}
|
|
1154
1198
|
|
|
@@ -1731,6 +1775,15 @@ function normalizePublish(value) {
|
|
|
1731
1775
|
fail("publish.address", "must be an object");
|
|
1732
1776
|
}
|
|
1733
1777
|
const address = /** @type {Record<string, unknown>} */ (addressInput ?? {});
|
|
1778
|
+
// A retired key is refused by name, ahead of the vocabulary check: reported
|
|
1779
|
+
// as merely unrecognized it would read as a misspelling of the one key that
|
|
1780
|
+
// survives, and the author would correct the spelling rather than learn
|
|
1781
|
+
// that the mechanism is gone (#215).
|
|
1782
|
+
for (const key of Object.keys(address)) {
|
|
1783
|
+
if (Object.hasOwn(RETIRED_ADDRESS_KEYS, key)) {
|
|
1784
|
+
fail(`publish.address.${key}`, RETIRED_ADDRESS_KEYS[key]);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1734
1787
|
rejectUnknownKeys(address, ADDRESS_KEYS, "publish.address.");
|
|
1735
1788
|
|
|
1736
1789
|
const prefix =
|
|
@@ -1749,24 +1802,9 @@ function normalizePublish(value) {
|
|
|
1749
1802
|
fail("publish.address.prefix", "must not begin with a slash");
|
|
1750
1803
|
}
|
|
1751
1804
|
|
|
1752
|
-
const landing =
|
|
1753
|
-
address.landing === undefined ?
|
|
1754
|
-
DEFAULT_ADDRESS_SCHEME.landing
|
|
1755
|
-
: optionalString(address.landing, "publish.address.landing");
|
|
1756
|
-
// A retired rule is refused by name, before the vocabulary check: reported
|
|
1757
|
-
// as merely unrecognized it would read as a misspelling of the one that
|
|
1758
|
-
// survives, and the author would correct the value rather than learn that
|
|
1759
|
-
// the mechanism is gone (#202).
|
|
1760
|
-
if (Object.hasOwn(RETIRED_LANDING_RULES, landing)) {
|
|
1761
|
-
fail("publish.address.landing", RETIRED_LANDING_RULES[landing]);
|
|
1762
|
-
}
|
|
1763
|
-
if (!LANDING_RULES.includes(landing)) {
|
|
1764
|
-
fail("publish.address.landing", `must be one of ${LANDING_RULES.join(", ")}`);
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
1805
|
return Object.freeze({
|
|
1768
1806
|
site: normalizeSiteMode(publish.site),
|
|
1769
|
-
address: Object.freeze({ prefix
|
|
1807
|
+
address: Object.freeze({ prefix }),
|
|
1770
1808
|
manifests: Object.freeze({
|
|
1771
1809
|
publish: optionalBoolean(manifests.publish, "publish.manifests.publish", false),
|
|
1772
1810
|
consume: optionalBoolean(manifests.consume, "publish.manifests.consume", false),
|
package/docs/content-format.md
CHANGED
|
@@ -138,6 +138,17 @@ it _there_, and a system that disagrees is not in error. A weapon weighs what
|
|
|
138
138
|
This is the same rule as `hm3.type` overriding a derived document type, applied
|
|
139
139
|
to fields: derive from the shared source, and let the system state the exception.
|
|
140
140
|
|
|
141
|
+
**A field whose spelling means something else at the note level has no shared
|
|
142
|
+
source.** The fallback assumes the two vocabularies agree about what a name
|
|
143
|
+
means, and they do not always: a note's top-level `title` is the heading its page
|
|
144
|
+
publishes under, while an `affiliation` item's `system.title` is the style of
|
|
145
|
+
address an office carries. Where they diverge, the field declares what the
|
|
146
|
+
top-level key means instead, and the top level stops being read for it — leaving
|
|
147
|
+
`<system>.system.<field>` and the legacy in-block position, which describe the
|
|
148
|
+
document rather than the note. `title` is the one field this applies to; `subType`
|
|
149
|
+
is the other declared item field spelled like a note-level key, and there the two
|
|
150
|
+
levels mean the same thing by design.
|
|
151
|
+
|
|
141
152
|
**A `WikiLink` becomes a shortcode where the target field expects one.** SoHL
|
|
142
153
|
stores cross-references as shortcode strings, which is what the `Code` suffix
|
|
143
154
|
marks: `data.assocSkill` is a link to a skill note, and `system.assocSkillCode`
|
|
@@ -249,17 +260,16 @@ against the same constant a shortcode is checked against, and a note carrying a
|
|
|
249
260
|
hyphenated value is reported where it wrote it:
|
|
250
261
|
|
|
251
262
|
```text
|
|
252
|
-
Trauma/Blood_Loss.md:3:1: error: `subType` "blood-loss" is not
|
|
263
|
+
Trauma/Blood_Loss.md:3:1: error: `subType` "blood-loss" is not a well-formed subType — a subType is letters and digits only (^[A-Za-z0-9]+$), the same charset a type, a shortcode and a contentPackage are held to. It is a vocabulary term the whole toolchain keys on, and one closed set away from being an address segment again, so a charset that held for every term but this one would be a rule nobody could state in a sentence
|
|
253
264
|
```
|
|
254
265
|
|
|
255
266
|
One declared value broke the rule and has been renamed: a `doc`'s `user-guide`
|
|
256
|
-
is now **`userguide`**. The old spelling
|
|
257
|
-
release
|
|
258
|
-
not
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
```
|
|
267
|
+
is now **`userguide`**. The old spelling was accepted for one transitional
|
|
268
|
+
release, as a warning naming the replacement, so the 43 `sohl` notes authoring
|
|
269
|
+
it were not invalidated by the release that renamed them. Every consumer tree
|
|
270
|
+
has swept, so the acceptance is gone (#210) and `user-guide` is refused by the
|
|
271
|
+
charset check — it contains a hyphen, which is the reason that always applied.
|
|
272
|
+
No retirement-specific code outlived the sweep.
|
|
263
273
|
|
|
264
274
|
That is a guarantee rather than an observation, and it holds: of **4,456 distinct
|
|
265
275
|
shortcodes** across the four content trees, not one contains a character outside
|
|
@@ -786,6 +796,16 @@ mapping for a field no schema declares is the drift these tables exist to catch.
|
|
|
786
796
|
likewise absent here: they are filled on an embedded membership, never from a
|
|
787
797
|
catalogue note's `data:`.
|
|
788
798
|
|
|
799
|
+
**`system.title` is not the note's `title`.** The two are unrelated quantities
|
|
800
|
+
that share a spelling. A note's top-level `title` is _the title of the note_ —
|
|
801
|
+
the heading its page is published under; an affiliation's `system.title` is _the
|
|
802
|
+
style of address the office carries_, Ajaw or Warden, which a being holds by
|
|
803
|
+
virtue of its rank. So the top-level key is **not** a shared source for this
|
|
804
|
+
field, and a note that writes one is stating its own heading and nothing else
|
|
805
|
+
(#218). Author the style of address on the membership — the `system.title` of the
|
|
806
|
+
entry in a being's `sohl.items` — or, on a catalogue note that genuinely carries
|
|
807
|
+
one, at `sohl.system.title`.
|
|
808
|
+
|
|
789
809
|
### type: affliction
|
|
790
810
|
|
|
791
811
|
Represents an affliction.
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
// The scheme vocabulary is part of the configuration contract — a
|
|
33
33
|
// repository names its scheme in `package-build.config.yaml` — so it is
|
|
34
34
|
// declared beside the rest of that vocabulary rather than here, and this
|
|
35
|
-
// module
|
|
36
|
-
// but `node:path` and `engine/ids.mjs`,
|
|
37
|
-
// cycle (see `engine/pack-config.mjs`).
|
|
38
|
-
import { DEFAULT_ADDRESS_SCHEME
|
|
35
|
+
// module re-exports it beside the addresses it derives. `config.mjs` is the
|
|
36
|
+
// leaf entry point and imports nothing but `node:path` and `engine/ids.mjs`,
|
|
37
|
+
// so the direction cannot close a cycle (see `engine/pack-config.mjs`).
|
|
38
|
+
import { DEFAULT_ADDRESS_SCHEME } from "../content-config.mjs";
|
|
39
39
|
|
|
40
|
-
export { DEFAULT_ADDRESS_SCHEME
|
|
40
|
+
export { DEFAULT_ADDRESS_SCHEME };
|
|
41
41
|
|
|
42
42
|
/** The knowledgebase's mount within this package's site (#1470). */
|
|
43
43
|
export const KB_PREFIX = "kb/";
|
|
@@ -116,26 +116,18 @@ export function contentAddress(fm) {
|
|
|
116
116
|
* `/<package>/api/` for generated API docs, neither of which contains a hyphen
|
|
117
117
|
* or names a type.
|
|
118
118
|
*
|
|
119
|
+
* **It takes no address scheme.** It took one until #215, to validate the
|
|
120
|
+
* `landing` rule it then discarded; with that key retired, `prefix` was the
|
|
121
|
+
* only thing left in the scheme and the paragraph above is the reason it never
|
|
122
|
+
* applied. A parameter read by nothing is the defect this deletion is about.
|
|
123
|
+
*
|
|
119
124
|
* @param {object} fm - Parsed frontmatter.
|
|
120
|
-
* @param {object} [options] - Options.
|
|
121
|
-
* @param {{prefix?: string, landing?: string}} [options.scheme] - The
|
|
122
|
-
* repository's address scheme; defaults to {@link DEFAULT_ADDRESS_SCHEME}.
|
|
123
|
-
* `landing` is validated against {@link LANDING_RULES} and selects nothing —
|
|
124
|
-
* it is accepted so a configuration declaring the still-true `landing: readme`
|
|
125
|
-
* keeps loading, and is removed once none does.
|
|
126
125
|
* @returns {string} The package-relative address, with a trailing slash and no
|
|
127
126
|
* leading one.
|
|
128
127
|
* @throws {Error} When the note has no type or no shortcode to be addressed by.
|
|
129
128
|
* Such a note is not published, and inventing an address for one would put a
|
|
130
129
|
* dead entry in the manifest.
|
|
131
130
|
*/
|
|
132
|
-
export function packageAddress(fm
|
|
133
|
-
const { landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
|
|
134
|
-
if (!LANDING_RULES.includes(landing)) {
|
|
135
|
-
throw new Error(
|
|
136
|
-
`unknown landing rule ${JSON.stringify(landing)} — expected one ` +
|
|
137
|
-
`of ${LANDING_RULES.join(", ")}`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
131
|
+
export function packageAddress(fm) {
|
|
140
132
|
return `${addressSlug(fm)}/`;
|
|
141
133
|
}
|
|
@@ -111,6 +111,34 @@ function fieldTable(fields) {
|
|
|
111
111
|
return padTable(rows);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The fields of one type that are **not** filled from the note's top level.
|
|
116
|
+
*
|
|
117
|
+
* A field ordinarily falls back to the top-level property spelled like its
|
|
118
|
+
* name, so an author who writes `weight: 3` at the top of a note reasonably
|
|
119
|
+
* expects it to reach the document. Where that spelling means something else at
|
|
120
|
+
* the note level the fallback is off, and an author has no way to tell from the
|
|
121
|
+
* table — the field is there, the value is written, and the document ships the
|
|
122
|
+
* default. So the reason each such field declares is rendered beside its table
|
|
123
|
+
* rather than left in the source (#218).
|
|
124
|
+
*
|
|
125
|
+
* Below the table, not inside it: the reason is a sentence or two, and
|
|
126
|
+
* {@link padTable} pads every column to its widest cell, so a cell holding it
|
|
127
|
+
* would stretch the whole type's table past legibility.
|
|
128
|
+
*
|
|
129
|
+
* @param {readonly object[]} fields - The type's declaration.
|
|
130
|
+
* @returns {string[]} Markdown lines, empty when the type exempts nothing.
|
|
131
|
+
*/
|
|
132
|
+
function sharedExemptions(fields) {
|
|
133
|
+
const exempt = authoredFields(fields).filter((field) => field.topLevelMeans);
|
|
134
|
+
if (!exempt.length) return [];
|
|
135
|
+
return exempt.flatMap((field) => [
|
|
136
|
+
`**\`${field.name}\` is not read from the note's top level.** There it means ` +
|
|
137
|
+
`${field.topLevelMeans}`,
|
|
138
|
+
"",
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
/**
|
|
115
143
|
* A minimal note for one type: the frontmatter envelope every note carries,
|
|
116
144
|
* plus exactly the `sohl:` fields the type requires.
|
|
@@ -204,6 +232,7 @@ export function renderItemFieldReference({
|
|
|
204
232
|
"",
|
|
205
233
|
...fieldTable(declared[type]),
|
|
206
234
|
"",
|
|
235
|
+
...sharedExemptions(declared[type]),
|
|
207
236
|
...workedExample(type, declared[type]),
|
|
208
237
|
"",
|
|
209
238
|
);
|
package/engine/field-spec.mjs
CHANGED
|
@@ -65,6 +65,31 @@ export { setPath };
|
|
|
65
65
|
* still read, second, until #126 moves the corpus off it.
|
|
66
66
|
*
|
|
67
67
|
* Absent means the value is not authored at all — see `value`.
|
|
68
|
+
* @property {string} [topLevelMeans] - **What the note's top-level key of this
|
|
69
|
+
* name means instead** — declared only where it means something else, and
|
|
70
|
+
* stating it removes the shared top-level position from this field's
|
|
71
|
+
* resolution order (#218).
|
|
72
|
+
*
|
|
73
|
+
* A field's `name` doubles as its identity and as the shared property it
|
|
74
|
+
* draws from, which is right wherever the two levels state the same quantity
|
|
75
|
+
* — `data.weight` is the weight, whoever reads it. It is wrong wherever a
|
|
76
|
+
* spelling collides across the two vocabularies. An `affiliation` item's
|
|
77
|
+
* `system.title` is the style of address an office carries; a note's
|
|
78
|
+
* top-level `title` is the note's own heading. Nothing relates them, and
|
|
79
|
+
* before this key one silently fed the other, stringifying an authored
|
|
80
|
+
* `title: null` into fifteen documents.
|
|
81
|
+
*
|
|
82
|
+
* **The value is the reason**, not a flag with a comment beside it. A boolean
|
|
83
|
+
* would record the decision and lose the case for it, and the next person
|
|
84
|
+
* adding a field needs to know the question exists — this package's own rule
|
|
85
|
+
* that the declaration *is* the statement, never a description of one. The
|
|
86
|
+
* author-facing reference renders it, so an author reading the field table
|
|
87
|
+
* learns that the top-level key will not fill this field, and why.
|
|
88
|
+
*
|
|
89
|
+
* The exempted field is still authorable, at both of the positions that
|
|
90
|
+
* describe the *document* rather than the note: `<system>.system.<to>` and
|
|
91
|
+
* the legacy in-block `<system>.<name>`. Absent means the ordinary case —
|
|
92
|
+
* the top level is read, as the third step.
|
|
68
93
|
* @property {string} [shape] - Human-readable shape, for documentation. Comes
|
|
69
94
|
* paired with `read` from one of the coercion constants below.
|
|
70
95
|
* @property {(raw: any, ctx: {fm: object, field: FieldSpec}) => any} [read] -
|
|
@@ -61,13 +61,7 @@ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.
|
|
|
61
61
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
62
62
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
63
63
|
import { isAddressSegment } from "./address-charset.mjs";
|
|
64
|
-
import {
|
|
65
|
-
declaredTags,
|
|
66
|
-
retiredSubType,
|
|
67
|
-
retiredSubTypeMessage,
|
|
68
|
-
subTypeCharsetMessage,
|
|
69
|
-
typeCharsetMessage,
|
|
70
|
-
} from "./note-vocabulary.mjs";
|
|
64
|
+
import { declaredTags, subTypeCharsetMessage, typeCharsetMessage } from "./note-vocabulary.mjs";
|
|
71
65
|
import {
|
|
72
66
|
RETIRED_FIELD_ALIASES,
|
|
73
67
|
declaresRetiredAlias,
|
|
@@ -342,11 +336,18 @@ function checkDataContainer(note, { type, fields }) {
|
|
|
342
336
|
* called, and `rules`, `userguide`, `reference` mean three genres and nothing
|
|
343
337
|
* else.
|
|
344
338
|
*
|
|
345
|
-
* **
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
339
|
+
* **Two checks, in this order** — the charset, then the closed set (#206,
|
|
340
|
+
* #204). The charset is first because it is the more general statement about
|
|
341
|
+
* the same value: a value outside `^[A-Za-z0-9]+$` is refused whatever the type
|
|
342
|
+
* declares, and only once it is a well-formed term is the type's own list the
|
|
343
|
+
* reason to refuse it.
|
|
344
|
+
*
|
|
345
|
+
* There were three. #206 ran a retired-spelling check ahead of both, accepting
|
|
346
|
+
* `user-guide` as a warning naming `userguide`, so the 43 `sohl` notes
|
|
347
|
+
* authoring it were not invalidated by the release that renamed it. Every
|
|
348
|
+
* consumer tree has swept, so the acceptance guarded nothing and is gone: the
|
|
349
|
+
* old spelling now falls through to the charset check, which refuses it for the
|
|
350
|
+
* reason that always applied — it contains a hyphen (#210).
|
|
350
351
|
*
|
|
351
352
|
* @param {object} note - The note.
|
|
352
353
|
* @param {object} opts
|
|
@@ -374,26 +375,7 @@ function checkSubType(note, { type, entry }) {
|
|
|
374
375
|
];
|
|
375
376
|
}
|
|
376
377
|
|
|
377
|
-
//
|
|
378
|
-
// before the charset, because the note is not wrong about the charset in
|
|
379
|
-
// some general way — it is wrong about one value, and naming the
|
|
380
|
-
// replacement is the whole of what the author needs. A warning rather than
|
|
381
|
-
// an error for the same reason the retired field aliases below are: the
|
|
382
|
-
// note compiles to the correct page, and erroring would red every tree the
|
|
383
|
-
// moment it took this release, ahead of any chance to sweep.
|
|
384
|
-
const replacement = retiredSubType(type, value);
|
|
385
|
-
if (replacement) {
|
|
386
|
-
return [
|
|
387
|
-
{
|
|
388
|
-
file: note.file,
|
|
389
|
-
...at,
|
|
390
|
-
severity: "warning",
|
|
391
|
-
message: retiredSubTypeMessage(type, value, replacement),
|
|
392
|
-
},
|
|
393
|
-
];
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// The charset, before the closed set: a hyphenated value is unaddressable
|
|
378
|
+
// The charset, before the closed set: a value outside it is refused
|
|
397
379
|
// whatever the type declares, and the type's list is not the reason it is
|
|
398
380
|
// refused. Reported here rather than only for an enumerated type, so a
|
|
399
381
|
// `subTypes: null` type — whose values nothing may yet check — is still
|
|
@@ -479,6 +461,44 @@ function checkTags(note, { type }) {
|
|
|
479
461
|
return findings;
|
|
480
462
|
}
|
|
481
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The frontmatter fields that name artwork, and so resolve through
|
|
466
|
+
* {@link module:engine/helpers.resolveImg}.
|
|
467
|
+
*
|
|
468
|
+
* Both, always: a being carries `img` and `portrait` independently — the token
|
|
469
|
+
* art and the sheet portrait — and a rule about how the translator reads an
|
|
470
|
+
* empty value belongs to the translator, not to whichever key happens to be
|
|
471
|
+
* more common. Eleven `sohl-kethira-basic` beings write `portrait: ""` and no
|
|
472
|
+
* note in any tree writes `img: ""` on a being; a check keyed on `img` alone
|
|
473
|
+
* would have called that tree clean (#218).
|
|
474
|
+
*
|
|
475
|
+
* @type {readonly string[]}
|
|
476
|
+
*/
|
|
477
|
+
const ART_FIELDS = Object.freeze(["img", "portrait"]);
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read a shared top-level field the way the compiler reads one: the `sohl:`
|
|
481
|
+
* block first, then the note's top level.
|
|
482
|
+
*
|
|
483
|
+
* The same order {@link module:engine/helpers.sohlField} uses, restated here
|
|
484
|
+
* rather than imported so this module stays a leaf the linter can load without
|
|
485
|
+
* a resolved build configuration. Unlike `sohlField` it distinguishes the two
|
|
486
|
+
* empties — an authored `""` comes back as `""` and an authored `null` as
|
|
487
|
+
* `null` — which is the whole point of the caller below (#218).
|
|
488
|
+
*
|
|
489
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
490
|
+
* @param {string} key - The field name.
|
|
491
|
+
* @returns {any} The authored value, or `undefined` where neither position
|
|
492
|
+
* declares one.
|
|
493
|
+
*/
|
|
494
|
+
function authoredValue(fm, key) {
|
|
495
|
+
const block = fm?.sohl;
|
|
496
|
+
if (block && typeof block === "object" && !Array.isArray(block) && Object.hasOwn(block, key)) {
|
|
497
|
+
return block[key];
|
|
498
|
+
}
|
|
499
|
+
return fm && Object.hasOwn(fm, key) ? fm[key] : undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
482
502
|
/**
|
|
483
503
|
* Check one note against its type's schema.
|
|
484
504
|
*
|
|
@@ -521,6 +541,53 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
521
541
|
"note in the tree belongs to it",
|
|
522
542
|
});
|
|
523
543
|
}
|
|
544
|
+
// `img: ""` was how a note said "I name no art" while `resolveImg`
|
|
545
|
+
// conflated the two empties and every caller defaulted with `||`. It now
|
|
546
|
+
// says the opposite — "ship no art, and do not default me" (#218) — so a
|
|
547
|
+
// note carrying the old spelling has quietly changed meaning. Forty-five
|
|
548
|
+
// `sohl-thalorna` notes were written under the old reading and would have
|
|
549
|
+
// lost their default art with no error and no warning; this is the guard
|
|
550
|
+
// that would have caught them.
|
|
551
|
+
//
|
|
552
|
+
// **Both art fields, because both go through `resolveImg`.** `portrait` is
|
|
553
|
+
// not a variant spelling of `img` — a being carries the two independently —
|
|
554
|
+
// and checking only the more common one is how the sweep that prompted this
|
|
555
|
+
// guard missed eleven `sohl-kethira-basic` beings that write
|
|
556
|
+
// `portrait: ""`. Whatever the rule is, it belongs to the function, not to
|
|
557
|
+
// one of the keys that reaches it.
|
|
558
|
+
//
|
|
559
|
+
// A **warning**, on the pattern the `package:` and retired-alias sweeps
|
|
560
|
+
// set: the note still compiles, to a document that is merely iconless, so
|
|
561
|
+
// reddening a tree over it would refuse before the sweep rather than after
|
|
562
|
+
// it. It is transitional in the same sense — `""` is a legal thing to mean,
|
|
563
|
+
// and the message says so, but nothing in any tree means it yet.
|
|
564
|
+
//
|
|
565
|
+
// **These two only, never `title`.** The rule reads as a general one about
|
|
566
|
+
// optional strings, and it is not — it belongs to `resolveImg`, and `title`
|
|
567
|
+
// never goes through it.
|
|
568
|
+
//
|
|
569
|
+
// It once had a sharper reason, recorded here because it was load-bearing
|
|
570
|
+
// and is now false: a note's top-level `title` was simultaneously the shared
|
|
571
|
+
// source for an `affiliation` item's `system.title`, so asking an author for
|
|
572
|
+
// `title: null` would have compiled the literal string `"null"` into the
|
|
573
|
+
// document. That collision is gone — the field declares `topLevelMeans` and
|
|
574
|
+
// the top-level key is no longer a source for it — so `title: null` is now
|
|
575
|
+
// harmless. Whether `title: ""` deserves a warning of its own is a separate
|
|
576
|
+
// question about the *page's* heading, still open on #218, and not settled
|
|
577
|
+
// by extending an art-path check to it.
|
|
578
|
+
for (const key of ART_FIELDS) {
|
|
579
|
+
if (authoredValue(fm, key) !== "") continue;
|
|
580
|
+
findings.push({
|
|
581
|
+
file: note.file,
|
|
582
|
+
...at(key),
|
|
583
|
+
severity: "warning",
|
|
584
|
+
message:
|
|
585
|
+
`\`${key}: ""\` means "ship no art at all" — it no longer falls ` +
|
|
586
|
+
`back to this type's default. Write \`${key}: null\` for a note ` +
|
|
587
|
+
'that simply names none; keep `""` only where the document is ' +
|
|
588
|
+
"meant to have no image",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
524
591
|
if (Object.hasOwn(fm, "draft")) {
|
|
525
592
|
findings.push({
|
|
526
593
|
file: note.file,
|