@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. package/types/engine/written-in.d.mts +76 -0
@@ -0,0 +1,389 @@
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 `scroll` fence**: text in a script written in vertical columns, read
16
+ * from the right, as a handscroll unrolls.
17
+ *
18
+ * ````markdown
19
+ * ```scroll {rows=25 cols=12 lang=tsu map=tengchou}
20
+ * chen teng chou hing
21
+ * tan tsou dou rou
22
+ *
23
+ * gen chei chou tsuen
24
+ * ```
25
+ * ````
26
+ *
27
+ * - **The body is raw.** It is a code fence, so Markdown never reformats it.
28
+ * - **A paragraph is a run of lines**, joined and reflowed as one text; a
29
+ * blank line starts the next paragraph, flush at the top of a fresh column.
30
+ * - **`rows=`** is the characters in a column and **`cols=`** the columns in a
31
+ * panel, each a whole number above zero. Either may be left out.
32
+ * - **`lang=`, `font=` and `map=`** are read by
33
+ * {@link module:engine/content-scripts.scanRuns}, which maps the text and
34
+ * refuses a fence whose script its note does not state is written in
35
+ * columns.
36
+ *
37
+ * {@link renderScroll} writes the HTML the site, the preview and Foundry show:
38
+ * a `.scroll` block whose stylesheet sets `writing-mode: vertical-rl`. The book
39
+ * has no vertical writing and draws the columns as panel images; see
40
+ * {@link scrollPanels} and {@link module:engine/font-outlines}.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import MarkdownIt from "markdown-it";
46
+
47
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
48
+
49
+ /**
50
+ * The keys a scroll fence's attribute block takes. `lang`, `font` and `map`
51
+ * are read by `scanRuns`; `data-pagefind-ignore` is written on the site by its
52
+ * rewrite of a fence set in a face, and reaches the element as written.
53
+ */
54
+ export const SCROLL_KEYS = Object.freeze([
55
+ "rows",
56
+ "cols",
57
+ "lang",
58
+ "font",
59
+ "map",
60
+ "data-pagefind-ignore",
61
+ ]);
62
+
63
+ /** The keys that size a scroll, each a whole number above zero. */
64
+ const SIZE_KEYS = Object.freeze({ rows: "characters", cols: "columns" });
65
+
66
+ /**
67
+ * The distance from one column to the next, in ems: the character's one em
68
+ * and a gap of half an em.
69
+ */
70
+ export const SCROLL_PITCH = 1.5;
71
+
72
+ /**
73
+ * The longest column the book sets when a fence states no `rows=`: each
74
+ * paragraph is one column as long as the longest, up to this many characters.
75
+ */
76
+ export const BOOK_SCROLL_ROWS = 40;
77
+
78
+ /** The class every scroll carries. */
79
+ export const SCROLL_CLASS = "scroll";
80
+
81
+ const structureParser = new MarkdownIt({ html: true });
82
+
83
+ /**
84
+ * One `scroll` fence.
85
+ *
86
+ * @typedef {object} Scroll
87
+ * @property {number} start - The 0-based line of its opening.
88
+ * @property {number} end - The 0-based line of its closing.
89
+ * @property {string} fence - Its fence marker.
90
+ * @property {string} indent - The opening line's indentation.
91
+ * @property {Record<string, string>} attributes - Its `key=value` attributes.
92
+ * @property {string[]} classes - Its classes.
93
+ * @property {number|undefined} rows - Its `rows=`, where it is valid.
94
+ * @property {number|undefined} cols - Its `cols=`, where it is valid.
95
+ * @property {boolean} invalid - Whether its opening has a finding.
96
+ */
97
+
98
+ /**
99
+ * The 1-based columns of `key=` and of its value in an opening line.
100
+ *
101
+ * @param {string} line - The opening line.
102
+ * @param {string} key - The key.
103
+ * @returns {{key: number, value: number}} Its columns, or the brace's when it
104
+ * is not found.
105
+ */
106
+ function keyColumns(line, key) {
107
+ const from = Math.max(0, line.indexOf("{"));
108
+ const match = new RegExp(`[\\s{]${key.replace(/[-]/g, "\\$&")}=`).exec(line.slice(from));
109
+ if (!match) return { key: from + 1, value: from + 1 };
110
+ const at = from + match.index + 1;
111
+ const value = at + key.length + 1;
112
+ return { key: at + 1, value: value + (line[value] === '"' ? 1 : 0) + 1 };
113
+ }
114
+
115
+ /**
116
+ * Read every `scroll` fence in a source, and every fault in its opening.
117
+ *
118
+ * A fence inside a block quote or an HTML block is not read, and neither is a
119
+ * fence quoted inside another fence.
120
+ *
121
+ * @param {string} source - Markdown.
122
+ * @returns {{blocks: Scroll[], errors: Array<{line: number, column: number, message: string}>}}
123
+ * The fences, and each finding at its 1-based position.
124
+ */
125
+ export function scanScrolls(source) {
126
+ const text = String(source ?? "");
127
+ const blocks = [];
128
+ const errors = [];
129
+ if (!/^ *(?:`{3,}|~{3,}) *scroll(?:\s|$)/m.test(text)) return { blocks, errors };
130
+ const lines = text.split("\n");
131
+ const literalLines = new Set();
132
+ for (const token of structureParser.parse(text, {})) {
133
+ if (!token.map || !["blockquote_open", "html_block"].includes(token.type)) continue;
134
+ for (let line = token.map[0]; line < token.map[1]; line++) literalLines.add(line);
135
+ }
136
+ let opening = null;
137
+ for (let i = 0; i < lines.length; i++) {
138
+ if (literalLines.has(i)) continue;
139
+ const match = /^ *(`{3,}|~{3,})(.*)$/.exec(lines[i]);
140
+ if (opening) {
141
+ if (
142
+ match &&
143
+ match[1][0] === opening.fence[0] &&
144
+ match[1].length >= opening.fence.length &&
145
+ !match[2].trim()
146
+ ) {
147
+ if (opening.scroll) {
148
+ const block = { ...opening.scroll, end: i };
149
+ if (!lines.slice(block.start + 1, i).some((line) => line.trim()))
150
+ errors.push({
151
+ line: block.start + 1,
152
+ column: block.indent.length + 1,
153
+ message: "scroll fence is empty",
154
+ });
155
+ blocks.push(block);
156
+ }
157
+ opening = null;
158
+ }
159
+ continue;
160
+ }
161
+ if (!match) continue;
162
+ const info = match[2].trim();
163
+ opening = { fence: match[1], scroll: undefined };
164
+ if (info.split(/\s/)[0] !== "scroll") continue;
165
+ const indent = /^ */.exec(lines[i])[0];
166
+ const scroll = {
167
+ start: i,
168
+ end: i,
169
+ fence: match[1],
170
+ indent,
171
+ attributes: {},
172
+ classes: [],
173
+ rows: undefined,
174
+ cols: undefined,
175
+ invalid: false,
176
+ };
177
+ opening.scroll = scroll;
178
+ const raw = info.slice("scroll".length).trim();
179
+ const at = { line: i + 1, column: indent.length + 1 };
180
+ if (raw && !/^\{[^}\n]*\}$/.test(raw)) {
181
+ errors.push({ ...at, message: "scroll attributes need {…} braces" });
182
+ scroll.invalid = true;
183
+ continue;
184
+ }
185
+ const parsed = parseExtensionAttributes(raw ? raw.slice(1, -1) : "");
186
+ scroll.attributes = parsed.values;
187
+ scroll.classes = parsed.classes;
188
+ const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
189
+ if (parsed.id) problems.push("a scroll fence takes no `#id`");
190
+ for (const message of problems) errors.push({ ...at, message });
191
+ if (problems.length) scroll.invalid = true;
192
+ for (const [key, value] of Object.entries(parsed.values)) {
193
+ const columns = keyColumns(lines[i], key);
194
+ if (!SCROLL_KEYS.includes(key)) {
195
+ errors.push({
196
+ line: i + 1,
197
+ column: columns.key,
198
+ message: `\`${key}=\` is not a scroll attribute; a scroll takes \`rows=\`, \`cols=\`, \`lang=\`, \`font=\` and \`map=\``,
199
+ });
200
+ scroll.invalid = true;
201
+ } else if (Object.hasOwn(SIZE_KEYS, key)) {
202
+ if (/^[1-9]\d*$/.test(value)) scroll[key] = Number(value);
203
+ else {
204
+ errors.push({
205
+ line: i + 1,
206
+ column: columns.value,
207
+ message: `\`${key}=${value}\` is not a number of ${SIZE_KEYS[key]}: \`${key}=\` is a whole number above zero`,
208
+ });
209
+ scroll.invalid = true;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ if (opening?.scroll)
215
+ errors.push({
216
+ line: opening.scroll.start + 1,
217
+ column: opening.scroll.indent.length + 1,
218
+ message: "scroll fence needs a closing code fence",
219
+ });
220
+ errors.sort((a, b) => a.line - b.line || a.column - b.column);
221
+ return { blocks, errors };
222
+ }
223
+
224
+ /**
225
+ * A scroll's paragraphs: each run of nonblank lines, its lines trimmed and
226
+ * joined by a space.
227
+ *
228
+ * @param {string[]} lines - The body's lines.
229
+ * @returns {string[]} The paragraphs, in order.
230
+ */
231
+ export function scrollParagraphs(lines) {
232
+ const paragraphs = [];
233
+ let current = [];
234
+ for (const line of [...lines, ""]) {
235
+ if (line.trim()) {
236
+ current.push(line.trim());
237
+ continue;
238
+ }
239
+ if (current.length) paragraphs.push(current.join(" "));
240
+ current = [];
241
+ }
242
+ return paragraphs;
243
+ }
244
+
245
+ /** The emphasis tags the rewrite of marked text writes, which a scroll keeps. */
246
+ const EMPHASIS_TAG = /(<(?:em|strong)(?: class="emph-(?:rubric|overline)")?>|<\/(?:em|strong)>)/;
247
+
248
+ /** HTML-escape text. */
249
+ const escapeHtml = (value) =>
250
+ String(value)
251
+ .replace(/&/g, "&amp;")
252
+ .replace(/"/g, "&quot;")
253
+ .replace(/</g, "&lt;")
254
+ .replace(/>/g, "&gt;");
255
+
256
+ /**
257
+ * A paragraph's text as HTML: escaped, with the emphasis tags marked text is
258
+ * rewritten with kept as tags.
259
+ *
260
+ * @param {string} text - The paragraph.
261
+ * @returns {string} Its HTML.
262
+ */
263
+ function paragraphHtml(text) {
264
+ return text
265
+ .split(EMPHASIS_TAG)
266
+ .map((part, at) => (at % 2 ? part : escapeHtml(part)))
267
+ .join("");
268
+ }
269
+
270
+ /**
271
+ * Replace each well-formed `scroll` fence with the HTML block the site, the
272
+ * preview and Foundry show: a `<div class="scroll">` holding a `<p>` for each
273
+ * paragraph, its size carried as `--scroll-rows` and `--scroll-cols`. A fence
274
+ * with a finding in its opening is left as a code fence.
275
+ *
276
+ * @param {string} source - Markdown, its marked text already rewritten.
277
+ * @returns {{markdown: string}} The Markdown.
278
+ */
279
+ export function renderScroll(source) {
280
+ const text = String(source ?? "");
281
+ const { blocks } = scanScrolls(text);
282
+ if (!blocks.length) return { markdown: text };
283
+ const lines = text.split("\n");
284
+ const output = [];
285
+ let cursor = 0;
286
+ for (const block of blocks) {
287
+ if (block.invalid) continue;
288
+ output.push(...lines.slice(cursor, block.start));
289
+ const attrs = [`class="${escapeHtml([SCROLL_CLASS, ...block.classes].join(" "))}"`];
290
+ const entries = Object.entries(block.attributes);
291
+ const ordered = [
292
+ ...entries.filter(([key]) => key === "lang"),
293
+ ...entries.filter(([key]) => key !== "lang"),
294
+ ];
295
+ for (const [key, value] of ordered)
296
+ attrs.push(
297
+ `${["lang", "data-pagefind-ignore"].includes(key) ? key : `data-${key}`}="${escapeHtml(value)}"`,
298
+ );
299
+ const style = [
300
+ ...(block.rows === undefined ? [] : [`--scroll-rows: ${block.rows}`]),
301
+ ...(block.cols === undefined ? [] : [`--scroll-cols: ${block.cols}`]),
302
+ ];
303
+ if (style.length) attrs.push(`style="${style.join("; ")}"`);
304
+ let indent = block.indent;
305
+ if (
306
+ indent.length >= 4 &&
307
+ !lines.slice(0, block.start).some((line) => /^ *(?:[-+*]|\d+[.)]) +/.test(line))
308
+ )
309
+ indent = "";
310
+ output.push(
311
+ "",
312
+ `${indent}<div ${attrs.join(" ")}>`,
313
+ ...scrollParagraphs(lines.slice(block.start + 1, block.end)).map(
314
+ (paragraph) => `${indent}<p>${paragraphHtml(paragraph)}</p>`,
315
+ ),
316
+ `${indent}</div>`,
317
+ "",
318
+ );
319
+ cursor = block.end + 1;
320
+ }
321
+ output.push(...lines.slice(cursor));
322
+ return { markdown: output.join("\n") };
323
+ }
324
+
325
+ /**
326
+ * One character of a scroll as the book draws it.
327
+ *
328
+ * @typedef {object} ScrollCell
329
+ * @property {string} text - The character, one code point.
330
+ * @property {string} roman - The source text it was written from: the text of
331
+ * the piece of the map it begins, with the text of any piece before it that
332
+ * wrote nothing; `""` for a later character of the same piece.
333
+ * @property {"rubric"|"overline"} [emphasis] - The convention it is set by.
334
+ */
335
+
336
+ /**
337
+ * A scroll's text, as the book lays it out.
338
+ *
339
+ * @typedef {object} ScrollLayout
340
+ * @property {ScrollCell[][]} paragraphs - Each paragraph's characters.
341
+ * @property {import("./script-fonts.mjs").Face|undefined} face - The face it
342
+ * is drawn in.
343
+ */
344
+
345
+ /**
346
+ * Lay a scroll's paragraphs into columns and the columns into panels.
347
+ *
348
+ * Each paragraph starts a fresh column, filled from the top, `rows` characters
349
+ * to a column; the columns are read from the right and grouped `cols` to a
350
+ * panel. Without `rows`, a column is as long as the longest paragraph, up to
351
+ * {@link BOOK_SCROLL_ROWS}; without `cols`, each column is a panel of its own,
352
+ * so the book sets as many beside one another as fit its measure.
353
+ *
354
+ * @param {ScrollCell[][]} paragraphs - The characters of each paragraph.
355
+ * @param {{rows?: number, cols?: number}} size - The fence's size.
356
+ * @returns {{rows: number, panels: ScrollCell[][][]}} The column length, and
357
+ * each panel's columns, the first column the rightmost.
358
+ */
359
+ export function scrollPanels(paragraphs, { rows, cols } = {}) {
360
+ const longest = Math.max(1, ...paragraphs.map((cells) => cells.length));
361
+ const length = rows ?? Math.min(longest, BOOK_SCROLL_ROWS);
362
+ const columns = [];
363
+ for (const cells of paragraphs)
364
+ for (let at = 0; at < cells.length; at += length)
365
+ columns.push(cells.slice(at, at + length));
366
+ const width = cols ?? 1;
367
+ const panels = [];
368
+ for (let at = 0; at < columns.length; at += width) panels.push(columns.slice(at, at + width));
369
+ return { rows: length, panels };
370
+ }
371
+
372
+ /**
373
+ * The roman text a panel was written from, as its alt text: each paragraph's
374
+ * source text, a space between paragraphs, its spacing collapsed.
375
+ *
376
+ * @param {ScrollCell[][]} panel - The panel's columns.
377
+ * @param {ScrollCell[][]} paragraphs - The scroll's paragraphs, so a column
378
+ * that begins a paragraph is told from one that continues it.
379
+ * @returns {string} The text.
380
+ */
381
+ export function panelRoman(panel, paragraphs) {
382
+ const firsts = new Set(paragraphs.map((cells) => cells[0]));
383
+ let out = "";
384
+ for (const column of panel) {
385
+ if (out && firsts.has(column[0])) out += " ";
386
+ for (const cell of column) out += cell.roman;
387
+ }
388
+ return out.replace(/\s+/g, " ").trim();
389
+ }