@heroiclands/package-build 24.0.0 → 24.0.2
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 +28 -0
- package/assets/preview/gitea.ini +1 -1
- package/assets/styles/content-base.css +33 -0
- package/bin/content-commands.mjs +9 -7
- package/docs/api.md +6 -5
- package/docs/authoring/links-and-markup.md +164 -16
- package/docs/commands.md +23 -19
- package/docs/configuration.md +16 -0
- package/docs/content-format.md +4 -2
- package/docs/guides/site.md +43 -0
- package/docs/guides/writing-in-invented-scripts.md +68 -6
- package/docs/reference/format-details.md +143 -49
- package/docs/reference/note-types.md +47 -28
- package/docs/source-preview.md +2 -2
- package/engine/content-blocks.mjs +6 -2
- package/engine/content-format.yaml +2 -0
- package/engine/content-index.mjs +5 -1
- package/engine/content-render.mjs +2 -1
- package/engine/content-scripts.mjs +268 -21
- package/engine/content-scroll.mjs +389 -0
- package/engine/font-outlines.mjs +394 -0
- package/engine/language-data.mjs +154 -63
- package/engine/language-rows.mjs +32 -6
- package/engine/language-terms.mjs +32 -8
- package/engine/map-lexicon.mjs +31 -6
- package/engine/note-vocabulary.mjs +58 -6
- package/engine/pdf-fonts.mjs +1 -1
- package/engine/pdf-render.mjs +52 -11
- package/engine/run-rows.mjs +45 -3
- package/engine/script-direction.mjs +67 -0
- package/engine/script-emphasis-terms.mjs +11 -3
- package/engine/script-font-delivery.mjs +42 -9
- package/engine/script-fonts.mjs +7 -0
- package/engine/site-build.mjs +8 -1
- package/engine/site-reach.mjs +213 -0
- package/engine/site-render.mjs +120 -45
- package/engine/site-root.mjs +68 -26
- package/engine/site-serve.mjs +2 -1
- package/engine/site-theme.mjs +19 -6
- package/engine/sql-tables.mjs +9 -6
- package/engine/verse-metre.mjs +1 -0
- package/engine/written-in.mjs +208 -0
- package/package.json +1 -1
- package/site-theme/static/css/style.css +7 -0
- package/types/engine/content-scripts.d.mts +31 -6
- package/types/engine/content-scroll.d.mts +179 -0
- package/types/engine/font-outlines.d.mts +67 -0
- package/types/engine/language-data.d.mts +13 -0
- package/types/engine/language-rows.d.mts +4 -3
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/map-lexicon.d.mts +4 -3
- package/types/engine/pdf-fonts.d.mts +10 -0
- package/types/engine/run-rows.d.mts +28 -6
- package/types/engine/script-direction.d.mts +10 -0
- package/types/engine/script-emphasis-terms.d.mts +10 -3
- package/types/engine/script-font-delivery.d.mts +5 -3
- package/types/engine/script-fonts.d.mts +9 -0
- package/types/engine/site-reach.d.mts +76 -0
- package/types/engine/site-render.d.mts +4 -2
- package/types/engine/site-root.d.mts +2 -0
- package/types/engine/written-in.d.mts +76 -0
package/engine/site-root.mjs
CHANGED
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
import fs from "node:fs";
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import * as pagefind from "pagefind";
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
import { feedPaths, reachablePages } from "./site-reach.mjs";
|
|
55
56
|
|
|
56
57
|
import { formatDiagnostic } from "./diagnostics.mjs";
|
|
57
58
|
|
|
@@ -149,11 +150,40 @@ export function headers({ pkg, base, fonts = false } = {}) {
|
|
|
149
150
|
* binary.
|
|
150
151
|
*
|
|
151
152
|
* @callback SiteIndexer
|
|
152
|
-
* @param {{site: string, output: string}} where - The rendered
|
|
153
|
-
* directory inside it the index is written to
|
|
153
|
+
* @param {{site: string, output: string, base?: string}} where - The rendered
|
|
154
|
+
* site, the directory inside it the index is written to, and the path the
|
|
155
|
+
* site is served at. With a `base`, only the pages reachable from the
|
|
156
|
+
* homepage are indexed.
|
|
154
157
|
* @returns {Promise<{pages: number}>} How many pages were indexed.
|
|
155
158
|
*/
|
|
156
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The addresses of the pages reachable from a rendered site's homepage.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} site - The rendered site's directory, served at `base`.
|
|
164
|
+
* @param {string} base - The path the site is served at.
|
|
165
|
+
* @returns {Set<string>} Page addresses, each beginning with `base`.
|
|
166
|
+
*/
|
|
167
|
+
function reachableFromDisk(site, base) {
|
|
168
|
+
const file = (url) => path.join(site, ...url.slice(base.length).split("/").filter(Boolean));
|
|
169
|
+
const read = (target) => {
|
|
170
|
+
try {
|
|
171
|
+
return fs.readFileSync(target, "utf8");
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return reachablePages({
|
|
177
|
+
base,
|
|
178
|
+
html: (url) => read(path.join(file(url), "index.html")),
|
|
179
|
+
expand: (target) => {
|
|
180
|
+
if (!target.endsWith(".xml")) return [];
|
|
181
|
+
const xml = read(file(target));
|
|
182
|
+
return xml === undefined ? [] : feedPaths(xml);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
/**
|
|
158
188
|
* Build the search index of a rendered site with Pagefind.
|
|
159
189
|
*
|
|
@@ -169,12 +199,17 @@ export function headers({ pkg, base, fonts = false } = {}) {
|
|
|
169
199
|
* - The binary missing is reported by the package as an install failure of
|
|
170
200
|
* its own; it is rethrown naming the platform package to install.
|
|
171
201
|
*
|
|
202
|
+
* With a `base`, the index covers the pages reachable from the homepage by
|
|
203
|
+
* the links the rendered HTML carries, and no others: an unlinked page is
|
|
204
|
+
* served but not searchable. Each reachable page is handed to Pagefind as its
|
|
205
|
+
* own file, so a page outside the set is never read.
|
|
206
|
+
*
|
|
172
207
|
* The output directory is emptied first, so an index of an earlier build's
|
|
173
208
|
* pages never survives beside the current one.
|
|
174
209
|
*
|
|
175
210
|
* @type {SiteIndexer}
|
|
176
211
|
*/
|
|
177
|
-
export async function indexSite({ site, output }) {
|
|
212
|
+
export async function indexSite({ site, output, base }) {
|
|
178
213
|
if (!fs.statSync(site, { throwIfNoEntry: false })?.isDirectory()) {
|
|
179
214
|
throw new Error(`${site} is not a directory, so there is nothing to index`);
|
|
180
215
|
}
|
|
@@ -201,33 +236,40 @@ export async function indexSite({ site, output }) {
|
|
|
201
236
|
}
|
|
202
237
|
let pages;
|
|
203
238
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
239
|
+
if (base) {
|
|
240
|
+
pages = 0;
|
|
241
|
+
for (const url of reachableFromDisk(site, base)) {
|
|
242
|
+
const sourcePath = path.posix.join(
|
|
243
|
+
...url.slice(base.length).split("/"),
|
|
244
|
+
"index.html",
|
|
245
|
+
);
|
|
246
|
+
const content = fs.readFileSync(path.join(site, sourcePath), "utf8");
|
|
247
|
+
const added = await index.addHTMLFile({ sourcePath, content });
|
|
248
|
+
if (added.errors.length) throw new Error(added.errors.join("; "));
|
|
249
|
+
pages += 1;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const added = await index.addDirectory({ path: site });
|
|
253
|
+
if (added.errors.length) throw new Error(added.errors.join("; "));
|
|
254
|
+
pages = added.page_count;
|
|
255
|
+
}
|
|
207
256
|
if (!pages) {
|
|
208
257
|
throw new Error(`no page under ${site} was indexed — the site holds no HTML`);
|
|
209
258
|
}
|
|
210
|
-
|
|
211
|
-
|
|
259
|
+
// The files are taken from the index in memory and written here,
|
|
260
|
+
// because `writeFiles` can answer while the binary is still flushing
|
|
261
|
+
// them, which leaves an index that a reader loads half-written.
|
|
262
|
+
const { files, errors } = await index.getFiles();
|
|
263
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
264
|
+
if (!files.length) throw new Error("the search index holds no files");
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
const target = path.join(output, file.path);
|
|
267
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
268
|
+
fs.writeFileSync(target, file.content);
|
|
269
|
+
}
|
|
212
270
|
} finally {
|
|
213
271
|
await pagefind.close();
|
|
214
272
|
}
|
|
215
|
-
// Pagefind may finish its API call while fragment writes are still visible
|
|
216
|
-
// as incomplete gzip streams under heavy parallel load. A successful site
|
|
217
|
-
// build must hand deployment complete fragments.
|
|
218
|
-
const fragmentDir = path.join(output, "fragment");
|
|
219
|
-
const deadline = Date.now() + 5000;
|
|
220
|
-
for (;;) {
|
|
221
|
-
try {
|
|
222
|
-
const fragments = fs.readdirSync(fragmentDir);
|
|
223
|
-
if (!fragments.length) throw new Error("no search fragments were written");
|
|
224
|
-
for (const name of fragments) gunzipSync(fs.readFileSync(path.join(fragmentDir, name)));
|
|
225
|
-
break;
|
|
226
|
-
} catch (error) {
|
|
227
|
-
if (Date.now() >= deadline) throw error;
|
|
228
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
273
|
return { pages };
|
|
232
274
|
}
|
|
233
275
|
|
|
@@ -289,7 +331,7 @@ export async function writeSiteRoot({ pkg, base, out, search = true, indexer = i
|
|
|
289
331
|
}
|
|
290
332
|
let pages;
|
|
291
333
|
try {
|
|
292
|
-
({ pages } = await indexer({ site: rendered, output: dir }));
|
|
334
|
+
({ pages } = await indexer({ site: rendered, output: dir, base: prefix }));
|
|
293
335
|
} catch (err) {
|
|
294
336
|
const message = err instanceof Error ? err.message : String(err);
|
|
295
337
|
// A directory, so the locator is the file field alone: there is no
|
package/engine/site-serve.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import path from "node:path";
|
|
|
45
45
|
import { siteBuildFailed } from "./site-build.mjs";
|
|
46
46
|
import { SITE_THEME_DIR } from "./site-theme.mjs";
|
|
47
47
|
import { SITE_STATIC_DIR, buildPackageSite, writeSite, writeThemeFiles } from "./site-render.mjs";
|
|
48
|
+
import { siteBase } from "../content-config.mjs";
|
|
48
49
|
import { SEARCH_DIR, indexSite } from "./site-root.mjs";
|
|
49
50
|
|
|
50
51
|
/** The address a served page listens on for the server's reloads. */
|
|
@@ -165,7 +166,7 @@ export async function serveSite({
|
|
|
165
166
|
|
|
166
167
|
const indexForSearch = async () => {
|
|
167
168
|
if (!search || config.site.search === false || !state) return;
|
|
168
|
-
await indexSite({ site: out, output: path.join(out, SEARCH_DIR) });
|
|
169
|
+
await indexSite({ site: out, output: path.join(out, SEARCH_DIR), base: siteBase(config) });
|
|
169
170
|
};
|
|
170
171
|
|
|
171
172
|
await buildAll();
|
package/engine/site-theme.mjs
CHANGED
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
* `draftNotice`, `scriptFonts`), its `menu`, and `pages`, every regular page
|
|
38
38
|
* as `{url, title, section, type, subType, date, content}`. `tagPages` says the site
|
|
39
39
|
* writes a page for each tag, and `pageExists` answers whether a path inside
|
|
40
|
-
* the site is published. `
|
|
40
|
+
* the site is published. `visible`, where the site sets it, answers whether a
|
|
41
|
+
* generated link may point at a URL; the Related card, the previous and next
|
|
42
|
+
* links and the infobox links leave out, or set as plain text, what it refuses. `feed` is the site's Atom feed as `{title, url,
|
|
41
43
|
* section}`, `section` being the one its posts are listed in, and
|
|
42
44
|
* `mergeIndexes` the search bundles of other sites its search reads beside
|
|
43
45
|
* its own.
|
|
@@ -453,11 +455,14 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
|
|
|
453
455
|
return { bannerUrl: url, title, tagline };
|
|
454
456
|
};
|
|
455
457
|
|
|
456
|
-
/**
|
|
458
|
+
/** Whether a generated link may point at a URL: any, where the site sets no `visible`. */
|
|
459
|
+
const visible = (url) => (typeof site.visible === "function" ? site.visible(url) : true);
|
|
460
|
+
|
|
461
|
+
/** One infobox entry; one that leads outside the reachable pages is its text alone. */
|
|
457
462
|
const entryOf = (entry) => ({
|
|
458
463
|
lead: text(entry?.lead),
|
|
459
464
|
text: text(entry?.text),
|
|
460
|
-
url: entry?.url ? safeUrl(entry.url) : "",
|
|
465
|
+
url: entry?.url && visible(entry.url) ? safeUrl(entry.url) : "",
|
|
461
466
|
});
|
|
462
467
|
|
|
463
468
|
/** The value of one row, by its kind. */
|
|
@@ -552,7 +557,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
|
|
|
552
557
|
const deliberate = [];
|
|
553
558
|
const merged = [];
|
|
554
559
|
for (const ref of related.seeAlso ?? []) {
|
|
555
|
-
if (seen.has(ref.url)) continue;
|
|
560
|
+
if (seen.has(ref.url) || !visible(ref.url)) continue;
|
|
556
561
|
seen.add(ref.url);
|
|
557
562
|
deliberate.push({
|
|
558
563
|
url: safeUrl(ref.url),
|
|
@@ -561,7 +566,7 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
|
|
|
561
566
|
});
|
|
562
567
|
}
|
|
563
568
|
for (const ref of [...(related.mentions ?? []), ...(related.backlinks ?? [])]) {
|
|
564
|
-
if (seen.has(ref.url)) continue;
|
|
569
|
+
if (seen.has(ref.url) || !visible(ref.url)) continue;
|
|
565
570
|
seen.add(ref.url);
|
|
566
571
|
merged.push(ref);
|
|
567
572
|
}
|
|
@@ -595,7 +600,15 @@ export function createSiteTheme({ site, dir = SITE_THEME_DIR, bannerData, report
|
|
|
595
600
|
|
|
596
601
|
/** The prev/next links of a page. */
|
|
597
602
|
const navOf = (page, narrow) => {
|
|
598
|
-
const { prev, next } = neighbours(
|
|
603
|
+
const { prev, next } = neighbours(
|
|
604
|
+
{
|
|
605
|
+
...page,
|
|
606
|
+
siblings: (page.siblings ?? []).filter(
|
|
607
|
+
(item) => item.url === page.url || visible(item.url),
|
|
608
|
+
),
|
|
609
|
+
},
|
|
610
|
+
narrow,
|
|
611
|
+
);
|
|
599
612
|
if (!prev && !next) return undefined;
|
|
600
613
|
const link = (item) => item && { url: safeUrl(item.url), title: text(item.title) };
|
|
601
614
|
return { prev: link(prev), next: link(next) };
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -603,11 +603,7 @@ async function createEventRelation(connection, file, records, prefix, audience)
|
|
|
603
603
|
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
|
|
604
604
|
// `whenIn` and `untilIn` are keyed by calendar Address, and read as structs
|
|
605
605
|
// whatever their number of keys, so `whenIn['<calendar>']` always binds.
|
|
606
|
-
await createTableFromFile(
|
|
607
|
-
connection,
|
|
608
|
-
`${prefix}events`,
|
|
609
|
-
readJsonAuto(file).replace(/\)$/, ", map_inference_threshold=-1)"),
|
|
610
|
-
);
|
|
606
|
+
await createTableFromFile(connection, `${prefix}events`, readJsonAuto(file));
|
|
611
607
|
}
|
|
612
608
|
|
|
613
609
|
/**
|
|
@@ -941,13 +937,20 @@ async function createRowRelation(connection, file, rows, view, columns, types) {
|
|
|
941
937
|
* type's fields with `NULL` where a record lacks one. A dependency's index has
|
|
942
938
|
* exactly the same shape and needs exactly the same reading.
|
|
943
939
|
*
|
|
940
|
+
* `map_inference_threshold=-1` keeps every object a struct. Left at its default,
|
|
941
|
+
* DuckDB reads an object whose keys are sparsely spread across rows (the mean
|
|
942
|
+
* share of rows carrying a key falls under `field_appearance_threshold`) as a
|
|
943
|
+
* map, so `data.<key>` changes meaning with the corpus: one more field on one
|
|
944
|
+
* note can turn a plain comparison into a JSON conversion error.
|
|
945
|
+
*
|
|
944
946
|
* @param {string} file - The JSONL to read.
|
|
945
947
|
* @returns {string} The `SELECT … FROM read_json_auto(…)` clause.
|
|
946
948
|
*/
|
|
947
949
|
function readJsonAuto(file) {
|
|
948
950
|
return (
|
|
949
951
|
`SELECT * FROM read_json_auto('${file.replace(/'/g, "''")}', ` +
|
|
950
|
-
`format='newline_delimited', union_by_name=true, maximum_object_size=20000000
|
|
952
|
+
`format='newline_delimited', union_by_name=true, maximum_object_size=20000000, ` +
|
|
953
|
+
`map_inference_threshold=-1)`
|
|
951
954
|
);
|
|
952
955
|
}
|
|
953
956
|
|
package/engine/verse-metre.mjs
CHANGED
|
@@ -129,6 +129,7 @@ export function stressIndex(word, rule, pairs) {
|
|
|
129
129
|
const count = nuclei.length;
|
|
130
130
|
if (count <= 1) return 0;
|
|
131
131
|
if (count === 2) return rule.two === "last" ? 1 : 0;
|
|
132
|
+
if (rule.longer === "first") return 0;
|
|
132
133
|
if (rule.longer === "antepenultimate") return count - 3;
|
|
133
134
|
if (rule.longer === "final") return count - 1;
|
|
134
135
|
return count - 2;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **The script a language is written in.** A language note — a `skill` of
|
|
16
|
+
* `subType: language` — names the map and the face its text is written in:
|
|
17
|
+
*
|
|
18
|
+
* ```yaml
|
|
19
|
+
* data:
|
|
20
|
+
* writtenIn: { map: semaron, font: semaron-kuriale }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* A run marked with the language's name takes them where it states neither:
|
|
24
|
+
* `[Zaron tholmeza]{lang=vyl}` is mapped by `semaron` and set in
|
|
25
|
+
* `semaron-kuriale`. A run that states one of the two takes the other from the
|
|
26
|
+
* default only where they agree, so the run is never set in a face its map is
|
|
27
|
+
* not drawn in:
|
|
28
|
+
*
|
|
29
|
+
* - **`map=` alone** takes the default `font` where that map is drawn in it.
|
|
30
|
+
* - **`font=` alone** takes the default `map` where the map is drawn in that
|
|
31
|
+
* face.
|
|
32
|
+
* - **Both** take nothing.
|
|
33
|
+
*
|
|
34
|
+
* The default belongs to the run's own language, after a div or fence around
|
|
35
|
+
* it has given it its keys: a span marked `lang=en` inside a div of a language
|
|
36
|
+
* with a default takes no face from it.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
42
|
+
import { nearest } from "./near-miss.mjs";
|
|
43
|
+
import { compileMap } from "./text-maps.mjs";
|
|
44
|
+
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
45
|
+
|
|
46
|
+
/** The `data` key a language note names its script in. */
|
|
47
|
+
export const WRITTEN_IN_KEY = "writtenIn";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A language's script.
|
|
51
|
+
*
|
|
52
|
+
* @typedef {object} WrittenIn
|
|
53
|
+
* @property {string} map - The `data.map.name` of the mapping note.
|
|
54
|
+
* @property {string} font - The slug of the face, one the map is drawn in.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** Whether a value is a plain map. */
|
|
58
|
+
const isMap = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a map is drawn in a face: it lists the face, or lists none.
|
|
62
|
+
*
|
|
63
|
+
* @param {import("./text-maps.mjs").TextMap} map - The map.
|
|
64
|
+
* @param {string} font - The face's slug.
|
|
65
|
+
* @returns {boolean} Whether the map may set text in it.
|
|
66
|
+
*/
|
|
67
|
+
export function drawnIn(map, font) {
|
|
68
|
+
return !map.fonts.size || map.fonts.has(font);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A list of names as prose: `a`, `a` and `b`, `a`, `b` and `c`. */
|
|
72
|
+
function said(names) {
|
|
73
|
+
const quoted = names.map((name) => `\`${name}\``);
|
|
74
|
+
return quoted.length < 2 ?
|
|
75
|
+
quoted.join("")
|
|
76
|
+
: `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The script each configured language's note names, where the map is known
|
|
81
|
+
* and is drawn in the face. A default that names an unknown map or a face its
|
|
82
|
+
* map is not drawn in is the note's finding, and fills no run.
|
|
83
|
+
*
|
|
84
|
+
* @param {ReadonlyMap<string, Record<string, any>>} byName - Each configured
|
|
85
|
+
* language's note record, by language name, from
|
|
86
|
+
* {@link module:engine/map-lexicon.languageRecordsByName}.
|
|
87
|
+
* @param {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} maps - The
|
|
88
|
+
* maps by name.
|
|
89
|
+
* @returns {Map<string, WrittenIn>} The scripts, by language name.
|
|
90
|
+
*/
|
|
91
|
+
export function writtenInOf(byName, maps) {
|
|
92
|
+
const out = new Map();
|
|
93
|
+
for (const [name, record] of byName ?? []) {
|
|
94
|
+
const value = record?.data?.[WRITTEN_IN_KEY];
|
|
95
|
+
if (!isMap(value) || typeof value.map !== "string" || typeof value.font !== "string")
|
|
96
|
+
continue;
|
|
97
|
+
const map = maps?.get(value.map);
|
|
98
|
+
if (!map || !drawnIn(map, value.font)) continue;
|
|
99
|
+
out.set(name, Object.freeze({ map: value.map, font: value.font }));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The keys a run takes from its language's script: each of `map` and `font`
|
|
106
|
+
* the run does not state, where it agrees with the one the run does.
|
|
107
|
+
*
|
|
108
|
+
* @param {{lang?: string, map?: string, font?: string}} keys - The run's keys,
|
|
109
|
+
* its own and those it inherits.
|
|
110
|
+
* @param {ReadonlyMap<string, WrittenIn>|undefined} scripts - Each language's
|
|
111
|
+
* script, from {@link writtenInOf}.
|
|
112
|
+
* @param {ReadonlyMap<string, import("./text-maps.mjs").TextMap>|undefined} maps -
|
|
113
|
+
* The maps by name.
|
|
114
|
+
* @returns {{map?: string, font?: string}} What the run takes; empty when it
|
|
115
|
+
* takes nothing.
|
|
116
|
+
*/
|
|
117
|
+
export function fromWrittenIn(keys, scripts, maps) {
|
|
118
|
+
if (keys.lang === undefined || (keys.map !== undefined && keys.font !== undefined)) return {};
|
|
119
|
+
const script = scripts?.get(keys.lang);
|
|
120
|
+
if (!script) return {};
|
|
121
|
+
if (keys.map === undefined && keys.font === undefined) return { ...script };
|
|
122
|
+
if (keys.map === undefined) {
|
|
123
|
+
const map = maps?.get(script.map);
|
|
124
|
+
return map && drawnIn(map, /** @type {string} */ (keys.font)) ? { map: script.map } : {};
|
|
125
|
+
}
|
|
126
|
+
const map = maps?.get(keys.map);
|
|
127
|
+
return map && drawnIn(map, script.font) ? { font: script.font } : {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The maps each lint index's notes declare, read once. */
|
|
131
|
+
const declaredMaps = new WeakMap();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Each map the index's notes declare, by name; the first note to declare a
|
|
135
|
+
* name keeps it.
|
|
136
|
+
*
|
|
137
|
+
* @param {{notes?: Iterable<{fm?: Record<string, any>}>}} index - The lint index.
|
|
138
|
+
* @returns {Map<string, import("./text-maps.mjs").TextMap>} The maps.
|
|
139
|
+
*/
|
|
140
|
+
function mapsIn(index) {
|
|
141
|
+
const known = declaredMaps.get(index);
|
|
142
|
+
if (known) return known;
|
|
143
|
+
const maps = new Map();
|
|
144
|
+
for (const note of index.notes ?? []) {
|
|
145
|
+
const map = compileMap(note?.fm?.data?.map);
|
|
146
|
+
if (map && !maps.has(map.name)) maps.set(map.name, map);
|
|
147
|
+
}
|
|
148
|
+
declaredMaps.set(index, maps);
|
|
149
|
+
return maps;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check a note's `data.writtenIn`: that the note is a language, that `map`
|
|
154
|
+
* names a map a mapping note declares, and that the map is drawn in `font`.
|
|
155
|
+
* The data-key check owns the keys and their kinds.
|
|
156
|
+
*
|
|
157
|
+
* @param {{file: string, raw?: string, fm?: Record<string, any>}} note - The note.
|
|
158
|
+
* @param {{index?: {notes?: Iterable<object>}}} [context] - The lint index;
|
|
159
|
+
* without one, only the note's `subType` is checked.
|
|
160
|
+
* @returns {object[]} The findings, each at its key or value.
|
|
161
|
+
*/
|
|
162
|
+
export function checkWrittenIn(note, { index } = {}) {
|
|
163
|
+
const value = note?.fm?.data?.[WRITTEN_IN_KEY];
|
|
164
|
+
if (value === undefined || value === null) return [];
|
|
165
|
+
const raw = note.raw ?? "";
|
|
166
|
+
/**
|
|
167
|
+
* @param {Array<string|number>} keyPath - Where the fault is.
|
|
168
|
+
* @param {string} message - What is wrong.
|
|
169
|
+
* @param {boolean} [key] - Locate the key rather than its value.
|
|
170
|
+
*/
|
|
171
|
+
const finding = (keyPath, message, key = false) => ({
|
|
172
|
+
file: note.file,
|
|
173
|
+
...positionOfFrontmatterPath(raw, ["data", WRITTEN_IN_KEY, ...keyPath], { key }),
|
|
174
|
+
severity: "error",
|
|
175
|
+
message,
|
|
176
|
+
});
|
|
177
|
+
if (String(note.fm?.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
|
|
178
|
+
return [
|
|
179
|
+
finding(
|
|
180
|
+
[],
|
|
181
|
+
`\`data.${WRITTEN_IN_KEY}\` names the script a language is written in, and this ` +
|
|
182
|
+
`note's \`subType\` is not \`${LANGUAGE_SUBTYPE}\``,
|
|
183
|
+
true,
|
|
184
|
+
),
|
|
185
|
+
];
|
|
186
|
+
if (!index || !isMap(value) || typeof value.map !== "string") return [];
|
|
187
|
+
const maps = mapsIn(index);
|
|
188
|
+
const map = maps.get(value.map);
|
|
189
|
+
if (!map) {
|
|
190
|
+
const near = nearest(value.map, [...maps.keys()]);
|
|
191
|
+
return [
|
|
192
|
+
finding(
|
|
193
|
+
["map"],
|
|
194
|
+
`\`data.${WRITTEN_IN_KEY}.map\` is \`${value.map}\`, which no mapping note's ` +
|
|
195
|
+
"`data.map.name` declares" +
|
|
196
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
197
|
+
),
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
if (typeof value.font !== "string" || drawnIn(map, value.font)) return [];
|
|
201
|
+
return [
|
|
202
|
+
finding(
|
|
203
|
+
["font"],
|
|
204
|
+
`\`data.${WRITTEN_IN_KEY}.font\` is \`${value.font}\`, which the map \`${map.name}\` ` +
|
|
205
|
+
`is not drawn in; it is drawn in ${said([...map.fonts.keys()])}`,
|
|
206
|
+
),
|
|
207
|
+
];
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "24.0.
|
|
3
|
+
"version": "24.0.2",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -3114,6 +3114,13 @@ summary.info-sidebar-title::after {
|
|
|
3114
3114
|
and `mono` set the site's own serif and mono, and the reader's sans. */
|
|
3115
3115
|
.script-block { margin:1rem 0; line-height:1.7; }
|
|
3116
3116
|
.script-block[dir="rtl"], .verse[dir="rtl"] { text-align:start; }
|
|
3117
|
+
/* A scroll: columns top to bottom, read from the right; the line height is the
|
|
3118
|
+
column pitch, one em and half an em between columns. A panel wider than the
|
|
3119
|
+
page scrolls sideways from the right. */
|
|
3120
|
+
.single-content .scroll { writing-mode:vertical-rl; line-height:1.5; inline-size:max-content; max-block-size:100%; overflow-x:auto; overflow-y:hidden; margin:1rem 0; }
|
|
3121
|
+
.single-content .scroll[data-rows] { inline-size:calc(var(--scroll-rows) * 1em); }
|
|
3122
|
+
.single-content .scroll[data-cols] { block-size:calc(var(--scroll-cols) * 1.5em); }
|
|
3123
|
+
.single-content .scroll p { margin:0; text-indent:0; }
|
|
3117
3124
|
.font-serif { font-family:var(--font-body); }
|
|
3118
3125
|
.font-sans { font-family:sans-serif; }
|
|
3119
3126
|
.font-mono { font-family:var(--font-mono); }
|
|
@@ -63,6 +63,10 @@ export function scanRuns(source: string, ctx?: RunContext, syntax?: import("./co
|
|
|
63
63
|
* - **A div or fence in a right-to-left face** carries `dir=rtl`. No direction
|
|
64
64
|
* mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
|
|
65
65
|
* direction.
|
|
66
|
+
* - **A scroll fence's body** is rewritten to one line per paragraph, its
|
|
67
|
+
* lines joined by what the map writes for a space, and left unescaped,
|
|
68
|
+
* since the body is raw. Its layout, each character with the source text it
|
|
69
|
+
* was written from, is returned for the book, which draws it.
|
|
66
70
|
*
|
|
67
71
|
* A run with a finding is left as written; {@link scanRuns} reports it.
|
|
68
72
|
*
|
|
@@ -72,8 +76,9 @@ export function scanRuns(source: string, ctx?: RunContext, syntax?: import("./co
|
|
|
72
76
|
* scanner projections.
|
|
73
77
|
* @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
|
|
74
78
|
* Markdown is rendered for; only `site` writes the search marker.
|
|
75
|
-
* @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
|
|
76
|
-
* The rewritten Markdown,
|
|
79
|
+
* @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
|
|
80
|
+
* The rewritten Markdown, the findings of {@link scanRuns}, and each
|
|
81
|
+
* rewritten scroll fence's layout, by its rewritten body.
|
|
77
82
|
*/
|
|
78
83
|
export function renderRuns(source: string, ctx?: RunContext, syntax?: import("./content-session.mjs").ContentSyntax, options?: {
|
|
79
84
|
target?: "site" | "foundry" | "book";
|
|
@@ -84,6 +89,7 @@ export function renderRuns(source: string, ctx?: RunContext, syntax?: import("./
|
|
|
84
89
|
column: number;
|
|
85
90
|
message: string;
|
|
86
91
|
}>;
|
|
92
|
+
scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>;
|
|
87
93
|
};
|
|
88
94
|
/** The keys this module reads, which no construct passes through as written. */
|
|
89
95
|
export const RUN_KEYS: readonly string[];
|
|
@@ -184,6 +190,11 @@ export type RunDetail = {
|
|
|
184
190
|
* - The emphasis it holds, by absolute offset.
|
|
185
191
|
*/
|
|
186
192
|
emphasis: Delimiter[];
|
|
193
|
+
/**
|
|
194
|
+
* - A scroll fence whose opening has a finding,
|
|
195
|
+
* which is left as written.
|
|
196
|
+
*/
|
|
197
|
+
unrolled?: boolean | undefined;
|
|
187
198
|
};
|
|
188
199
|
/**
|
|
189
200
|
* What a run is checked against.
|
|
@@ -208,6 +219,12 @@ export type RunContext = {
|
|
|
208
219
|
* {@link module :engine/text-maps.mapsFromRecords}.
|
|
209
220
|
*/
|
|
210
221
|
maps?: ReadonlyMap<string, import("./text-maps.mjs").TextMap> | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* -
|
|
224
|
+
* The script each configured language's note names, by language name, from
|
|
225
|
+
* {@link module :engine/written-in.writtenInOf}.
|
|
226
|
+
*/
|
|
227
|
+
writtenIn?: ReadonlyMap<string, import("./written-in.mjs").WrittenIn> | undefined;
|
|
211
228
|
/**
|
|
212
229
|
* -
|
|
213
230
|
* Each configured language's lexicon by name, from
|
|
@@ -243,17 +260,25 @@ export type RunContext = {
|
|
|
243
260
|
* from {@link module :engine/script-emphasis.languageEmphasesOf}.
|
|
244
261
|
*/
|
|
245
262
|
languageEmphases?: ReadonlyMap<string, import("./script-emphasis.mjs").LanguageEmphasis> | undefined;
|
|
263
|
+
/**
|
|
264
|
+
* - Each face whose
|
|
265
|
+
* script is written in columns, from the script notes, by
|
|
266
|
+
* {@link module :engine/script-direction.directionsFromRecords}.
|
|
267
|
+
*/
|
|
268
|
+
directions?: ReadonlyMap<string, "vertical-rl"> | undefined;
|
|
246
269
|
};
|
|
247
270
|
/**
|
|
248
|
-
* One span, div or
|
|
249
|
-
* `lang`, `font` and `map` are the ones it is set with: its own,
|
|
250
|
-
* innermost div or fence around it that states one
|
|
271
|
+
* One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
|
|
272
|
+
* `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
|
|
273
|
+
* else the innermost div or fence around it that states one, else its
|
|
274
|
+
* language's script. A scroll fence that still names no face takes the face of
|
|
275
|
+
* its map where the map is drawn in one.
|
|
251
276
|
*/
|
|
252
277
|
export type Run = {
|
|
253
278
|
/**
|
|
254
279
|
* - Which construct.
|
|
255
280
|
*/
|
|
256
|
-
kind: "span" | "block" | "verse";
|
|
281
|
+
kind: "span" | "block" | "verse" | "scroll";
|
|
257
282
|
/**
|
|
258
283
|
* - The language as written.
|
|
259
284
|
*/
|