@heroiclands/package-build 10.0.1 → 11.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 +279 -0
- package/CONTENT.md +218 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +59 -75
- package/docs/content-format.md +90 -67
- package/engine/base-compiler.mjs +7 -1
- package/engine/content-address.mjs +71 -18
- package/engine/content-format-check.mjs +1 -1
- package/engine/content-links.mjs +93 -112
- package/engine/content-lint.mjs +14 -10
- package/engine/content-slug.mjs +39 -105
- package/engine/diagnostics.mjs +16 -2
- package/engine/frontmatter-lint.mjs +26 -13
- package/engine/helpers.mjs +31 -68
- package/engine/homepage.mjs +131 -86
- package/engine/index.mjs +2 -5
- package/engine/manifest-emit.mjs +23 -4
- package/engine/note-vocabulary.mjs +58 -1
- package/engine/retired-fields.mjs +117 -6
- package/engine/site-build.mjs +182 -59
- package/engine/site-index.mjs +57 -102
- package/engine/web-wikilinks.mjs +183 -127
- package/engine/wikilink-syntax.mjs +174 -34
- package/engine/wikilinks.mjs +159 -117
- package/package.json +1 -1
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/content-address.d.mts +46 -14
- package/types/engine/content-links.d.mts +13 -17
- package/types/engine/content-slug.d.mts +11 -48
- package/types/engine/diagnostics.d.mts +14 -1
- package/types/engine/helpers.d.mts +4 -3
- package/types/engine/homepage.d.mts +96 -60
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +43 -0
- package/types/engine/retired-fields.d.mts +78 -1
- package/types/engine/site-build.d.mts +70 -17
- package/types/engine/site-index.d.mts +19 -21
- package/types/engine/web-wikilinks.d.mts +29 -28
- package/types/engine/wikilink-syntax.d.mts +126 -40
- package/types/engine/wikilinks.d.mts +29 -24
- package/engine/abbreviations.mjs +0 -0
- package/engine/alias-index.mjs +0 -153
- package/types/engine/abbreviations.d.mts +0 -44
- package/types/engine/alias-index.d.mts +0 -122
package/bin/content-build.mjs
CHANGED
|
@@ -103,6 +103,8 @@ import {
|
|
|
103
103
|
formatUnaddressableFinding as formatUnaddressable,
|
|
104
104
|
} from "../engine/site-build.mjs";
|
|
105
105
|
import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
|
|
106
|
+
// The one place a link finding is worded, shared with both builds (#184).
|
|
107
|
+
import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
|
|
106
108
|
import {
|
|
107
109
|
emitDiagnostic,
|
|
108
110
|
positionInFrontmatter,
|
|
@@ -189,6 +191,26 @@ function reportFailure(err) {
|
|
|
189
191
|
else log.error(message);
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/**
|
|
195
|
+
* A note's source text, or `""` when it cannot be read.
|
|
196
|
+
*
|
|
197
|
+
* Used only to turn a link finding into a position. A path that no longer
|
|
198
|
+
* resolves — a page a build generated, a tree walked from somewhere else —
|
|
199
|
+
* yields `""`, and {@link positionOfLiteral} then reports nothing, so the
|
|
200
|
+
* diagnostic drops the line and column rather than guessing them.
|
|
201
|
+
*
|
|
202
|
+
* @param {string|undefined} file - Absolute path to the note.
|
|
203
|
+
* @returns {string} The file's contents, or `""`.
|
|
204
|
+
*/
|
|
205
|
+
function readRawNote(file) {
|
|
206
|
+
if (!file) return "";
|
|
207
|
+
try {
|
|
208
|
+
return fs.readFileSync(file, "utf8");
|
|
209
|
+
} catch {
|
|
210
|
+
return "";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
192
214
|
/**
|
|
193
215
|
* The declaration sets this package ships, addressable by system id.
|
|
194
216
|
*
|
|
@@ -1022,8 +1044,7 @@ function linksCommand() {
|
|
|
1022
1044
|
const {
|
|
1023
1045
|
deadAnchors,
|
|
1024
1046
|
deadAddresses,
|
|
1025
|
-
|
|
1026
|
-
aliasCollisions,
|
|
1047
|
+
unlabelledLinks,
|
|
1027
1048
|
frontmatterLinks,
|
|
1028
1049
|
homepageLinks,
|
|
1029
1050
|
usedManifest,
|
|
@@ -1039,62 +1060,19 @@ function linksCommand() {
|
|
|
1039
1060
|
`heading in ${d.dest.rel} declares`,
|
|
1040
1061
|
});
|
|
1041
1062
|
}
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
// because the corrections differ.
|
|
1045
|
-
|
|
1063
|
+
// Every link is an address (#180) and every address must
|
|
1064
|
+
// resolve (#184), so all of these are errors — but they read
|
|
1065
|
+
// differently because the corrections differ. The wording comes
|
|
1066
|
+
// from the shared table, so the checker cannot describe a
|
|
1067
|
+
// defect differently from the build that also refuses it.
|
|
1068
|
+
for (const d of [...deadAddresses, ...unlabelledLinks]) {
|
|
1046
1069
|
emitDiagnostic({
|
|
1047
1070
|
file: d.note.file,
|
|
1048
1071
|
...positionOfLiteral(d.note.raw, d.text, d.occurrence),
|
|
1049
1072
|
severity: "error",
|
|
1050
|
-
message:
|
|
1051
|
-
d.reason === "not-an-address" ?
|
|
1052
|
-
`"${d.target}" is written as an address — the ` +
|
|
1053
|
-
`"|" says so — but it is not one; write ` +
|
|
1054
|
-
`[[type-shortcode|Text]], or drop the "|" to ` +
|
|
1055
|
-
`name it as an alias within this note's type`
|
|
1056
|
-
: d.reason === "unknown-type" ?
|
|
1057
|
-
`address [[${d.target}]] names no known ` + `content type`
|
|
1058
|
-
: `dead address [[${d.target}]] — no document ` + `has that identity`,
|
|
1073
|
+
message: linkFindingMessage(d),
|
|
1059
1074
|
});
|
|
1060
1075
|
}
|
|
1061
|
-
// An alias that names nothing may be a worldbuilding
|
|
1062
|
-
// placeholder — a long-standing convention in the setting
|
|
1063
|
-
// trees — so it is reported and does not fail the build. The
|
|
1064
|
-
// ambiguous case is the collision below, reported at its
|
|
1065
|
-
// claimants rather than here.
|
|
1066
|
-
for (const d of deadAliases) {
|
|
1067
|
-
emitDiagnostic({
|
|
1068
|
-
file: d.note.file,
|
|
1069
|
-
...positionOfLiteral(d.note.raw, d.text, d.occurrence),
|
|
1070
|
-
severity: "warning",
|
|
1071
|
-
message:
|
|
1072
|
-
d.ambiguous ?
|
|
1073
|
-
`alias [[${d.target}]] is claimed by ` +
|
|
1074
|
-
`${d.claimants.length} ${d.note.type} notes, ` +
|
|
1075
|
-
`so it names none of them; address the ` +
|
|
1076
|
-
`intended one as [[type-shortcode|Text]]`
|
|
1077
|
-
: `unresolved alias [[${d.target}]] — no ` +
|
|
1078
|
-
`${d.note.type} note claims that name`,
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
// Reported once per claimant, at the claimant, because the
|
|
1082
|
-
// note that merely cites an ambiguous alias is innocent (#13).
|
|
1083
|
-
for (const c of aliasCollisions) {
|
|
1084
|
-
const others = c.claimants.map((n) => n.rel).join(", ");
|
|
1085
|
-
for (const claimant of c.claimants) {
|
|
1086
|
-
emitDiagnostic({
|
|
1087
|
-
file: claimant.file,
|
|
1088
|
-
...positionInFrontmatter(claimant.raw, "aliases", c.alias),
|
|
1089
|
-
severity: "error",
|
|
1090
|
-
message:
|
|
1091
|
-
`alias "${c.alias}" is claimed by ` +
|
|
1092
|
-
`${c.claimants.length} ${c.type} notes ` +
|
|
1093
|
-
`(${others}), so [[${c.alias}]] names none of ` +
|
|
1094
|
-
`them; rename all but one`,
|
|
1095
|
-
});
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
1076
|
for (const f of frontmatterLinks) {
|
|
1099
1077
|
emitDiagnostic({
|
|
1100
1078
|
file: f.note.file,
|
|
@@ -1119,12 +1097,10 @@ function linksCommand() {
|
|
|
1119
1097
|
});
|
|
1120
1098
|
}
|
|
1121
1099
|
|
|
1122
|
-
// Warnings are reported and do not fail: a dead alias may be a
|
|
1123
|
-
// note not yet written, which is the placeholder convention.
|
|
1124
1100
|
const failures =
|
|
1125
1101
|
deadAnchors.length +
|
|
1126
1102
|
deadAddresses.length +
|
|
1127
|
-
|
|
1103
|
+
unlabelledLinks.length +
|
|
1128
1104
|
frontmatterLinks.length +
|
|
1129
1105
|
homepageLinks.length;
|
|
1130
1106
|
if (failures) {
|
|
@@ -1132,21 +1108,14 @@ function linksCommand() {
|
|
|
1132
1108
|
process.exitCode = 1;
|
|
1133
1109
|
} else {
|
|
1134
1110
|
log.info(
|
|
1135
|
-
`${index.notes.length} notes: every
|
|
1136
|
-
`
|
|
1137
|
-
`(${usedManifest.size}
|
|
1138
|
-
`via manifest), no
|
|
1111
|
+
`${index.notes.length} notes: every link is a labelled ` +
|
|
1112
|
+
`address, every anchor link lands and every ` +
|
|
1113
|
+
`address resolves (${usedManifest.size} ` +
|
|
1114
|
+
`cross-package reference(s) via manifest), no ` +
|
|
1139
1115
|
`wikilink in frontmatter, every homepage address ` +
|
|
1140
1116
|
`resolvable.`,
|
|
1141
1117
|
);
|
|
1142
1118
|
}
|
|
1143
|
-
if (deadAliases.length) {
|
|
1144
|
-
log.warn(
|
|
1145
|
-
`${deadAliases.length} unresolved alias(es) — a bare ` +
|
|
1146
|
-
`[[Name]] naming no note may be a placeholder, so ` +
|
|
1147
|
-
`these are reported rather than failed.`,
|
|
1148
|
-
);
|
|
1149
|
-
}
|
|
1150
1119
|
} catch (err) {
|
|
1151
1120
|
reportFailure(err);
|
|
1152
1121
|
process.exitCode = 1;
|
|
@@ -1275,16 +1244,13 @@ function siteCommand() {
|
|
|
1275
1244
|
`page verbatim, and reaches the reader as brackets`,
|
|
1276
1245
|
});
|
|
1277
1246
|
}
|
|
1278
|
-
for (const f of gates.
|
|
1247
|
+
for (const f of gates.addressErrors) {
|
|
1279
1248
|
emitDiagnostic({
|
|
1280
1249
|
file: f.file,
|
|
1281
1250
|
severity: "error",
|
|
1282
1251
|
message: `cannot derive a URL: ${f.reason}`,
|
|
1283
1252
|
});
|
|
1284
1253
|
}
|
|
1285
|
-
for (const c of gates.collisions) {
|
|
1286
|
-
log.error(`${c.url} claimed by ${c.sources.join(", ")}`);
|
|
1287
|
-
}
|
|
1288
1254
|
for (const s of gates.staleManifests) {
|
|
1289
1255
|
emitDiagnostic({
|
|
1290
1256
|
file: path.join(loadPackConfig().paths.manifests, `${s.package}.json`),
|
|
@@ -1310,8 +1276,21 @@ function siteCommand() {
|
|
|
1310
1276
|
for (const e of result.tableErrors) {
|
|
1311
1277
|
log.error(`bad content table: ${e.reason} (${e.source})`);
|
|
1312
1278
|
}
|
|
1279
|
+
// Reported the way the pack build reports the very same
|
|
1280
|
+
// finding: `file:line:column: error: message`, path first, and
|
|
1281
|
+
// the message from the shared table (#184). It used to be a
|
|
1282
|
+
// `log.error` whose timestamp prefix sat where a parser reads
|
|
1283
|
+
// the path from, and whose text named a `reason` code rather
|
|
1284
|
+
// than saying what to do — so one authored link produced a
|
|
1285
|
+
// machine-readable diagnostic from one build and prose from
|
|
1286
|
+
// another.
|
|
1313
1287
|
for (const e of result.wikiErrors) {
|
|
1314
|
-
|
|
1288
|
+
emitDiagnostic({
|
|
1289
|
+
file: e.file,
|
|
1290
|
+
...positionOfLiteral(readRawNote(e.file), e.link, e.occurrence),
|
|
1291
|
+
severity: "error",
|
|
1292
|
+
message: linkFindingMessage(e),
|
|
1293
|
+
});
|
|
1315
1294
|
}
|
|
1316
1295
|
if (result.tableErrors.length || result.wikiErrors.length) {
|
|
1317
1296
|
process.exitCode = 1;
|
|
@@ -1319,11 +1298,16 @@ function siteCommand() {
|
|
|
1319
1298
|
}
|
|
1320
1299
|
|
|
1321
1300
|
if (result.manifests && !result.manifests.complete) {
|
|
1301
|
+
// Not a softening any more (#184): an address into one of
|
|
1302
|
+
// these packages fails like any other that resolves
|
|
1303
|
+
// nowhere. The warning names them so an author meeting that
|
|
1304
|
+
// failure knows the fix may be to vendor a manifest rather
|
|
1305
|
+
// than to correct a shortcode.
|
|
1322
1306
|
log.warn(
|
|
1323
|
-
`
|
|
1324
|
-
|
|
1325
|
-
`
|
|
1326
|
-
`
|
|
1307
|
+
`no link manifest vendored for ` +
|
|
1308
|
+
`${result.manifests.missing.join(", ")} — an ` +
|
|
1309
|
+
`address into one of those packages resolves ` +
|
|
1310
|
+
`nowhere and fails the build.`,
|
|
1327
1311
|
);
|
|
1328
1312
|
}
|
|
1329
1313
|
|
package/docs/content-format.md
CHANGED
|
@@ -74,8 +74,8 @@ silently become a theme parameter, which is exactly why these cannot live
|
|
|
74
74
|
together.
|
|
75
75
|
|
|
76
76
|
**Tags are open, except the ones that classify.** `tags:` shares the top level's
|
|
77
|
-
openness: a tag naming a theme
|
|
78
|
-
`byzaria`, `
|
|
77
|
+
openness: a tag naming a theme or a region — `underworld`,
|
|
78
|
+
`byzaria`, `riverlands` — is the author's own and this build has no opinion about it.
|
|
79
79
|
A tag that classifies the subject is different, because something queries it. A
|
|
80
80
|
settlement tagged `village` appears in the list of villages and an untagged one
|
|
81
81
|
does not, so `vilage` does not merely look wrong: it removes the note from an
|
|
@@ -91,6 +91,17 @@ is declared, so a near miss is a finding that names what you probably meant.
|
|
|
91
91
|
| **being station** | `being` | `tradesfolk`, `common-folk`, `soldiery`, `administration`, `clergy`, `mages`, `underworld`, `dependents`, `guilded`, `unguilded` |
|
|
92
92
|
| **state** | any | `draft` |
|
|
93
93
|
|
|
94
|
+
**`draft` is the one tag either build reads.** A note tagged `draft` exists so a
|
|
95
|
+
link into it is not dead, and a link into it renders marked — as
|
|
96
|
+
`<span class="sohl-draft-link" title="Draft — not yet written">…</span>` in a
|
|
97
|
+
compiled journal and on the website alike, with the appearance supplied by the
|
|
98
|
+
consuming system's stylesheet or the site theme. **Nothing else changes**: the
|
|
99
|
+
note compiles, validates, publishes and resolves exactly as any other, and it
|
|
100
|
+
stays in the packs, in the link manifest and on the site. That is what separates
|
|
101
|
+
the tag from the retired `draft:` field, whose whole effect was to move a note
|
|
102
|
+
from published to unresolvable without saying so — and which is refused, by name,
|
|
103
|
+
if you write it.
|
|
104
|
+
|
|
94
105
|
**The group's scope is what makes the check work.** A group names the types it
|
|
95
106
|
applies to, and a place's kinds are only ever checked on a place. Without that
|
|
96
107
|
the rule is wrong on every note it touches: `azravan` on a faith, `barter` on an
|
|
@@ -235,49 +246,78 @@ resolution with nothing to say so.
|
|
|
235
246
|
`type/shortcode` with a slash is the legacy form, still resolved so links written
|
|
236
247
|
before the vault migrated do not silently die. A slash is _unconditionally_ an
|
|
237
248
|
address separator — pipe or no pipe — so an unknown type before one is an error
|
|
238
|
-
rather than
|
|
249
|
+
rather than something to guess at.
|
|
239
250
|
|
|
240
|
-
####
|
|
251
|
+
#### Every link is an address, and every link carries a label
|
|
241
252
|
|
|
242
|
-
There
|
|
253
|
+
There is one namespace, and the pipe is required:
|
|
243
254
|
|
|
244
255
|
| written | resolved as | displays |
|
|
245
256
|
| -------------------- | ----------- | -------------------------- |
|
|
246
|
-
| `[[Alias]]` | an alias | the alias, as written |
|
|
247
257
|
| `[[WikiLink\|]]` | an address | the target note's own name |
|
|
248
258
|
| `[[WikiLink\|Text]]` | an address | `Text` |
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
####
|
|
259
|
+
| `[[Name]]` | nothing | a finding |
|
|
260
|
+
|
|
261
|
+
**A link written without a label addresses nothing** (#180), and the correction
|
|
262
|
+
is always the same: write `[[type-shortcode|Text]]`.
|
|
263
|
+
|
|
264
|
+
The bare form used to name an **alias** — a note's own display name, or one of
|
|
265
|
+
the names it listed in `aliases:` — looked up within the citing note's type. It
|
|
266
|
+
was measured before it was retired, and the namespace was empty in practice:
|
|
267
|
+
across 8,305 wikilinks in three content trees, **not one** bare link resolved to
|
|
268
|
+
a note. What the index behind it did do was fold every note's `name.full` into
|
|
269
|
+
itself, so two notes of one type could not share a display name — a rules page
|
|
270
|
+
and a user-guide page both called "Gear" were a build failure whose every
|
|
271
|
+
available fix moved a published URL (#179).
|
|
272
|
+
|
|
273
|
+
The top-level `aliases:` that fed it is **retired** and refused. The nested
|
|
274
|
+
`name.aliases:` is **not**: it is reserved for a use that does not exist yet, so
|
|
275
|
+
it is permitted and read by nothing — no index, no resolver, no lint rule, no
|
|
276
|
+
derived address. A note carrying one behaves exactly as one without it.
|
|
277
|
+
|
|
278
|
+
Requiring the label is also what makes positional parsing safe. Note names
|
|
279
|
+
contain hyphens — `Grukar-ahk` is a name, not a `Grukar` of type `ahk` — so a
|
|
280
|
+
target that does not parse as an address is reported as one that does not, rather
|
|
281
|
+
than split at an arbitrary place or quietly looked up somewhere else.
|
|
282
|
+
|
|
283
|
+
The **empty** label is not a way of writing no label. It says _address this
|
|
284
|
+
target, and show whatever it calls itself_ — so a note renamed later takes its new
|
|
285
|
+
name at every citation with no link edited. `[[x|]]` is labelled; `[[x]]` is not.
|
|
286
|
+
|
|
287
|
+
The link part may still be an anchor: `[[#slug|Text]]` addresses a section of the
|
|
288
|
+
page it is written on. It is the label that is required, not a target.
|
|
289
|
+
|
|
290
|
+
#### Every address resolves, and every build says so the same way
|
|
291
|
+
|
|
292
|
+
An address that names no note **fails the build** (#184) — in the link checker,
|
|
293
|
+
in the pack compilers and in the site build alike.
|
|
294
|
+
|
|
295
|
+
It was a warning in the checker and, in the site build, nothing at all while any
|
|
296
|
+
linkable package had no vendored manifest. The reasoning was that `[[Sunless
|
|
297
|
+
Vault]]` might be a placeholder for a note somebody meant to write. That was a
|
|
298
|
+
property of the **bare** form, which is retired, and the intent behind it has a
|
|
299
|
+
real spelling now: a note tagged `draft` exists, resolves, compiles and
|
|
300
|
+
publishes, and a link to it renders visibly marked (#183). So an address landing
|
|
301
|
+
nowhere is a typo or an omission, and both want fixing.
|
|
302
|
+
|
|
303
|
+
There are six ways a link can fail, and each is one **error** with one message
|
|
304
|
+
wherever it is met:
|
|
305
|
+
|
|
306
|
+
| finding | what it means | the fix |
|
|
307
|
+
| ---------------- | ---------------------------------------------- | ----------------------------------------- |
|
|
308
|
+
| `unlabelled` | no `\|`, so the link addresses nothing | write `[[type-shortcode\|Text]]` |
|
|
309
|
+
| `not-an-address` | labelled, but the target is not an address | write the address, not the name |
|
|
310
|
+
| `unknown-type` | qualified, but names no type this build knows | correct the type segment |
|
|
311
|
+
| `unresolved` | parses as an address; nothing publishes it | fix the shortcode, or vendor the manifest |
|
|
312
|
+
| `ambiguous` | more than one package publishes the short form | write `[[package-type-shortcode\|Text]]` |
|
|
313
|
+
| `unknown-anchor` | the address resolves; the `#section` does not | correct the anchor |
|
|
314
|
+
|
|
315
|
+
The vocabulary and the messages live in one module (`engine/wikilink-syntax.mjs`)
|
|
316
|
+
precisely because an author meets whichever build ran first. Three resolvers read
|
|
317
|
+
one authored link; they must not describe the same mistake in three ways, and
|
|
318
|
+
they must never disagree about whether it is a mistake at all.
|
|
319
|
+
|
|
320
|
+
#### In frontmatter, a link is a bare address
|
|
281
321
|
|
|
282
322
|
A `WikiLink` **field** takes the address with no brackets:
|
|
283
323
|
|
|
@@ -292,17 +332,10 @@ not `[[hexhodai]]`. The field is declared as a `WikiLink`, so the schema already
|
|
|
292
332
|
knows the value is an address and reads it as one; brackets would be punctuation
|
|
293
333
|
the reader has to strip before it can do anything.
|
|
294
334
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
The reason is that frontmatter is structure rather than prose. An alias is an
|
|
301
|
-
authorial convenience for writing a sentence that reads well; a field value is a
|
|
302
|
-
reference something else will compile against, and it should say exactly what it
|
|
303
|
-
points at. There is also nowhere to put the pipe: the distinction body text draws
|
|
304
|
-
with punctuation has no equivalent in a YAML scalar, so the region as a whole
|
|
305
|
-
picks one namespace and keeps it.
|
|
335
|
+
A frontmatter value is parsed by the address grammar above, so a single-segment
|
|
336
|
+
value such as `hexhodai` is a _shortcode_. Frontmatter is structure rather than
|
|
337
|
+
prose: a field value is a reference something else will compile against, and it
|
|
338
|
+
should say exactly what it points at.
|
|
306
339
|
|
|
307
340
|
**The field supplies the type.** Every `WikiLink` field declares the note type it
|
|
308
341
|
targets — `seat` a `place`, `parents` an `affiliation`, `stations` a `lore` — so
|
|
@@ -339,15 +372,10 @@ So the ladder has one rung where the field helps and three where it only checks:
|
|
|
339
372
|
a bare shortcode takes its type from the declaration, and every longer form
|
|
340
373
|
states the type itself and is verified against it.
|
|
341
374
|
|
|
342
|
-
This also settles the alias question on its own. Aliases resolve within the
|
|
343
|
-
_source_ note's type, and a field almost always points at a different type — a
|
|
344
|
-
being's `stations` are `lore` notes — so an alias in a field would be looked up
|
|
345
|
-
in the wrong namespace even where one existed to find.
|
|
346
|
-
|
|
347
375
|
This is enforced rather than merely preferred: the build walks every frontmatter
|
|
348
|
-
value, reports each bracketed link it finds, each value
|
|
349
|
-
|
|
350
|
-
of what `content-build links` reports when it passes.
|
|
376
|
+
value, reports each bracketed link it finds, and reports each value whose
|
|
377
|
+
qualification contradicts its field. A successful run says so — _no wikilink in
|
|
378
|
+
frontmatter_ is part of what `content-build links` reports when it passes.
|
|
351
379
|
|
|
352
380
|
Brackets belong in prose, where a link sits inside a sentence and needs marking
|
|
353
381
|
off from the words around it. A frontmatter value has nothing to be marked off
|
|
@@ -355,21 +383,16 @@ from.
|
|
|
355
383
|
|
|
356
384
|
#### Not yet implemented
|
|
357
385
|
|
|
358
|
-
|
|
386
|
+
One rule in this section is settled but unbuilt, and describes the target rather
|
|
359
387
|
than current behaviour:
|
|
360
388
|
|
|
361
389
|
- **The `<system>` segment.** `readQualifier` reads package, type and shortcode;
|
|
362
390
|
there is no system segment. A four-segment target today parses as
|
|
363
391
|
`package-type-shortcode` with a hyphenated shortcode, or fails.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
The corpus is close to the rule already. Of 12,056 links, 10,413 are
|
|
370
|
-
`[[type-shortcode|Label]]`, which is correct as written. Two authored links use
|
|
371
|
-
an unpiped multi-segment target, and 69 use a pipe with a note name where an
|
|
372
|
-
address belongs; those are the migration.
|
|
392
|
+
The corpus is close to the rule already. Of 12,056 links, 10,413 are
|
|
393
|
+
`[[type-shortcode|Label]]`, which is correct as written. Two authored links use
|
|
394
|
+
an unpiped multi-segment target, and 69 use a pipe with a note name where an
|
|
395
|
+
address belongs; those are the migration.
|
|
373
396
|
|
|
374
397
|
### What a note produces
|
|
375
398
|
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
} from "./helpers.mjs";
|
|
78
78
|
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
79
79
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
80
|
-
import { assertNoDraftField } from "./retired-fields.mjs";
|
|
80
|
+
import { assertNoAliasesField, assertNoDraftField } from "./retired-fields.mjs";
|
|
81
81
|
import { assertTypeNotRetired, packForType } from "./ids.mjs";
|
|
82
82
|
import { carriesSystemBlock } from "./system-block.mjs";
|
|
83
83
|
import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.mjs";
|
|
@@ -770,6 +770,11 @@ export class BasePackCompiler {
|
|
|
770
770
|
// - `draft:` (#69): it excluded the note from the packs, the
|
|
771
771
|
// manifest and the site, and no checker reported the links that
|
|
772
772
|
// left dangling.
|
|
773
|
+
// - `aliases:` (#180): it fed the alias index, which the bare
|
|
774
|
+
// `[[Alias]]` form was looked up in; the form is retired, so the
|
|
775
|
+
// list has no reader left. The nested `name.aliases` is a
|
|
776
|
+
// different field and is **not** refused — it is reserved, and
|
|
777
|
+
// deliberately neither read nor validated.
|
|
773
778
|
//
|
|
774
779
|
// Both are reported and counted — never skipped, which is how a
|
|
775
780
|
// tree naming a package nothing answers to used to compile zero
|
|
@@ -778,6 +783,7 @@ export class BasePackCompiler {
|
|
|
778
783
|
try {
|
|
779
784
|
assertNoDeclaredPackage(fm, { absPath });
|
|
780
785
|
assertNoDraftField(fm, { absPath });
|
|
786
|
+
assertNoAliasesField(fm, { absPath });
|
|
781
787
|
} catch (err) {
|
|
782
788
|
stats.declined++;
|
|
783
789
|
this.errorCount++;
|
|
@@ -18,9 +18,17 @@
|
|
|
18
18
|
* knowledgebase build renders the page, and the link manifest records the
|
|
19
19
|
* address other packages link to. Stating it twice is how a manifest comes to
|
|
20
20
|
* assert a URL that resolves at build time and 404s for the reader.
|
|
21
|
+
*
|
|
22
|
+
* **A page's URL is its address** — `<package>/<type>-<shortcode>/` (#181). It
|
|
23
|
+
* used to be derived from `name.full`, which made a display string load-bearing
|
|
24
|
+
* in three separate ways: a rename moved the URL and nothing redirected, two
|
|
25
|
+
* notes in one section could derive the same URL so a uniqueness check had to
|
|
26
|
+
* run, and a long name had to be abbreviated through a word table to keep the
|
|
27
|
+
* result short. `(type, shortcode)` is unique within a package by rule
|
|
28
|
+
* (`engine/content-lint.mjs`), so the address is **unique by construction** —
|
|
29
|
+
* there is no check to run, and no rename to survive.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
|
-
import { contentSlug } from "./content-slug.mjs";
|
|
24
32
|
// The scheme vocabulary is part of the configuration contract — a
|
|
25
33
|
// repository names its scheme in `package-build.config.yaml` — so it is
|
|
26
34
|
// declared beside the rest of that vocabulary rather than here, and this
|
|
@@ -49,22 +57,54 @@ export function sectionOf(fm) {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
52
|
-
*
|
|
60
|
+
* The single path segment a note is addressed by: `type-shortcode`.
|
|
61
|
+
*
|
|
62
|
+
* Lowercased, so it is exactly the tail of the note's canonical key
|
|
63
|
+
* (`canonicalKey` in `engine/kb-manifest.mjs` lowercases too) — which is what
|
|
64
|
+
* makes a manifest entry's `path` derivable from the key it is filed under
|
|
65
|
+
* rather than transported beside it.
|
|
66
|
+
*
|
|
67
|
+
* The hyphen is a separator and never occurs inside a segment: a shortcode is
|
|
68
|
+
* `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced by `content-lint.mjs`)
|
|
69
|
+
* and a type is a bare word. That is the same charset guarantee positional key
|
|
70
|
+
* parsing rests on, so the address and the key cannot disagree about where one
|
|
71
|
+
* ends and the other begins.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} fm - Parsed frontmatter.
|
|
74
|
+
* @returns {string} The address segment, e.g. `weapongear-dagger`.
|
|
75
|
+
* @throws {Error} When the note declares no type or no shortcode — either way
|
|
76
|
+
* it has no address, which is reported rather than papered over: inventing
|
|
77
|
+
* one would publish a page nothing can link to and record a manifest entry
|
|
78
|
+
* pointing at it.
|
|
79
|
+
*/
|
|
80
|
+
export function addressSlug(fm) {
|
|
81
|
+
const type = typeof fm?.type === "string" ? fm.type.trim() : "";
|
|
82
|
+
if (!type) {
|
|
83
|
+
throw new Error("note declares no type, so it has no address");
|
|
84
|
+
}
|
|
85
|
+
const shortcode = typeof fm?.shortcode === "string" ? fm.shortcode.trim() : "";
|
|
86
|
+
if (!shortcode) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`note declares no shortcode, so it has no address — a page is ` +
|
|
89
|
+
`addressed as "${type}-<shortcode>"`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return `${type}-${shortcode}`.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A note's address below the knowledgebase mount, e.g. `affliction-aconite/`.
|
|
53
97
|
*
|
|
54
98
|
* A `README.md` **is** its section's landing page rather than a page within it,
|
|
55
|
-
* so it addresses the section itself and has no
|
|
99
|
+
* so it addresses the section itself and has no address of its own.
|
|
56
100
|
*
|
|
57
101
|
* @param {object} fm - Parsed frontmatter.
|
|
58
|
-
* @param {string} name - The note's display name; the slug derives from it
|
|
59
|
-
* (#1278), never from the shortcode, which is identity rather than
|
|
60
|
-
* presentation.
|
|
61
102
|
* @param {boolean} isReadme - Whether the file is a `README.md`.
|
|
62
|
-
* @returns {string} The
|
|
63
|
-
* @throws {Error} When the
|
|
103
|
+
* @returns {string} The mount-relative address, with a trailing slash.
|
|
104
|
+
* @throws {Error} When the note has no address.
|
|
64
105
|
*/
|
|
65
|
-
export function contentAddress(fm,
|
|
66
|
-
|
|
67
|
-
return isReadme ? `${sec}/` : `${sec}/${contentSlug(name)}/`;
|
|
106
|
+
export function contentAddress(fm, isReadme) {
|
|
107
|
+
return isReadme ? `${sectionOf(fm)}/` : `${addressSlug(fm)}/`;
|
|
68
108
|
}
|
|
69
109
|
|
|
70
110
|
/**
|
|
@@ -92,17 +132,30 @@ function landingOf(fm, isReadme, landing) {
|
|
|
92
132
|
}
|
|
93
133
|
|
|
94
134
|
/**
|
|
95
|
-
* A note's address relative to its **package**, e.g. `
|
|
135
|
+
* A note's address relative to its **package**, e.g. `affliction-aconite/`.
|
|
96
136
|
*
|
|
97
137
|
* This is the form the link manifest records and the site build emits pages at,
|
|
98
138
|
* and it is one function because those two must agree — a manifest asserting an
|
|
99
139
|
* address the site does not publish resolves at build time and 404s for the
|
|
100
140
|
* reader, which is the failure this module exists to prevent.
|
|
101
141
|
*
|
|
142
|
+
* **The prefix does not apply to a page's own address.** `prefix` says where the
|
|
143
|
+
* content tree *mounts inside the package* — where its section directories and
|
|
144
|
+
* their landing pages live — and a landing page is addressed by that mount
|
|
145
|
+
* (`kb/rules/`). An ordinary page is addressed by `(type, shortcode)`, which is
|
|
146
|
+
* a package-wide identity and takes no mount: `sohl` publishes
|
|
147
|
+
* `/sohl/affliction-aconite/` while its section landings stay at
|
|
148
|
+
* `/sohl/kb/affliction/`. The `type-` half is what keeps that flat namespace
|
|
149
|
+
* clear of the package's fixed mounts — `/<package>/` for the landing,
|
|
150
|
+
* `/<package>/api/` for generated API docs, neither of which contains a hyphen
|
|
151
|
+
* or names a type.
|
|
152
|
+
*
|
|
153
|
+
* **The section still decides where the *file* is written**, which is why a
|
|
154
|
+
* note without one still has no address: Hugo derives a section from a page's
|
|
155
|
+
* directory rather than from its URL, so a page with nowhere to be filed is a
|
|
156
|
+
* page with no section landing, no `.CurrentSection` and no per-section layout.
|
|
157
|
+
*
|
|
102
158
|
* @param {object} fm - Parsed frontmatter.
|
|
103
|
-
* @param {string} name - The note's display name; a page slug derives from it
|
|
104
|
-
* (#1278), never from the shortcode, which is identity rather than
|
|
105
|
-
* presentation.
|
|
106
159
|
* @param {object} [options] - Options.
|
|
107
160
|
* @param {boolean} [options.isReadme] - Whether the file is a `README.md`.
|
|
108
161
|
* @param {{prefix?: string, landing?: string}} [options.scheme] - The
|
|
@@ -110,11 +163,11 @@ function landingOf(fm, isReadme, landing) {
|
|
|
110
163
|
* @returns {string} The package-relative address, with a trailing slash and no
|
|
111
164
|
* leading one.
|
|
112
165
|
* @throws {Error} When the note has no address — no section, a landing page
|
|
113
|
-
* naming no section, or
|
|
166
|
+
* naming no section, or no shortcode to be addressed by. Each is a note that
|
|
114
167
|
* is not published, and inventing an address for one would put a dead entry
|
|
115
168
|
* in the manifest.
|
|
116
169
|
*/
|
|
117
|
-
export function packageAddress(fm,
|
|
170
|
+
export function packageAddress(fm, { isReadme = false, scheme } = {}) {
|
|
118
171
|
const { prefix, landing } = { ...DEFAULT_ADDRESS_SCHEME, ...scheme };
|
|
119
172
|
if (!LANDING_RULES.includes(landing)) {
|
|
120
173
|
throw new Error(
|
|
@@ -133,5 +186,5 @@ export function packageAddress(fm, name, { isReadme = false, scheme } = {}) {
|
|
|
133
186
|
if (typeof sec !== "string" || !sec) {
|
|
134
187
|
throw new Error(`type "${fm.type}" has no section`);
|
|
135
188
|
}
|
|
136
|
-
return `${
|
|
189
|
+
return `${addressSlug(fm)}/`;
|
|
137
190
|
}
|
|
@@ -269,7 +269,7 @@ function nearest(key, candidates) {
|
|
|
269
269
|
* @type {ReadonlySet<string>}
|
|
270
270
|
*/
|
|
271
271
|
export const NOTE_LEVEL_KEYS = Object.freeze(
|
|
272
|
-
new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"
|
|
272
|
+
new Set(["id", "type", "subType", "shortcode", "description", "tags", "name"]),
|
|
273
273
|
);
|
|
274
274
|
|
|
275
275
|
/** Whether a value is a plain object a block could be written as. */
|