@heroiclands/package-build 24.0.1 → 24.0.3
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 +32 -0
- package/assets/preview/gitea.ini +1 -1
- package/assets/styles/content-base.css +33 -0
- package/bin/content-commands.mjs +17 -17
- package/content-config.mjs +1 -52
- package/docs/api.md +20 -13
- package/docs/authoring/links-and-markup.md +165 -17
- package/docs/commands.md +20 -20
- package/docs/configuration.md +29 -69
- package/docs/content-format.md +4 -2
- package/docs/guides/site.md +3 -1
- package/docs/guides/writing-in-invented-scripts.md +68 -6
- package/docs/reference/format-details.md +160 -118
- package/docs/reference/note-types.md +49 -29
- package/docs/source-preview.md +2 -2
- package/engine/content-blocks.mjs +6 -2
- package/engine/content-charset.mjs +1 -0
- 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/dictionary-layout.mjs +16 -3
- package/engine/font-outlines.mjs +394 -0
- package/engine/foreign-catalog.mjs +11 -122
- package/engine/language-data.mjs +154 -63
- package/engine/language-rows.mjs +35 -9
- package/engine/language-terms.mjs +39 -9
- package/engine/map-lexicon.mjs +31 -6
- package/engine/note-vocabulary.mjs +58 -6
- package/engine/pdf-build.mjs +9 -178
- 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-reach.mjs +23 -0
- package/engine/site-render.mjs +18 -3
- package/engine/verse-metre.mjs +1 -0
- package/engine/written-in.mjs +208 -0
- package/package.json +2 -1
- package/site-theme/static/css/style.css +7 -0
- package/types/content-config.d.mts +1 -19
- package/types/engine/content-scripts.d.mts +31 -6
- package/types/engine/content-scroll.d.mts +179 -0
- package/types/engine/dictionary-layout.d.mts +5 -0
- package/types/engine/font-outlines.d.mts +67 -0
- package/types/engine/foreign-catalog.d.mts +0 -38
- package/types/engine/language-data.d.mts +13 -0
- package/types/engine/language-rows.d.mts +5 -4
- package/types/engine/language-terms.d.mts +7 -0
- package/types/engine/map-lexicon.d.mts +4 -3
- package/types/engine/pdf-build.d.mts +5 -27
- 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 +11 -0
- package/types/engine/written-in.d.mts +76 -0
- package/engine/asset-replacement.mjs +0 -93
- package/types/engine/asset-replacement.d.mts +0 -22
package/engine/pdf-build.mjs
CHANGED
|
@@ -124,15 +124,7 @@ import {
|
|
|
124
124
|
pdfTextWidth,
|
|
125
125
|
resamplePdfImages,
|
|
126
126
|
} from "./pdf-images.mjs";
|
|
127
|
-
import {
|
|
128
|
-
ASSETS_SEGMENT,
|
|
129
|
-
assetPathnameKey,
|
|
130
|
-
pathnameProblem,
|
|
131
|
-
resolvePathname,
|
|
132
|
-
} from "./pathnames.mjs";
|
|
133
|
-
import { ASSET_SYSTEM, assetTypeOfRoot, isAssetShortcode } from "./asset-types.mjs";
|
|
134
|
-
import { canonicalKey } from "./address.mjs";
|
|
135
|
-
import { isComplete as isForeignCacheComplete, newestVersionDir } from "./metadata-index.mjs";
|
|
127
|
+
import { assetPathnameKey, pathnameProblem, resolvePathname } from "./pathnames.mjs";
|
|
136
128
|
import {
|
|
137
129
|
createParser,
|
|
138
130
|
labelFor,
|
|
@@ -147,7 +139,6 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
|
147
139
|
import { resolveIconGlyphs } from "./pdf-fonts.mjs";
|
|
148
140
|
import { drawMapsFrom } from "./derived-sections.mjs";
|
|
149
141
|
import { generatedDrawing } from "./generated-sections.mjs";
|
|
150
|
-
import { resolveAssetReplacement } from "./asset-replacement.mjs";
|
|
151
142
|
import { typstScriptFontPath } from "./script-font-delivery.mjs";
|
|
152
143
|
|
|
153
144
|
/**
|
|
@@ -166,39 +157,18 @@ import { typstScriptFontPath } from "./script-font-delivery.mjs";
|
|
|
166
157
|
* a build from touching a path outside its own output.
|
|
167
158
|
*
|
|
168
159
|
* A file **this** package ships stages directly. A pathname naming another
|
|
169
|
-
* package's file
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* answers it; `opts.foreignIndex` is the merged index that answer is read
|
|
173
|
-
* against. A pathname naming a package no replacement carries it for, or a
|
|
174
|
-
* URL, names something no build here can open — a build reaches no network —
|
|
175
|
-
* and the caller reports it as a picture the book will not carry.
|
|
160
|
+
* package's file, or a URL, names something no build here can open — a build
|
|
161
|
+
* reaches no network — and the caller reports it as a picture the book will
|
|
162
|
+
* not carry.
|
|
176
163
|
*
|
|
177
164
|
* @param {string} src - The pathname, as authored.
|
|
178
165
|
* @param {object} config - The resolved configuration.
|
|
179
|
-
* @param {object} [opts]
|
|
180
|
-
* @param {Map<string, object>} [opts.foreignIndex] - Every fetched
|
|
181
|
-
* dependency's own records, keyed by canonical address — the index a
|
|
182
|
-
* replacement's record is read out of.
|
|
183
|
-
* @param {Function} [opts.resolveReplacement] - {@link
|
|
184
|
-
* module:engine/asset-replacement.resolveAssetReplacement}, or an
|
|
185
|
-
* equivalent a caller injects for a test. Omitted, a foreign pathname this
|
|
186
|
-
* package does not own stays declined exactly as it always has.
|
|
187
166
|
* @returns {{from: string, to: string}|null} The file, and where under the
|
|
188
167
|
* output directory it is staged.
|
|
189
|
-
* @throws {Error} When a declared replacement answers the address but its
|
|
190
|
-
* archive was never fetched.
|
|
191
168
|
*/
|
|
192
|
-
export function stagedImagePath(src, config
|
|
169
|
+
export function stagedImagePath(src, config) {
|
|
193
170
|
const forms = resolvePathname(src, config);
|
|
194
171
|
if (!forms || forms.state !== "package") return null;
|
|
195
|
-
// A declared replacement answers before the package's own file does, which
|
|
196
|
-
// is the whole of what declaring one buys: the address a note writes is
|
|
197
|
-
// this package's own, and the picture staged for it is the replacement's.
|
|
198
|
-
// A miss falls through, so a package declaring none stages exactly what it
|
|
199
|
-
// ships.
|
|
200
|
-
const replaced = opts.resolveReplacement ? stagedReplacementPath(forms, config, opts) : null;
|
|
201
|
-
if (replaced) return replaced;
|
|
202
172
|
if (forms.own) {
|
|
203
173
|
return {
|
|
204
174
|
from: path.resolve(config.rootDir, forms.local),
|
|
@@ -208,126 +178,6 @@ export function stagedImagePath(src, config, opts = {}) {
|
|
|
208
178
|
return null;
|
|
209
179
|
}
|
|
210
180
|
|
|
211
|
-
/**
|
|
212
|
-
* Every declared relationship that opted a dependency in as this package's
|
|
213
|
-
* asset replacement, in declaration order.
|
|
214
|
-
*
|
|
215
|
-
* The same shape {@link module:engine/foreign-catalog.itemCatalogRelationships}
|
|
216
|
-
* returns, read directly rather than through that module: this file stays a
|
|
217
|
-
* leaf over the configuration it is handed, and the flag is as small to read
|
|
218
|
-
* back as it was to validate.
|
|
219
|
-
*
|
|
220
|
-
* @param {object} config - The resolved build configuration.
|
|
221
|
-
* @returns {Array<{id: string, package: string}>} The relationships, each
|
|
222
|
-
* with the package segment its own content index publishes under.
|
|
223
|
-
*/
|
|
224
|
-
function assetReplacementRelationships(config) {
|
|
225
|
-
const out = [];
|
|
226
|
-
for (const entries of Object.values(config.relationships ?? {})) {
|
|
227
|
-
for (const rel of entries ?? []) {
|
|
228
|
-
if (rel.assetReplacement) {
|
|
229
|
-
out.push({ id: rel.id, package: rel.contentPackage ?? rel.id });
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return out;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* The address a pathname this package does not own is read back as, for
|
|
238
|
-
* asking whether a declared replacement answers it instead of the package
|
|
239
|
-
* the pathname names.
|
|
240
|
-
*
|
|
241
|
-
* {@link module:engine/asset-replacement.resolveAssetReplacement} only ever
|
|
242
|
-
* matches an address whose package segment is the compiling package's own,
|
|
243
|
-
* so the pathname's own package is set aside and its suffix — the one piece
|
|
244
|
-
* every surface derives from, and the piece a replacement publishes under
|
|
245
|
-
* its own package instead — is read back as this package's type and
|
|
246
|
-
* shortcode.
|
|
247
|
-
*
|
|
248
|
-
* @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
|
|
249
|
-
* resolved.
|
|
250
|
-
* @param {object} config - The resolved build configuration.
|
|
251
|
-
* @returns {string|null} The canonical address, or `null` when the suffix is
|
|
252
|
-
* not shaped like an asset's.
|
|
253
|
-
*/
|
|
254
|
-
function localAssetAddress(forms, config) {
|
|
255
|
-
const segments = String(forms.suffix ?? "").split("/");
|
|
256
|
-
const assetType = assetTypeOfRoot(segments[0]);
|
|
257
|
-
if (!assetType) return null;
|
|
258
|
-
const base = segments[segments.length - 1];
|
|
259
|
-
const ext = path.extname(base);
|
|
260
|
-
const shortcode = (ext ? base.slice(0, -ext.length) : base).toLowerCase();
|
|
261
|
-
if (!isAssetShortcode(shortcode)) return null;
|
|
262
|
-
return canonicalKey(config.contentPackage, ASSET_SYSTEM, assetType.type, shortcode);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* The cache directory one relationship's archive was fetched into, found the
|
|
267
|
-
* way {@link module:engine/foreign-catalog.foreignItemCatalogDirs} finds an
|
|
268
|
-
* item catalogue's: the newest `.complete`-stamped `<id>@<version>`
|
|
269
|
-
* directory under `config.paths.foreignCache`.
|
|
270
|
-
*
|
|
271
|
-
* @param {object} config - The resolved build configuration.
|
|
272
|
-
* @param {string} id - The relationship's dependency id.
|
|
273
|
-
* @returns {string|null} The directory, or `null` when nothing is cached.
|
|
274
|
-
*/
|
|
275
|
-
function cachedReplacementDir(config, id) {
|
|
276
|
-
const root = config.paths.foreignCache;
|
|
277
|
-
if (!fs.existsSync(root)) return null;
|
|
278
|
-
const cached = fs
|
|
279
|
-
.readdirSync(root)
|
|
280
|
-
.filter((name) => name.startsWith(`${id}@`))
|
|
281
|
-
.map((name) => path.join(root, name))
|
|
282
|
-
.filter(isForeignCacheComplete);
|
|
283
|
-
return cached.length ? newestVersionDir(cached) : null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* The file a declared replacement stages in place of a pathname this package
|
|
288
|
-
* does not own, or `null` when no replacement answers it.
|
|
289
|
-
*
|
|
290
|
-
* @param {import("./pathnames.mjs").PathnameForms} forms - The pathname,
|
|
291
|
-
* resolved, with `forms.own` already `false`.
|
|
292
|
-
* @param {object} config - The resolved build configuration.
|
|
293
|
-
* @param {object} opts
|
|
294
|
-
* @param {Map<string, object>} [opts.foreignIndex] - Every fetched
|
|
295
|
-
* dependency's own records, keyed by canonical address.
|
|
296
|
-
* @param {Function} opts.resolveReplacement - {@link
|
|
297
|
-
* module:engine/asset-replacement.resolveAssetReplacement}.
|
|
298
|
-
* @returns {{from: string, to: string}|null} The file, staged at the same
|
|
299
|
-
* `pdf`-relative path this address would have taken had this package
|
|
300
|
-
* shipped it.
|
|
301
|
-
* @throws {Error} When the replacement that answers the address has not
|
|
302
|
-
* been fetched.
|
|
303
|
-
*/
|
|
304
|
-
function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolveReplacement }) {
|
|
305
|
-
const address = localAssetAddress(forms, config);
|
|
306
|
-
if (!address) return null;
|
|
307
|
-
const relationships = assetReplacementRelationships(config);
|
|
308
|
-
if (!relationships.length) return null;
|
|
309
|
-
const hit = resolveReplacement(address, {
|
|
310
|
-
localPackage: config.contentPackage,
|
|
311
|
-
replacements: relationships.map((rel) => rel.package),
|
|
312
|
-
index: foreignIndex,
|
|
313
|
-
});
|
|
314
|
-
if (!hit) return null;
|
|
315
|
-
const assetPath = hit.record?.asset?.path;
|
|
316
|
-
if (!assetPath) return null;
|
|
317
|
-
const rel = relationships.find((entry) => entry.package === hit.package);
|
|
318
|
-
const dir = cachedReplacementDir(config, rel?.id ?? hit.package);
|
|
319
|
-
if (!dir) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
`\`${hit.package}\` declares \`assetReplacement: true\` and carries \`${address}\`, ` +
|
|
322
|
-
"but its archive has not been fetched. Run `package-build deps fetch` first.",
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
return {
|
|
326
|
-
from: path.join(dir, ASSETS_SEGMENT, assetPath),
|
|
327
|
-
to: forms.pdf,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
181
|
/**
|
|
332
182
|
* Where the book keeps the pictures its section plates are drawn over.
|
|
333
183
|
*
|
|
@@ -553,10 +403,6 @@ function configFileFor(config, rootDir) {
|
|
|
553
403
|
* {@link module:engine/pdf-pages}.
|
|
554
404
|
* @param {Function} [opts.readPages] - {@link
|
|
555
405
|
* module:engine/pdf-pages.readEntryPages}, for tests.
|
|
556
|
-
* @param {Function} [opts.resolveReplacement] - {@link
|
|
557
|
-
* module:engine/asset-replacement.resolveAssetReplacement}, for tests.
|
|
558
|
-
* Loaded from that module otherwise, and only when a relationship declares
|
|
559
|
-
* `assetReplacement: true` — a build with none never imports it.
|
|
560
406
|
* @returns {Promise<{built: boolean, reason: string|null, findings: object[],
|
|
561
407
|
* imageReports: object[], books: Array<{id: string, title: string,
|
|
562
408
|
* built: boolean, typ: string, pdf: string|null, stats: object}>,
|
|
@@ -576,7 +422,6 @@ export async function buildPdf({
|
|
|
576
422
|
version = "",
|
|
577
423
|
compile = true,
|
|
578
424
|
readPages = readEntryPages,
|
|
579
|
-
resolveReplacement,
|
|
580
425
|
} = {}) {
|
|
581
426
|
const resolved = config ?? loadPackConfig();
|
|
582
427
|
const findings = [];
|
|
@@ -642,7 +487,7 @@ export async function buildPdf({
|
|
|
642
487
|
out || resolved.pdf.out || path.join("build", "dist"),
|
|
643
488
|
);
|
|
644
489
|
|
|
645
|
-
const world = await prepareWorld(resolved, { contentBase, findings
|
|
490
|
+
const world = await prepareWorld(resolved, { contentBase, findings });
|
|
646
491
|
if (!world) return none(null);
|
|
647
492
|
|
|
648
493
|
const books = [];
|
|
@@ -944,11 +789,10 @@ function locateType(contentBase, record) {
|
|
|
944
789
|
* @param {object} shared - Options.
|
|
945
790
|
* @param {string} shared.contentBase - The content tree.
|
|
946
791
|
* @param {object[]} shared.findings - Collected here rather than thrown.
|
|
947
|
-
* @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
|
|
948
792
|
* @returns {Promise<object|null>} The world, or null when a site gate fired
|
|
949
793
|
* and the findings say why.
|
|
950
794
|
*/
|
|
951
|
-
async function prepareWorld(resolved, { contentBase, findings
|
|
795
|
+
async function prepareWorld(resolved, { contentBase, findings }) {
|
|
952
796
|
// The corpus, once — the same records the index, the site and the SQL
|
|
953
797
|
// tables read, so the book cannot select a note the other surfaces do not
|
|
954
798
|
// have.
|
|
@@ -1038,17 +882,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1038
882
|
const md = createParser(resolved.icons);
|
|
1039
883
|
const glyphs = resolveIconGlyphs(resolved.icons, resolved.pdf.iconFonts, findings);
|
|
1040
884
|
|
|
1041
|
-
// Asked only where a relationship declares `assetReplacement: true`: with
|
|
1042
|
-
// none declared the resolver is never consulted, and staging gives the
|
|
1043
|
-
// answer it gives a package that declares no replacement. A caller may
|
|
1044
|
-
// supply its own, which is how the staging tests drive it.
|
|
1045
|
-
const stagingOpts = {
|
|
1046
|
-
foreignIndex: gates.foreign.index,
|
|
1047
|
-
resolveReplacement:
|
|
1048
|
-
resolveReplacement ??
|
|
1049
|
-
(assetReplacementRelationships(resolved).length ? resolveAssetReplacement : null),
|
|
1050
|
-
};
|
|
1051
|
-
|
|
1052
885
|
return {
|
|
1053
886
|
records,
|
|
1054
887
|
db,
|
|
@@ -1060,7 +893,6 @@ async function prepareWorld(resolved, { contentBase, findings, resolveReplacemen
|
|
|
1060
893
|
assets,
|
|
1061
894
|
md,
|
|
1062
895
|
glyphs,
|
|
1063
|
-
stagingOpts,
|
|
1064
896
|
};
|
|
1065
897
|
}
|
|
1066
898
|
|
|
@@ -1150,7 +982,6 @@ async function selectBook(book, index, { world, configFile, configText, findings
|
|
|
1150
982
|
* @param {{get: Function}} [shared.elsewhere] - From
|
|
1151
983
|
* {@link module:engine/pdf-pages.elsewhereFor}: the notes another book is
|
|
1152
984
|
* home to, which this book names by book and page rather than links.
|
|
1153
|
-
* @param {Function} [shared.resolveReplacement] - As {@link buildPdf}.
|
|
1154
985
|
* @param {object[]} shared.findings - Collected here rather than thrown.
|
|
1155
986
|
* @returns {Promise<{built: boolean, typ: string|null, pdf: string|null,
|
|
1156
987
|
* stats: object|null, imageReports?: object[]}>} What was written: the
|
|
@@ -1178,10 +1009,10 @@ async function buildBook(book, shared) {
|
|
|
1178
1009
|
* @returns {{from: string, to: string}|null} As {@link stagedImagePath}.
|
|
1179
1010
|
*/
|
|
1180
1011
|
const stage = (src) => {
|
|
1181
|
-
const staged = stagedImagePath(src, resolved
|
|
1012
|
+
const staged = stagedImagePath(src, resolved);
|
|
1182
1013
|
return staged && { ...staged, to: inBook(staged.to) };
|
|
1183
1014
|
};
|
|
1184
|
-
const { records, site, gates, byFile, sqlTables, assets, md, glyphs
|
|
1015
|
+
const { records, site, gates, byFile, sqlTables, assets, md, glyphs } = world;
|
|
1185
1016
|
|
|
1186
1017
|
fs.mkdirSync(outDir, { recursive: true });
|
|
1187
1018
|
|
package/engine/pdf-fonts.mjs
CHANGED
|
@@ -55,7 +55,7 @@ import { familyOf } from "./content-icons.mjs";
|
|
|
55
55
|
* @param {Buffer} buf - The font file.
|
|
56
56
|
* @returns {Map<string, {offset: number, length: number}>} Its table directory.
|
|
57
57
|
*/
|
|
58
|
-
function tableDirectory(buf) {
|
|
58
|
+
export function tableDirectory(buf) {
|
|
59
59
|
const tables = new Map();
|
|
60
60
|
if (buf.length < 12) return tables;
|
|
61
61
|
// A TrueType collection points at its first font; a bare font starts at 0.
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* A note's markdown, and a document plan, rendered as Typst source.
|
|
16
16
|
*
|
|
17
|
-
* **This module emits text and reads nothing
|
|
18
|
-
* returns a `.typ` document; the filesystem, the note
|
|
19
|
-
* that turns the result into a PDF all live in
|
|
20
|
-
* {@link module:engine/pdf-build}.
|
|
17
|
+
* **This module emits text and reads nothing but a face's outlines.** It takes
|
|
18
|
+
* markdown and a plan and returns a `.typ` document; the filesystem, the note
|
|
19
|
+
* bodies and the compiler that turns the result into a PDF all live in
|
|
20
|
+
* {@link module:engine/pdf-build}. The one file it reads is the TrueType face a
|
|
21
|
+
* `scroll` fence is drawn from, through {@link module:engine/font-outlines}. That split is what lets the outline, the
|
|
21
22
|
* table of contents, every anchor and every link destination be asserted in a
|
|
22
23
|
* unit test with no renderer installed — which is most of what a book has to
|
|
23
24
|
* get right, and all of what a test can check without eyes.
|
|
@@ -95,6 +96,8 @@ import {
|
|
|
95
96
|
verseStanzas,
|
|
96
97
|
} from "./content-verse.mjs";
|
|
97
98
|
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
99
|
+
import { panelRoman, scanScrolls, scrollPanels, scrollParagraphs } from "./content-scroll.mjs";
|
|
100
|
+
import { BOOK_INK, BOOK_RUBRIC, fontOutlines, scrollPanelSvg } from "./font-outlines.mjs";
|
|
98
101
|
import { HTML_TAG, htmlMessage } from "./content-html.mjs";
|
|
99
102
|
import { EXPRESSION } from "./markdown-expressions.mjs";
|
|
100
103
|
import { inlineText } from "./name-markup.mjs";
|
|
@@ -498,11 +501,12 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
498
501
|
// is an offset into *this* string, and the renderer below walks slices of it.
|
|
499
502
|
if (!opts.nested) reportUnrenderable(authored, definitions, opts, given);
|
|
500
503
|
// Text marked with a language, a font or a map is rewritten once, ahead of
|
|
501
|
-
// every other pass, line for line; the nested calls below read the result
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
504
|
+
// every other pass, line for line; the nested calls below read the result,
|
|
505
|
+
// and the scroll fences' layouts the rewrite returns.
|
|
506
|
+
const marked =
|
|
507
|
+
opts.nested ? undefined : renderRuns(authored, opts.runs, undefined, { target: "book" });
|
|
508
|
+
const source = marked ? marked.markdown : authored;
|
|
509
|
+
if (marked) sharedOptions.scrolls = marked.scrolls;
|
|
506
510
|
// One map for the whole body, not one per block: a heading inside a
|
|
507
511
|
// blockquote or a list item shares the entry's anchor namespace with every
|
|
508
512
|
// other heading in the same body, because `sectionLabel` scopes by entry
|
|
@@ -522,6 +526,7 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
522
526
|
seen: new Map(),
|
|
523
527
|
footnoteState,
|
|
524
528
|
footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
|
|
529
|
+
scrolls: marked?.scrolls ?? opts.scrolls ?? new Map(),
|
|
525
530
|
};
|
|
526
531
|
// A withheld section is set in the box a `:::secret` is set in, and its
|
|
527
532
|
// heading is set inside it: a printed page honours no reader permission, so
|
|
@@ -752,6 +757,10 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
752
757
|
}
|
|
753
758
|
case "fence":
|
|
754
759
|
case "code_block": {
|
|
760
|
+
if (token.type === "fence" && /^scroll(?:\s|$)/.test(token.info?.trim() || "")) {
|
|
761
|
+
out.push(scrollBlock(token, ctx));
|
|
762
|
+
return 1;
|
|
763
|
+
}
|
|
755
764
|
if (token.type === "fence" && /^verse(?:\s|$)/.test(token.info?.trim() || "")) {
|
|
756
765
|
const opening = `${token.markup}${token.info}`;
|
|
757
766
|
const poem = scanVerse(`${opening}\n${token.content}${token.markup}`).blocks[0];
|
|
@@ -1199,6 +1208,38 @@ function padCells(cells, columns) {
|
|
|
1199
1208
|
return padded.map((c) => `[${c}]`).join(", ");
|
|
1200
1209
|
}
|
|
1201
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* A `scroll` fence as the book sets it: Typst has no vertical writing, so each
|
|
1213
|
+
* panel of at most `rows` × `cols` characters is an image drawn from the
|
|
1214
|
+
* face's outlines, its roman text as alt text. The panels sit in a paragraph
|
|
1215
|
+
* set right to left, so they are read from the right and wrap to the next
|
|
1216
|
+
* line where the measure ends; without `cols`, each column is a panel, and as
|
|
1217
|
+
* many columns sit side by side as fit. A fence the rewrite of marked text did
|
|
1218
|
+
* not set, or whose face has no TrueType file, is set as code.
|
|
1219
|
+
*
|
|
1220
|
+
* @param {object} token - The fence token.
|
|
1221
|
+
* @param {object} ctx - Render context, holding the scroll layouts.
|
|
1222
|
+
* @returns {string} Typst markup.
|
|
1223
|
+
*/
|
|
1224
|
+
function scrollBlock(token, ctx) {
|
|
1225
|
+
const scroll = scanScrolls(`${token.markup}${token.info}\n${token.content}${token.markup}`)
|
|
1226
|
+
.blocks[0];
|
|
1227
|
+
const layout = ctx.scrolls?.get(scrollParagraphs(token.content.split("\n")).join("\n\n"));
|
|
1228
|
+
if (!scroll || scroll.invalid || !layout?.face?.file)
|
|
1229
|
+
return rawBlock(token.content, token.info?.trim() || "");
|
|
1230
|
+
const outlines = fontOutlines(layout.face.file);
|
|
1231
|
+
const { rows, panels } = scrollPanels(layout.paragraphs, scroll);
|
|
1232
|
+
const boxes = panels.map(
|
|
1233
|
+
(panel) =>
|
|
1234
|
+
`#box(image(bytes("${escapeTypstString(scrollPanelSvg(panel, rows, outlines))}"), ` +
|
|
1235
|
+
`format: "svg", height: ${rows}em, alt: "${escapeTypstString(panelRoman(panel, layout.paragraphs))}"))`,
|
|
1236
|
+
);
|
|
1237
|
+
// A zero-width space lets a line break between two columns and adds no
|
|
1238
|
+
// width; panels of several columns are kept a column gap apart.
|
|
1239
|
+
const between = scroll.cols === undefined ? "\u200B" : "#h(1em, weak: true) ";
|
|
1240
|
+
return `\n#block(width: 100%)[#set text(dir: rtl)\n${boxes.join(between)}\n]\n\n`;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1202
1243
|
/**
|
|
1203
1244
|
* A fenced block as Typst raw text.
|
|
1204
1245
|
*
|
|
@@ -1944,10 +1985,10 @@ export function bookTypstPreamble(runs = {}, families = {}) {
|
|
|
1944
1985
|
// The title sets short of the measure, so a plate's last line does not
|
|
1945
1986
|
// run to the trimmed edge of the paper.
|
|
1946
1987
|
"#let book-title-measure = book-text-width - 2.5cm",
|
|
1947
|
-
|
|
1988
|
+
`#let book-ink = rgb("${BOOK_INK}")`,
|
|
1948
1989
|
// A rubric script's emphasis: a vermilion that holds 4.5:1 on the paper
|
|
1949
1990
|
// and stays apart from the ink in lightness.
|
|
1950
|
-
|
|
1991
|
+
`#let book-rubric = rgb("${BOOK_RUBRIC}")`,
|
|
1951
1992
|
'#let book-paper = rgb("#f4efe4")',
|
|
1952
1993
|
'#let book-accent = rgb("#7c3b1e")',
|
|
1953
1994
|
'#let book-head = rgb("#5e2b14")',
|
package/engine/run-rows.mjs
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* column value and none is a key, so two notes writing one tag in different
|
|
18
18
|
* case are two rows. Documentation records and assets contribute no rows.
|
|
19
19
|
*
|
|
20
|
+
* A run's `font` and `map` are the ones it is set with: its own, else those of
|
|
21
|
+
* the div or fence around it, else those its language's note names in
|
|
22
|
+
* `data.writtenIn`, filled by {@link fillWrittenIn} once every record exists.
|
|
23
|
+
*
|
|
20
24
|
* @module
|
|
21
25
|
*/
|
|
22
26
|
|
|
@@ -24,6 +28,9 @@ import { canonicalKey, readCanonicalKey } from "./address.mjs";
|
|
|
24
28
|
import { encodeAddresses } from "./address-values.mjs";
|
|
25
29
|
import { isNoteRecord } from "./index-records.mjs";
|
|
26
30
|
import { scanRuns } from "./content-scripts.mjs";
|
|
31
|
+
import { languageRecordsByName } from "./map-lexicon.mjs";
|
|
32
|
+
import { mapsFromRecords } from "./text-maps.mjs";
|
|
33
|
+
import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
|
|
27
34
|
|
|
28
35
|
/** Every column of the `runs` relation, in order. */
|
|
29
36
|
export const RUN_COLUMNS = Object.freeze([
|
|
@@ -41,12 +48,14 @@ export const RUN_COLUMNS = Object.freeze([
|
|
|
41
48
|
* One run as an index record carries it.
|
|
42
49
|
*
|
|
43
50
|
* @typedef {object} RecordRun
|
|
44
|
-
* @property {"span"|"block"|"verse"} kind - The construct.
|
|
51
|
+
* @property {"span"|"block"|"verse"|"scroll"} kind - The construct.
|
|
45
52
|
* @property {string|null} lang - The language it is set in, as written: its
|
|
46
53
|
* own `lang=`, else the one the div or fence around it states.
|
|
47
54
|
* @property {string|null} tag - The BCP 47 tag it resolves to, or null.
|
|
48
|
-
* @property {string|null} font - Its font, as written and inherited the same
|
|
49
|
-
*
|
|
55
|
+
* @property {string|null} font - Its font, as written and inherited the same
|
|
56
|
+
* way, else its language's script's.
|
|
57
|
+
* @property {string|null} map - Its map, as written and inherited the same way,
|
|
58
|
+
* else its language's script's.
|
|
50
59
|
* @property {number} line - 1-based file line of its opening.
|
|
51
60
|
* @property {number} column - 1-based UTF-16 file column of its opening.
|
|
52
61
|
*/
|
|
@@ -80,6 +89,39 @@ export function recordRuns(body, { context, bodyLine = 1, bodyColumn = 1, syntax
|
|
|
80
89
|
}));
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Give every recorded run the map and face its language's note names in
|
|
94
|
+
* `data.writtenIn`, where the run leaves them unstated, as a run is set. Read
|
|
95
|
+
* once every record exists, since a run's language note may be recorded after
|
|
96
|
+
* the note that marks it.
|
|
97
|
+
*
|
|
98
|
+
* @param {Record<string, any>[]} records - The package's index records; each
|
|
99
|
+
* note record's `runs` is updated in place.
|
|
100
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
101
|
+
* The configured languages, whose `note` names each language note.
|
|
102
|
+
*/
|
|
103
|
+
export function fillWrittenIn(records, config) {
|
|
104
|
+
const maps = mapsFromRecords(records);
|
|
105
|
+
const scripts = writtenInOf(languageRecordsByName(records, config), maps);
|
|
106
|
+
if (!scripts.size) return;
|
|
107
|
+
for (const record of records) {
|
|
108
|
+
if (!record || !isNoteRecord(record) || !Array.isArray(record.runs)) continue;
|
|
109
|
+
for (const run of record.runs) {
|
|
110
|
+
const filled = fromWrittenIn(
|
|
111
|
+
{
|
|
112
|
+
lang: run.lang ?? undefined,
|
|
113
|
+
map: run.map ?? undefined,
|
|
114
|
+
font: run.font ?? undefined,
|
|
115
|
+
},
|
|
116
|
+
scripts,
|
|
117
|
+
maps,
|
|
118
|
+
);
|
|
119
|
+
if (filled.map !== undefined) run.map = filled.map;
|
|
120
|
+
if (filled.font !== undefined) run.font = filled.font;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
83
125
|
/**
|
|
84
126
|
* The containing page's NOTE identity.
|
|
85
127
|
*
|
|
@@ -0,0 +1,67 @@
|
|
|
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 direction a script is written in.** A script note states it beside its
|
|
16
|
+
* faces:
|
|
17
|
+
*
|
|
18
|
+
* ```yaml
|
|
19
|
+
* data:
|
|
20
|
+
* script:
|
|
21
|
+
* fonts: [teng-chou]
|
|
22
|
+
* emphasis: rubric
|
|
23
|
+
* direction: vertical-rl
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `vertical-rl` is a script written in columns, top to bottom, the columns
|
|
27
|
+
* read from the right. A script that states no direction is written in lines.
|
|
28
|
+
* Markup never states a direction: a `scroll` fence takes its script's, and is
|
|
29
|
+
* refused in a script written in lines; see
|
|
30
|
+
* {@link module:engine/content-scroll}.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
36
|
+
import { SCRIPT_DIRECTIONS, SCRIPT_SUBTYPE } from "./script-emphasis-terms.mjs";
|
|
37
|
+
|
|
38
|
+
export { SCRIPT_DIRECTIONS } from "./script-emphasis-terms.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Each face's direction, from the script notes among the index records. The
|
|
42
|
+
* first note to list a face keeps it; a face whose note states no direction,
|
|
43
|
+
* or one the build does not know, is absent.
|
|
44
|
+
*
|
|
45
|
+
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
46
|
+
* @returns {Map<string, "vertical-rl">} The directions, by face slug.
|
|
47
|
+
*/
|
|
48
|
+
export function directionsFromRecords(records) {
|
|
49
|
+
const out = new Map();
|
|
50
|
+
const listed = new Set();
|
|
51
|
+
for (const record of records ?? []) {
|
|
52
|
+
if (!record || !isNoteRecord(record)) continue;
|
|
53
|
+
if (
|
|
54
|
+
record.type !== "skill" ||
|
|
55
|
+
String(record.subType ?? "").toLowerCase() !== SCRIPT_SUBTYPE
|
|
56
|
+
)
|
|
57
|
+
continue;
|
|
58
|
+
const script = record.data?.script;
|
|
59
|
+
if (!script || typeof script !== "object" || !Array.isArray(script.fonts)) continue;
|
|
60
|
+
for (const slug of script.fonts) {
|
|
61
|
+
if (typeof slug !== "string" || listed.has(slug)) continue;
|
|
62
|
+
listed.add(slug);
|
|
63
|
+
if (SCRIPT_DIRECTIONS.includes(script.direction)) out.set(slug, script.direction);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* **The words a script note and a language note state emphasis
|
|
16
|
-
* the vocabulary as it is declared, so this module imports
|
|
17
|
-
* checks and the conventions' use are in
|
|
15
|
+
* **The words a script note and a language note state emphasis and direction
|
|
16
|
+
* by.** Read by the vocabulary as it is declared, so this module imports
|
|
17
|
+
* nothing; the checks and the conventions' use are in
|
|
18
|
+
* {@link module:engine/script-emphasis} and {@link module:engine/script-direction}.
|
|
18
19
|
*
|
|
19
20
|
* @module
|
|
20
21
|
*/
|
|
@@ -34,5 +35,12 @@ export const EMPHASIS_CLASSES = Object.freeze({
|
|
|
34
35
|
*/
|
|
35
36
|
export const LANGUAGE_EMPHASES = Object.freeze(["words", ...EMPHASIS_CONVENTIONS]);
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The directions a script states it is written in. `vertical-rl` is columns,
|
|
40
|
+
* top to bottom, read from the right; a script stating none is written in
|
|
41
|
+
* lines.
|
|
42
|
+
*/
|
|
43
|
+
export const SCRIPT_DIRECTIONS = Object.freeze(["vertical-rl"]);
|
|
44
|
+
|
|
37
45
|
/** The `subType` of a script note. */
|
|
38
46
|
export const SCRIPT_SUBTYPE = "script";
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* **Delivering the installed faces to each target.** A consumer that installs
|
|
16
|
-
* `@heroiclands/fonts` and sets text in one of its faces with `font=`
|
|
17
|
-
* fonts on the site, in the Foundry
|
|
18
|
-
* nothing ships and nothing links to it.
|
|
16
|
+
* `@heroiclands/fonts` and sets text in one of its faces, with `font=` or by a
|
|
17
|
+
* language note's `data.writtenIn`, gets the fonts on the site, in the Foundry
|
|
18
|
+
* module and in its books; without both, nothing ships and nothing links to it.
|
|
19
19
|
*
|
|
20
20
|
* - **Site and Foundry** receive `fonts.css`, listing only the WOFF2 sources,
|
|
21
21
|
* the WOFF2 files and `LICENSE`.
|
|
@@ -27,9 +27,12 @@
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
|
|
30
|
+
import YAML from "yaml";
|
|
31
|
+
|
|
30
32
|
import { baseStyleLayer } from "./base-styles.mjs";
|
|
31
33
|
import { scanRuns } from "./content-scripts.mjs";
|
|
32
34
|
import { FONTS_MANIFEST, resolveScriptFonts } from "./script-fonts.mjs";
|
|
35
|
+
import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Where the files land in the staged Foundry module, relative to its root.
|
|
@@ -65,10 +68,37 @@ const LICENSE = "LICENSE";
|
|
|
65
68
|
/** The faces each configuration's notes set text in, found once. */
|
|
66
69
|
const usedFaces = new WeakMap();
|
|
67
70
|
|
|
71
|
+
/** A note's frontmatter block. */
|
|
72
|
+
const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The face a language note names in `data.writtenIn`, where it is installed.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} source - The note's file.
|
|
78
|
+
* @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
|
|
79
|
+
* @returns {string|undefined} The face's slug.
|
|
80
|
+
*/
|
|
81
|
+
function writtenInFace(source, installed) {
|
|
82
|
+
const yaml = FRONTMATTER.exec(source)?.[1];
|
|
83
|
+
if (yaml === undefined) return undefined;
|
|
84
|
+
let fm;
|
|
85
|
+
try {
|
|
86
|
+
fm = YAML.parse(yaml);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (fm?.type !== "skill" || String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
|
|
91
|
+
return undefined;
|
|
92
|
+
const font = fm.data?.writtenIn?.font;
|
|
93
|
+
return typeof font === "string" && installed.faces.has(font) ? font : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
/**
|
|
69
|
-
* The faces the package's notes set text in
|
|
70
|
-
*
|
|
71
|
-
*
|
|
97
|
+
* The faces the package's notes set text in, read from the notes
|
|
98
|
+
* themselves: every Markdown file under the content tree that writes `font=`
|
|
99
|
+
* is scanned for its runs, and the face every language note names in
|
|
100
|
+
* `data.writtenIn` counts, since a run marked with the language alone is set
|
|
101
|
+
* in it.
|
|
72
102
|
*
|
|
73
103
|
* @param {DeliveryConfig} config - The resolved configuration.
|
|
74
104
|
* @param {import("./script-fonts.mjs").ScriptFonts} installed - The fonts package.
|
|
@@ -90,10 +120,13 @@ export function facesUsed(config, installed) {
|
|
|
90
120
|
if (entry.isDirectory()) walk(full);
|
|
91
121
|
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
92
122
|
const source = fs.readFileSync(full, "utf8");
|
|
123
|
+
if (/^\s*writtenIn:/m.test(source)) {
|
|
124
|
+
const face = writtenInFace(source, installed);
|
|
125
|
+
if (face) used.add(face);
|
|
126
|
+
}
|
|
93
127
|
if (!/\bfont=/.test(source)) continue;
|
|
94
|
-
const body = source.replace(
|
|
95
|
-
|
|
96
|
-
(frontmatter) => frontmatter.replace(/[^\r\n]/g, " "),
|
|
128
|
+
const body = source.replace(FRONTMATTER, (frontmatter) =>
|
|
129
|
+
frontmatter.replace(/[^\r\n]/g, " "),
|
|
97
130
|
);
|
|
98
131
|
const ctx = { languages: config.languages ?? {}, faces: installed.faces };
|
|
99
132
|
for (const run of scanRuns(body, ctx).runs)
|