@heroiclands/package-build 23.7.1 → 23.8.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 +14 -0
- package/README.md +32 -0
- package/bin/content-commands.mjs +20 -6
- package/ci/check-banners.mjs +37 -19
- package/container.mjs +48 -10
- package/docs/api.md +87 -43
- package/docs/authoring/frontmatter.md +30 -1
- package/docs/authoring/links-and-markup.md +14 -0
- package/docs/commands.md +12 -2
- package/docs/content-format.md +23 -0
- package/docs/design.md +31 -0
- package/docs/guides/e2e.md +37 -1
- package/docs/project-setup.md +30 -15
- package/docs/reference/format-details.md +44 -9
- package/docs/reference/note-types.md +7 -4
- package/e2e.mjs +89 -2
- package/engine/base-compiler.mjs +11 -18
- package/engine/compile-corpus.mjs +2 -1
- package/engine/content-alerts.mjs +4 -2
- package/engine/content-blocks.mjs +18 -36
- package/engine/content-index.mjs +35 -22
- package/engine/content-poetry.mjs +9 -0
- package/engine/diagnostics.mjs +24 -11
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/generate.mjs +12 -22
- package/engine/helpers.mjs +8 -6
- package/engine/infobox.mjs +3 -0
- package/engine/journals.mjs +11 -1
- package/engine/markdownlint-run.mjs +12 -2
- package/engine/markup-validation.mjs +34 -0
- package/engine/note-addresses.mjs +1 -0
- package/engine/note-claims.mjs +19 -10
- package/engine/note-package.mjs +3 -5
- package/engine/note-vocabulary.mjs +29 -1
- package/engine/pdf-build.mjs +8 -1
- package/engine/prose-lint.mjs +12 -6
- package/engine/related-pages.mjs +22 -3
- package/engine/see-also.mjs +150 -0
- package/engine/site-build.mjs +5 -1
- package/engine/site-preview.mjs +4 -2
- package/engine/sql-tables.mjs +25 -10
- package/engine/standings.mjs +14 -12
- package/hugo-theme/layouts/partials/related.html +25 -8
- package/package.json +2 -1
- package/types/container.d.mts +4 -3
- package/types/e2e.d.mts +28 -0
- package/types/engine/base-compiler.d.mts +2 -2
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +20 -7
- package/types/engine/diagnostics.d.mts +7 -6
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/markdownlint-run.d.mts +3 -1
- package/types/engine/markup-validation.d.mts +14 -0
- package/types/engine/note-addresses.d.mts +10 -0
- package/types/engine/note-claims.d.mts +7 -1
- package/types/engine/note-package.d.mts +3 -1
- package/types/engine/note-vocabulary.d.mts +1 -0
- package/types/engine/related-pages.d.mts +10 -1
- package/types/engine/see-also.d.mts +55 -0
- package/types/engine/sql-tables.d.mts +7 -3
|
@@ -764,6 +764,38 @@ declares the key. Any other art key authored where no pass emits it, such as a
|
|
|
764
764
|
the note still compiles correctly and a site template may read there what no
|
|
765
765
|
document carries. `null` is never reported.
|
|
766
766
|
|
|
767
|
+
#### Deliberate cross-references
|
|
768
|
+
|
|
769
|
+
Every note type accepts `data.seeAlso`: an ordered list of maps with a required
|
|
770
|
+
`address` and an optional string `gloss`. The Address names a whole note,
|
|
771
|
+
defaults to the current package and the `note` system, and has no default type.
|
|
772
|
+
It may name a note in a declared dependency. A gloss is a short plain-text
|
|
773
|
+
phrase explaining the connection.
|
|
774
|
+
|
|
775
|
+
```yaml
|
|
776
|
+
data:
|
|
777
|
+
seeAlso:
|
|
778
|
+
- address: lore-harborhistory
|
|
779
|
+
gloss: The founding of the port
|
|
780
|
+
- address: place-breakwater
|
|
781
|
+
- address: thalorna-note-lore-searoutes
|
|
782
|
+
gloss: Routes beyond the bay
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
The vocabulary declares the closed entry shape. The note boundary decodes each
|
|
786
|
+
nested Address, and the frontmatter check locates unresolved, duplicate and
|
|
787
|
+
self references at `data.seeAlso.<index>.address`. Duplicate detection compares
|
|
788
|
+
resolved destinations, so alternative spellings of one target do not make two
|
|
789
|
+
entries. Anchors are not accepted.
|
|
790
|
+
|
|
791
|
+
On the website, `related.seeAlso` carries `{ title, url, type, gloss? }` entries
|
|
792
|
+
in authored order. The theme renders that group ahead of the inferred
|
|
793
|
+
backlinks and mentions, with duplicates removed by resolved URL. Deliberate
|
|
794
|
+
references can point outside the local site to declared dependency pages.
|
|
795
|
+
The field has no infobox row. Foundry journals append a See also page with
|
|
796
|
+
compendium links; books append a See also section with the book renderer's
|
|
797
|
+
normal internal or external links. An absent or empty list emits nothing.
|
|
798
|
+
|
|
767
799
|
#### The art slots
|
|
768
800
|
|
|
769
801
|
A note declares the art its document needs, and every other image in it is
|
|
@@ -821,15 +853,18 @@ are, so a key legal everywhere is stated once here rather than repeated in
|
|
|
821
853
|
twenty-five tables where the one that was mistyped would be the one nobody
|
|
822
854
|
noticed:
|
|
823
855
|
|
|
824
|
-
| shared `data` property | Values
|
|
825
|
-
| ---------------------- |
|
|
826
|
-
| `
|
|
827
|
-
| `
|
|
828
|
-
| `
|
|
829
|
-
| `
|
|
830
|
-
| `
|
|
831
|
-
| `
|
|
832
|
-
| `
|
|
856
|
+
| shared `data` property | Values | Description |
|
|
857
|
+
| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
|
|
858
|
+
| `seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, shown on the site, in journals and in books. |
|
|
859
|
+
| `seeAlso[].address` | a note `Address` | Required; the whole note to read next. |
|
|
860
|
+
| `seeAlso[].gloss` | `string` | An optional short phrase saying why to follow the reference. |
|
|
861
|
+
| `id` | `string` | A pinned document identity. |
|
|
862
|
+
| `pack` | `string` | The shared compendium route. |
|
|
863
|
+
| `packFolder` | `Address` or pack map | The shared compendium folder Address. |
|
|
864
|
+
| `harnworld` | `{ realm?, ritual? }` | HârnWorld source details. |
|
|
865
|
+
| `harnworld.realm` | `string` | The HârnWorld realm the subject belongs to. |
|
|
866
|
+
| `harnworld.ritual` | `string[]` | The HârnWorld religions the subject observes. |
|
|
867
|
+
| `banner` | `Address` | The page's hero image — an `image` address, cut to 1792×768. |
|
|
833
868
|
|
|
834
869
|
`data.harnworld` takes two keys and nothing else: `realm`, a string naming the
|
|
835
870
|
HârnWorld realm the subject belongs to, and `ritual`, a list of strings naming
|
|
@@ -34,6 +34,7 @@ These fields are accepted by every note type. A field's value can still be irrel
|
|
|
34
34
|
|
|
35
35
|
| Field | Shape | Meaning |
|
|
36
36
|
| ----------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| `data.seeAlso` | list of `{ address, gloss? }` | Deliberate cross-references in reading order, with optional glosses; shown on the site, in journals and in books. |
|
|
37
38
|
| `data.id` | string | A fixed document identity when the derived identity is unsuitable. |
|
|
38
39
|
| `data.pack` | string | The shared compendium route, with a system block overriding it. |
|
|
39
40
|
| `data.packFolder` | an Address, or a map of Addresses keyed by pack | The shared compendium folder Address, with a system block overriding it. |
|
|
@@ -42,10 +43,12 @@ These fields are accepted by every note type. A field's value can still be irrel
|
|
|
42
43
|
|
|
43
44
|
**Inner keys** — a key a field's value does not declare is an error at its own line and column.
|
|
44
45
|
|
|
45
|
-
| Key
|
|
46
|
-
|
|
|
47
|
-
| `data.
|
|
48
|
-
| `data.
|
|
46
|
+
| Key | Shape | Required | Meaning |
|
|
47
|
+
| ------------------------ | --------------- | -------- | -------------------------------------------------- |
|
|
48
|
+
| `data.seeAlso[].address` | a note Address | yes | The note to read next. |
|
|
49
|
+
| `data.seeAlso[].gloss` | a string | no | A short phrase saying why to follow the reference. |
|
|
50
|
+
| `data.harnworld.realm` | a string | no | The HârnWorld realm the subject belongs to. |
|
|
51
|
+
| `data.harnworld.ritual` | list of strings | no | The HârnWorld religions the subject observes. |
|
|
49
52
|
|
|
50
53
|
## Type-specific fields
|
|
51
54
|
|
package/e2e.mjs
CHANGED
|
@@ -43,6 +43,8 @@ import crypto from "node:crypto";
|
|
|
43
43
|
import { readdirSync, statSync } from "node:fs";
|
|
44
44
|
import fs from "node:fs/promises";
|
|
45
45
|
import path from "node:path";
|
|
46
|
+
import os from "node:os";
|
|
47
|
+
import { Browser, BrowserPlatform, computeExecutablePath, install } from "@puppeteer/browsers";
|
|
46
48
|
import process from "node:process";
|
|
47
49
|
import { spawnSync } from "node:child_process";
|
|
48
50
|
|
|
@@ -796,6 +798,91 @@ export function suiteExecutables(command) {
|
|
|
796
798
|
return [program];
|
|
797
799
|
}
|
|
798
800
|
|
|
801
|
+
/** The Chrome for Testing build shared by local and CI Cypress runs. */
|
|
802
|
+
export const CHROME_BUILD_ID = "155.0.8059.84";
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* The browser cache survives reseeding and container teardown.
|
|
806
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment overrides.
|
|
807
|
+
* @returns {string} The browser cache directory.
|
|
808
|
+
*/
|
|
809
|
+
export function browserCacheDir(env = process.env) {
|
|
810
|
+
if (env.HL_BROWSER_CACHE) return path.resolve(env.HL_BROWSER_CACHE);
|
|
811
|
+
if (env.FOUNDRYVTT_CACHE) return path.resolve(env.FOUNDRYVTT_CACHE, "..", "browser-cache");
|
|
812
|
+
if (env.HL_E2E_ROOT) return path.resolve(env.HL_E2E_ROOT, "browser-cache");
|
|
813
|
+
const cache =
|
|
814
|
+
env.XDG_CACHE_HOME ||
|
|
815
|
+
(process.platform === "darwin" ?
|
|
816
|
+
path.join(os.homedir(), "Library", "Caches")
|
|
817
|
+
: path.join(os.homedir(), ".cache"));
|
|
818
|
+
return path.join(cache, "heroiclands", "chrome-for-testing");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Supply pinned Chrome only to a recognizable Cypress command without a browser.
|
|
823
|
+
* Explicit browser arguments remain verbatim, including Electron and paths.
|
|
824
|
+
* @param {object} opts
|
|
825
|
+
* @param {readonly string[]} opts.command - The configured suite command.
|
|
826
|
+
* @param {string[]} [opts.args] - Extra suite arguments.
|
|
827
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Cache overrides.
|
|
828
|
+
* @param {string} [opts.platform] - Host operating system.
|
|
829
|
+
* @param {string} [opts.arch] - Host architecture.
|
|
830
|
+
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
831
|
+
* @returns {Promise<string[]>} Arguments to append to the suite command.
|
|
832
|
+
*/
|
|
833
|
+
export async function prepareSuiteBrowser({
|
|
834
|
+
command,
|
|
835
|
+
args = [],
|
|
836
|
+
env = process.env,
|
|
837
|
+
platform = process.platform,
|
|
838
|
+
arch = process.arch,
|
|
839
|
+
log = () => {},
|
|
840
|
+
}) {
|
|
841
|
+
const tool = suiteExecutables(command).at(-1);
|
|
842
|
+
if (!tool || executableName(tool) !== "cypress") return args;
|
|
843
|
+
const all = [...command, ...args];
|
|
844
|
+
const explicit = all.findLastIndex(
|
|
845
|
+
(arg) => arg === "--browser" || arg.startsWith("--browser="),
|
|
846
|
+
);
|
|
847
|
+
if (explicit !== -1) {
|
|
848
|
+
const selected =
|
|
849
|
+
all[explicit] === "--browser" ?
|
|
850
|
+
all[explicit + 1]
|
|
851
|
+
: all[explicit].slice("--browser=".length);
|
|
852
|
+
log(
|
|
853
|
+
`▸ Cypress browser: ${selected ?? "(missing --browser value)"} (explicit; version reported by Cypress)`,
|
|
854
|
+
);
|
|
855
|
+
return args;
|
|
856
|
+
}
|
|
857
|
+
const browserPlatform =
|
|
858
|
+
platform === "darwin" && arch === "arm64" ? BrowserPlatform.MAC_ARM
|
|
859
|
+
: platform === "linux" && arch === "x64" ? BrowserPlatform.LINUX
|
|
860
|
+
: null;
|
|
861
|
+
if (!browserPlatform)
|
|
862
|
+
throw new Error(
|
|
863
|
+
`Chrome for Testing has no configured build for ${platform}/${arch}. Pass --browser <name-or-path> to select an installed browser.`,
|
|
864
|
+
);
|
|
865
|
+
const options = {
|
|
866
|
+
browser: Browser.CHROME,
|
|
867
|
+
buildId: CHROME_BUILD_ID,
|
|
868
|
+
platform: browserPlatform,
|
|
869
|
+
cacheDir: browserCacheDir(env),
|
|
870
|
+
};
|
|
871
|
+
let executablePath = computeExecutablePath(options);
|
|
872
|
+
if (!isExecutableFile(executablePath)) {
|
|
873
|
+
try {
|
|
874
|
+
executablePath = (await install(options)).executablePath;
|
|
875
|
+
} catch (cause) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`Cannot install Chrome for Testing ${CHROME_BUILD_ID} for ${platform}/${arch} in ${options.cacheDir}. Check network access or pass --browser <name-or-path>.`,
|
|
878
|
+
{ cause },
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
log(`▸ Cypress browser: Chrome for Testing ${CHROME_BUILD_ID} (${executablePath})`);
|
|
883
|
+
return [...args, "--browser", executablePath];
|
|
884
|
+
}
|
|
885
|
+
|
|
799
886
|
/**
|
|
800
887
|
* @param {string} candidate - An absolute path.
|
|
801
888
|
* @returns {boolean} Whether it is there and is a file.
|
|
@@ -1128,7 +1215,7 @@ export async function e2eRun({
|
|
|
1128
1215
|
});
|
|
1129
1216
|
return runSuite({
|
|
1130
1217
|
command,
|
|
1131
|
-
args: suiteArgs,
|
|
1218
|
+
args: await prepareSuiteBrowser({ command, args: suiteArgs, env, log }),
|
|
1132
1219
|
cwd: config.rootDir,
|
|
1133
1220
|
// Only a headless run makes a claim worth checking. `open` hands
|
|
1134
1221
|
// the runner to a person, who decides what to execute and when to
|
|
@@ -1211,7 +1298,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
|
|
|
1211
1298
|
}
|
|
1212
1299
|
return runSuite({
|
|
1213
1300
|
command,
|
|
1214
|
-
args: suiteArgs,
|
|
1301
|
+
args: await prepareSuiteBrowser({ command, args: suiteArgs, env, log }),
|
|
1215
1302
|
cwd: config.rootDir,
|
|
1216
1303
|
results: config.e2eResults,
|
|
1217
1304
|
env,
|
package/engine/base-compiler.mjs
CHANGED
|
@@ -1254,19 +1254,18 @@ export class BasePackCompiler {
|
|
|
1254
1254
|
}
|
|
1255
1255
|
}
|
|
1256
1256
|
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
// nor the `bodyLine`/`bodyColumn` a diagnostic needs, and this pass has
|
|
1261
|
-
// to have the body anyway. So the read is one this pass makes anyway;
|
|
1262
|
-
// the index decides which files it covers.
|
|
1257
|
+
// Parsed source and body coordinates belong to the shared corpus.
|
|
1258
|
+
// A directly supplied corpus can fill missing entries on first use.
|
|
1259
|
+
this.corpus.parsed ??= new Map();
|
|
1263
1260
|
const dates = reckoningContext({ notes: this.corpus.records });
|
|
1264
1261
|
/** The note first filed under each document id in this pass. @type {Map<string, string>} */
|
|
1265
1262
|
const idOwners = new Map();
|
|
1266
1263
|
for (const record of this.corpus.records) {
|
|
1267
1264
|
if (!isNoteRecord(record)) continue;
|
|
1268
1265
|
const absPath = noteFile(this.contentBase, record);
|
|
1269
|
-
const
|
|
1266
|
+
const parsed = this.corpus.parsed.get(absPath) ?? parseMarkdownFile(absPath);
|
|
1267
|
+
this.corpus.parsed.set(absPath, parsed);
|
|
1268
|
+
const { body, bodyLine, bodyColumn, raw } = parsed;
|
|
1270
1269
|
const fm = cloneAddressState(authoredFrontmatter(record));
|
|
1271
1270
|
// Which note this pass is on, so anything it calls can report a
|
|
1272
1271
|
// position without every method having to be handed one.
|
|
@@ -1284,7 +1283,7 @@ export class BasePackCompiler {
|
|
|
1284
1283
|
// comes from the diagnostic locator, so the message must not
|
|
1285
1284
|
// repeat it.
|
|
1286
1285
|
try {
|
|
1287
|
-
assertNoDeclaredPackage(fm, { absPath });
|
|
1286
|
+
assertNoDeclaredPackage(fm, { absPath, raw });
|
|
1288
1287
|
} catch (err) {
|
|
1289
1288
|
stats.declined++;
|
|
1290
1289
|
this.errorCount++;
|
|
@@ -1375,7 +1374,7 @@ export class BasePackCompiler {
|
|
|
1375
1374
|
`${path.relative(process.cwd(), owner)}. Give one of the two notes a ` +
|
|
1376
1375
|
`different \`id\` under \`data:\`, or remove it to take the id derived ` +
|
|
1377
1376
|
`from its address`,
|
|
1378
|
-
locateFrontmatterKey(absPath, "id"),
|
|
1377
|
+
locateFrontmatterKey(absPath, "id", undefined, { raw }),
|
|
1379
1378
|
);
|
|
1380
1379
|
continue;
|
|
1381
1380
|
}
|
|
@@ -1398,15 +1397,9 @@ export class BasePackCompiler {
|
|
|
1398
1397
|
);
|
|
1399
1398
|
const position =
|
|
1400
1399
|
err.position ??
|
|
1401
|
-
(err.keyPath ?
|
|
1402
|
-
|
|
1403
|
-
:
|
|
1404
|
-
positionOfLiteral(
|
|
1405
|
-
fs.readFileSync(absPath, "utf8"),
|
|
1406
|
-
`{#${repeatedAnchor[1]}}`,
|
|
1407
|
-
2,
|
|
1408
|
-
)
|
|
1409
|
-
: undefined);
|
|
1400
|
+
(err.keyPath ? positionOfFrontmatterPath(raw, err.keyPath)
|
|
1401
|
+
: repeatedAnchor ? positionOfLiteral(raw, `{#${repeatedAnchor[1]}}`, 2)
|
|
1402
|
+
: undefined);
|
|
1410
1403
|
// `position` is set by whatever failed if it knew where — an
|
|
1411
1404
|
// unresolved address, a bad table directive — so the report
|
|
1412
1405
|
// points at the line rather than at the note.
|
|
@@ -60,7 +60,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
|
60
60
|
* created when none is passed, and returned on the corpus either way — a note
|
|
61
61
|
* the index refuses is a note the compile must still *report*, exactly as the
|
|
62
62
|
* compile loop reported it when the loop was the first to see it.
|
|
63
|
-
* @returns {Promise<{records: object[], linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
|
|
63
|
+
* @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[]}>} The corpus,
|
|
64
64
|
* its indexes, and the notes it could not record.
|
|
65
65
|
*/
|
|
66
66
|
export async function buildCompileCorpus({
|
|
@@ -86,6 +86,7 @@ export async function buildCompileCorpus({
|
|
|
86
86
|
const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
|
|
87
87
|
return {
|
|
88
88
|
records,
|
|
89
|
+
parsed,
|
|
89
90
|
problems: collected,
|
|
90
91
|
linkIndex: buildContentLinkIndex(contentBase, router, corpusOptions),
|
|
91
92
|
sqlTables: await prepareTreeSqlTables(contentBase, sqlOptions),
|
|
@@ -48,7 +48,7 @@ export const ALERT_TYPES = Object.freeze({
|
|
|
48
48
|
),
|
|
49
49
|
},
|
|
50
50
|
});
|
|
51
|
-
const parser = new MarkdownIt();
|
|
51
|
+
const parser = new MarkdownIt({ html: true });
|
|
52
52
|
const titleParser = new MarkdownIt({ html: false });
|
|
53
53
|
const marker = /^ {0,3}>[ \t]?\[!([^\]\n]+)\](.*)$/;
|
|
54
54
|
/** Read alerts outside code examples, retaining inclusive source line ranges.
|
|
@@ -85,7 +85,9 @@ export function scanAlerts(source) {
|
|
|
85
85
|
if (match[1] !== match[1].toUpperCase() || !Object.hasOwn(ALERT_TYPES, type)) {
|
|
86
86
|
errors.push({
|
|
87
87
|
...at,
|
|
88
|
-
message: `unknown alert type ${match[1]}; use
|
|
88
|
+
message: `unknown alert type ${match[1]}; use ${Object.keys(ALERT_TYPES)
|
|
89
|
+
.map((name) => name.toUpperCase())
|
|
90
|
+
.join(", ")}`,
|
|
89
91
|
});
|
|
90
92
|
continue;
|
|
91
93
|
}
|
|
@@ -62,6 +62,7 @@ import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-at
|
|
|
62
62
|
* and a stray `<section>` would close the block early.
|
|
63
63
|
*/
|
|
64
64
|
const titleParser = new MarkdownIt({ html: false });
|
|
65
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
65
66
|
|
|
66
67
|
/** The blocks an author may open, with the heading each takes by default. */
|
|
67
68
|
export const BLOCK_NAMES = Object.freeze({
|
|
@@ -99,28 +100,24 @@ function escapeAttribute(value) {
|
|
|
99
100
|
* @returns {{blocks: Array<{name: string, title: string, id: string, classes: string[], attributes: Record<string, string>, start: number, end: number, body: string}>, errors: Array<{line: number, column: number, message: string}>}}
|
|
100
101
|
*/
|
|
101
102
|
export function scanBlocks(source) {
|
|
102
|
-
const lines = String(source ?? "").split(
|
|
103
|
+
const lines = String(source ?? "").split(/\r?\n/),
|
|
103
104
|
blocks = [],
|
|
104
105
|
errors = [];
|
|
105
106
|
const stack = [];
|
|
106
|
-
|
|
107
|
+
// A bare fence can belong to a quote through lazy continuation.
|
|
108
|
+
// Parser ranges decide whether it can delimit a container.
|
|
109
|
+
const literalLines = new Set();
|
|
110
|
+
for (const token of structureParser.parse(String(source ?? ""), {})) {
|
|
111
|
+
if (
|
|
112
|
+
!token.map ||
|
|
113
|
+
!["blockquote_open", "fence", "code_block", "html_block"].includes(token.type)
|
|
114
|
+
)
|
|
115
|
+
continue;
|
|
116
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
117
|
+
}
|
|
107
118
|
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
if (literalLines.has(i)) continue;
|
|
108
120
|
const line = lines[i];
|
|
109
|
-
const code = /^ *(`{3,}|~{3,})(.*)$/.exec(line);
|
|
110
|
-
if (fence) {
|
|
111
|
-
if (
|
|
112
|
-
code &&
|
|
113
|
-
code[1][0] === fence[0] &&
|
|
114
|
-
code[1].length >= fence.length &&
|
|
115
|
-
!code[2].trim()
|
|
116
|
-
)
|
|
117
|
-
fence = null;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (code) {
|
|
121
|
-
fence = code[1];
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
121
|
const close = /^ *:::\s*$/.test(line);
|
|
125
122
|
const named = /^ *:::([A-Za-z][A-Za-z0-9-]*)(?:\s+(.*))?$/.exec(line);
|
|
126
123
|
const div = /^ *:::\s+(\{.*)$/.exec(line);
|
|
@@ -135,7 +132,7 @@ export function scanBlocks(source) {
|
|
|
135
132
|
if (!block.body)
|
|
136
133
|
errors.push({
|
|
137
134
|
line: block.start + 1,
|
|
138
|
-
column: 1,
|
|
135
|
+
column: lines[block.start].indexOf(":::") + 1,
|
|
139
136
|
message: `${block.name} block is empty`,
|
|
140
137
|
});
|
|
141
138
|
if (!stack.length) blocks.push(block);
|
|
@@ -144,7 +141,7 @@ export function scanBlocks(source) {
|
|
|
144
141
|
if (!close && !named && !div) continue;
|
|
145
142
|
const name = named?.[1] ?? "div",
|
|
146
143
|
raw = named?.[2] ?? div?.[1] ?? "";
|
|
147
|
-
const at = { line: i + 1, column: 1 };
|
|
144
|
+
const at = { line: i + 1, column: line.indexOf(":::") + 1 };
|
|
148
145
|
if (name !== "div" && !Object.hasOwn(BLOCK_NAMES, name)) {
|
|
149
146
|
errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
|
|
150
147
|
stack.push({ start: i, rejected: true });
|
|
@@ -189,27 +186,12 @@ export function scanBlocks(source) {
|
|
|
189
186
|
if (!block.rejected)
|
|
190
187
|
errors.push({
|
|
191
188
|
line: block.start + 1,
|
|
192
|
-
column: 1,
|
|
189
|
+
column: lines[block.start].indexOf(":::") + 1,
|
|
193
190
|
message: `${block.name} block needs a closing ::: line`,
|
|
194
191
|
});
|
|
195
192
|
for (const block of blocks) {
|
|
196
|
-
let literal = null;
|
|
197
193
|
for (let i = block.start + 1; i < block.end; i++) {
|
|
198
|
-
|
|
199
|
-
if (literal) {
|
|
200
|
-
if (
|
|
201
|
-
marker &&
|
|
202
|
-
marker[1][0] === literal[0] &&
|
|
203
|
-
marker[1].length >= literal.length &&
|
|
204
|
-
!marker[2].trim()
|
|
205
|
-
)
|
|
206
|
-
literal = null;
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (marker) {
|
|
210
|
-
literal = marker[1];
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
194
|
+
if (literalLines.has(i)) continue;
|
|
213
195
|
if (parseHeadingLine(lines[i])?.startsPage)
|
|
214
196
|
errors.push({
|
|
215
197
|
line: i + 1,
|
package/engine/content-index.mjs
CHANGED
|
@@ -410,23 +410,25 @@ function foundryBlock(entry, system) {
|
|
|
410
410
|
* @param {string} [absPath] - The file, read only on the failing path to locate
|
|
411
411
|
* the offending key.
|
|
412
412
|
* @param {string} [contentPackage] - The package this tree compiles as.
|
|
413
|
+
* @param {string} [raw] - Source text already read by the caller.
|
|
413
414
|
* @returns {void}
|
|
414
415
|
* @throws {Error} When the note authors a derived key. `file` and `position`
|
|
415
416
|
* ride on the error.
|
|
416
417
|
*/
|
|
417
|
-
function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
418
|
+
function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw) {
|
|
418
419
|
for (const key of DERIVED_KEYS) {
|
|
419
420
|
if (!Object.hasOwn(frontmatter ?? {}, key)) continue;
|
|
420
421
|
if (key === "package") {
|
|
421
422
|
// Throws with its own wording, and its own position.
|
|
422
|
-
assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage });
|
|
423
|
+
assertNoDeclaredPackage(frontmatter, { absPath, configured: contentPackage, raw });
|
|
423
424
|
}
|
|
424
425
|
const err = new Error(
|
|
425
426
|
`\`${key}:\` is derived by the content index and cannot be ` +
|
|
426
427
|
`authored — rename the frontmatter field`,
|
|
427
428
|
);
|
|
428
429
|
err.file = relPath;
|
|
429
|
-
const position =
|
|
430
|
+
const position =
|
|
431
|
+
absPath ? locateFrontmatterKey(absPath, key, undefined, { raw }) : undefined;
|
|
430
432
|
if (position) err.position = position;
|
|
431
433
|
throw err;
|
|
432
434
|
}
|
|
@@ -445,6 +447,7 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
|
|
|
445
447
|
* @param {string} options.relPath - Its path below the content root, POSIX-separated.
|
|
446
448
|
* @param {string} [options.absPath] - The file, read only on the failing path to
|
|
447
449
|
* locate the offending key.
|
|
450
|
+
* @param {string} [options.raw] - Source text already read by the caller.
|
|
448
451
|
* @param {string} options.contentPackage - The package the tree compiles as.
|
|
449
452
|
* @param {string} [options.body] - The note's markdown body, for its anchors
|
|
450
453
|
* and for the one question that decides whether the note publishes a page. A
|
|
@@ -473,6 +476,7 @@ export function buildIndexRecord({
|
|
|
473
476
|
frontmatter,
|
|
474
477
|
relPath,
|
|
475
478
|
absPath,
|
|
479
|
+
raw,
|
|
476
480
|
contentPackage,
|
|
477
481
|
body,
|
|
478
482
|
bodyLine,
|
|
@@ -481,7 +485,7 @@ export function buildIndexRecord({
|
|
|
481
485
|
dateContext,
|
|
482
486
|
resolveRole,
|
|
483
487
|
}) {
|
|
484
|
-
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage);
|
|
488
|
+
assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage, raw);
|
|
485
489
|
|
|
486
490
|
const posix = relPath.split(path.sep).join("/");
|
|
487
491
|
const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
|
|
@@ -490,9 +494,7 @@ export function buildIndexRecord({
|
|
|
490
494
|
} catch (error) {
|
|
491
495
|
error.file = absPath ?? relPath;
|
|
492
496
|
if (absPath && error.keyPath) {
|
|
493
|
-
const yamlText =
|
|
494
|
-
.readFileSync(absPath, "utf8")
|
|
495
|
-
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
497
|
+
const yamlText = (raw ?? rawNote(absPath)).match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
496
498
|
const locate = (/** @type {any} */ found) => {
|
|
497
499
|
const position = positionOfYamlPath(yamlText, found.keyPath, {
|
|
498
500
|
key: found.addressKey,
|
|
@@ -540,7 +542,7 @@ export function buildIndexRecord({
|
|
|
540
542
|
stub ? null : (
|
|
541
543
|
[
|
|
542
544
|
...collectAnchors(body, bodyLine, resolveRole),
|
|
543
|
-
...eventAnchors(frontmatter, rawNote(absPath)),
|
|
545
|
+
...eventAnchors(frontmatter, raw ?? rawNote(absPath)),
|
|
544
546
|
]
|
|
545
547
|
.sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
|
|
546
548
|
.map((a) => ({
|
|
@@ -659,6 +661,7 @@ export function indexRecordsForNote({
|
|
|
659
661
|
bodyLine,
|
|
660
662
|
relPath,
|
|
661
663
|
absPath,
|
|
664
|
+
raw,
|
|
662
665
|
contentPackage,
|
|
663
666
|
manifest,
|
|
664
667
|
addressContext,
|
|
@@ -672,6 +675,7 @@ export function indexRecordsForNote({
|
|
|
672
675
|
bodyLine,
|
|
673
676
|
relPath,
|
|
674
677
|
absPath,
|
|
678
|
+
raw,
|
|
675
679
|
contentPackage,
|
|
676
680
|
manifest,
|
|
677
681
|
addressContext,
|
|
@@ -783,10 +787,14 @@ function cultureTuple(value) {
|
|
|
783
787
|
* immediate parent whose package is not this one.
|
|
784
788
|
* @param {object[]} [options.problems] - Receives each conflict as a finding.
|
|
785
789
|
* Omitted, every conflict is collected and one Error lists them all.
|
|
790
|
+
* @param {Map<string, {raw: string}>} [options.parsed] - Notes already read, keyed by file.
|
|
786
791
|
* @returns {Array<Record<string, any>>} The same records.
|
|
787
792
|
* @throws {Error} When `problems` is omitted and any note's sources disagree.
|
|
788
793
|
*/
|
|
789
|
-
export function resolveCultures(
|
|
794
|
+
export function resolveCultures(
|
|
795
|
+
records,
|
|
796
|
+
{ contentPackage, contentBase, foreignRecord, problems, parsed },
|
|
797
|
+
) {
|
|
790
798
|
// Each note under every key a link may name it by: its record's canonical
|
|
791
799
|
// address, and its page address in the `note` system, which is what a
|
|
792
800
|
// `data.parents`, `data.seat` or `data.government` link names.
|
|
@@ -879,7 +887,7 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
|
|
|
879
887
|
*/
|
|
880
888
|
const conflict = (record, keyPath, subject, distinct) => {
|
|
881
889
|
const file = contentBase ? noteFile(contentBase, record) : record.file?.path;
|
|
882
|
-
const raw =
|
|
890
|
+
const raw = parsed?.get(file)?.raw ?? (contentBase ? rawNote(file) : undefined);
|
|
883
891
|
let position = positionOfFrontmatterPath(raw, keyPath, { key: true });
|
|
884
892
|
if (position.line === undefined && keyPath.length > 1)
|
|
885
893
|
position = positionOfFrontmatterPath(raw, ["data"], { key: true });
|
|
@@ -1026,9 +1034,9 @@ export function resolveCultures(records, { contentPackage, contentBase, foreignR
|
|
|
1026
1034
|
* @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
|
|
1027
1035
|
* Another package's entry for a canonical address, read for a parent that
|
|
1028
1036
|
* package publishes; see {@link resolveCultures}.
|
|
1029
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [options.parsed] -
|
|
1030
|
-
* Filled with each note's body and
|
|
1031
|
-
*
|
|
1037
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [options.parsed] -
|
|
1038
|
+
* Filled with each note's source text, body coordinates and frontmatter as
|
|
1039
|
+
* authored, keyed by file, for reuse throughout the command.
|
|
1032
1040
|
* @returns {Array<Record<string, any>>} The records, in a total order that does
|
|
1033
1041
|
* not depend on directory-read order.
|
|
1034
1042
|
*/
|
|
@@ -1042,7 +1050,7 @@ export function collectContentIndex(
|
|
|
1042
1050
|
problems,
|
|
1043
1051
|
addressContext,
|
|
1044
1052
|
foreignRecord,
|
|
1045
|
-
parsed,
|
|
1053
|
+
parsed = new Map(),
|
|
1046
1054
|
},
|
|
1047
1055
|
) {
|
|
1048
1056
|
const records = [];
|
|
@@ -1115,10 +1123,16 @@ export function collectContentIndex(
|
|
|
1115
1123
|
|
|
1116
1124
|
// Each recorded note with its body, for the words its prose cites.
|
|
1117
1125
|
const bodies = [];
|
|
1118
|
-
for (const { frontmatter, body, bodyLine, absPath } of parsedNotes) {
|
|
1126
|
+
for (const { frontmatter, body, bodyLine, bodyColumn, raw, absPath } of parsedNotes) {
|
|
1119
1127
|
// Before the index derives anything onto the frontmatter, so a later
|
|
1120
1128
|
// pass reads the note as authored.
|
|
1121
|
-
parsed
|
|
1129
|
+
parsed.set(absPath, {
|
|
1130
|
+
body,
|
|
1131
|
+
bodyLine,
|
|
1132
|
+
bodyColumn,
|
|
1133
|
+
raw,
|
|
1134
|
+
frontmatter: structuredClone(frontmatter),
|
|
1135
|
+
});
|
|
1122
1136
|
const fm = frontmatter ?? {};
|
|
1123
1137
|
applyComputedBeingAge(fm, present, dates);
|
|
1124
1138
|
const relPath = path.relative(contentBase, absPath);
|
|
@@ -1127,6 +1141,7 @@ export function collectContentIndex(
|
|
|
1127
1141
|
frontmatter: fm,
|
|
1128
1142
|
relPath,
|
|
1129
1143
|
absPath,
|
|
1144
|
+
raw,
|
|
1130
1145
|
contentPackage,
|
|
1131
1146
|
body,
|
|
1132
1147
|
bodyLine,
|
|
@@ -1142,9 +1157,7 @@ export function collectContentIndex(
|
|
|
1142
1157
|
err.identity = { type: fm.type, shortcode: fm.shortcode };
|
|
1143
1158
|
err.keyPath = ["shortcode"];
|
|
1144
1159
|
err.message = `shortcode "${fm.shortcode}" is not strictly alphanumeric — lowercase letters and digits only`;
|
|
1145
|
-
const yamlText =
|
|
1146
|
-
.readFileSync(absPath, "utf8")
|
|
1147
|
-
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
1160
|
+
const yamlText = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
1148
1161
|
err.position = positionOfYamlPath(yamlText, err.keyPath);
|
|
1149
1162
|
if (err.position.line) err.position.line++;
|
|
1150
1163
|
}
|
|
@@ -1186,13 +1199,13 @@ export function collectContentIndex(
|
|
|
1186
1199
|
// Every note record exists and every Address in it is a tuple, so a parent
|
|
1187
1200
|
// is found by its canonical address. Before the sort, which reads nothing
|
|
1188
1201
|
// the pass writes.
|
|
1189
|
-
resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
|
|
1202
|
+
resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
|
|
1190
1203
|
const cultureNotes = records
|
|
1191
1204
|
.filter((record) => isNoteRecord(record) && record.type === "lore")
|
|
1192
1205
|
.map((record) => ({
|
|
1193
1206
|
fm: record,
|
|
1194
1207
|
file: noteFile(contentBase, record),
|
|
1195
|
-
raw:
|
|
1208
|
+
raw: parsed.get(noteFile(contentBase, record)).raw,
|
|
1196
1209
|
}));
|
|
1197
1210
|
const cultureByKey = new Map(
|
|
1198
1211
|
cultureNotes.map((note) => [
|
|
@@ -1352,7 +1365,7 @@ function dependencyAddresses(config) {
|
|
|
1352
1365
|
* aborting the derivation. Omitted, the note throws — which is the contract
|
|
1353
1366
|
* the emitter needs, since an index missing a note asserts that it does not
|
|
1354
1367
|
* exist.
|
|
1355
|
-
* @param {Map<string, {body: string, frontmatter: object|null}>} [opts.parsed] -
|
|
1368
|
+
* @param {Map<string, {body: string, frontmatter: object|null, raw: string, bodyLine?: number, bodyColumn?: number}>} [opts.parsed] -
|
|
1356
1369
|
* Filled with each note's body and authored frontmatter, keyed by the note's
|
|
1357
1370
|
* file. Handed to {@link module:engine/sql-tables.prepareTreeSqlTables}, it
|
|
1358
1371
|
* spares that pass reading and parsing every note again.
|
|
@@ -71,9 +71,12 @@ export function poetryMarkdown(body) {
|
|
|
71
71
|
.join("\n\n");
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
import MarkdownIt from "markdown-it";
|
|
74
75
|
import { slugify } from "./content-slug.mjs";
|
|
75
76
|
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
76
77
|
|
|
78
|
+
const structureParser = new MarkdownIt({ html: true });
|
|
79
|
+
|
|
77
80
|
/** Read poetry code fences; start/end are inclusive zero-based source lines.
|
|
78
81
|
* @param {string} source - Authored Markdown.
|
|
79
82
|
*/
|
|
@@ -81,8 +84,14 @@ export function scanPoetry(source) {
|
|
|
81
84
|
const lines = String(source ?? "").split("\n"),
|
|
82
85
|
blocks = [],
|
|
83
86
|
errors = [];
|
|
87
|
+
const literalLines = new Set();
|
|
88
|
+
for (const token of structureParser.parse(String(source ?? ""), {})) {
|
|
89
|
+
if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
|
|
90
|
+
for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
|
|
91
|
+
}
|
|
84
92
|
let opening = null;
|
|
85
93
|
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (literalLines.has(i)) continue;
|
|
86
95
|
const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
|
|
87
96
|
if (opening) {
|
|
88
97
|
if (
|