@heroiclands/package-build 25.1.1 → 25.1.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.
@@ -80,8 +80,13 @@ function oneOf(form, candidates) {
80
80
  return { ambiguous: { form, glosses: candidates.map(({ gloss }) => gloss) } };
81
81
  }
82
82
 
83
+ /** Each language data's writings, by encoding and face. */
84
+ const writingsFound = new WeakMap();
85
+
83
86
  /**
84
- * The writings of a language's words under one encoding in one face.
87
+ * The writings of a language's words under one encoding in one face. One
88
+ * `data` yields one set of writings per encoding and face, and each word's
89
+ * writing is found once, so `data` must not be modified once it is read here.
85
90
  *
86
91
  * @param {Record<string, any>|undefined} data - The language note's `data`.
87
92
  * @param {string} enc - The encoding's name.
@@ -90,6 +95,34 @@ function oneOf(form, candidates) {
90
95
  * The writing of a run's word, and of the `data.words` entry at an index.
91
96
  */
92
97
  export function writingsOf(data, enc, font) {
98
+ if (!isMap(data)) return findWritings(data, enc, font);
99
+ if (!writingsFound.has(data)) writingsFound.set(data, new Map());
100
+ const byFace = writingsFound.get(data);
101
+ const key = JSON.stringify([enc, font ?? null]);
102
+ if (!byFace.has(key)) {
103
+ const found = findWritings(data, enc, font);
104
+ const words = new Map();
105
+ byFace.set(key, {
106
+ word: (written) => {
107
+ if (!words.has(written)) words.set(written, found.word(written));
108
+ return words.get(written);
109
+ },
110
+ entry: found.entry,
111
+ });
112
+ }
113
+ return byFace.get(key);
114
+ }
115
+
116
+ /**
117
+ * The writings of a language's words under one encoding in one face, found
118
+ * afresh; see {@link writingsOf}.
119
+ *
120
+ * @param {Record<string, any>|undefined} data - The language note's `data`.
121
+ * @param {string} enc - The encoding's name.
122
+ * @param {string|undefined} font - The face.
123
+ * @returns {{word: (word: string) => Writing, entry: (index: number) => Writing}}
124
+ */
125
+ function findWritings(data, enc, font) {
93
126
  const words = listOf(data?.words);
94
127
  const affixes = listOf(data?.affixes).filter(isMap);
95
128
  const grammar = isMap(data?.grammar) ? data.grammar : undefined;
@@ -0,0 +1,58 @@
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 block structure of a Markdown source, in the renderer's grammar.
16
+ *
17
+ * Several scanners ask only where a source's fences, code blocks, raw HTML and
18
+ * quotes lie, each reading block tokens' `type`, `map` and `level` and nothing
19
+ * else. Inline parsing fills only an `inline` token's `children`, so this
20
+ * parser skips it, and the block tokens it yields are the ones a full parse
21
+ * yields. The scanners commonly ask about the same source in turn, so the most
22
+ * recent sources' tokens are kept.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import MarkdownIt from "markdown-it";
28
+
29
+ /** The renderer's block grammar, with inline parsing and its passes switched off. */
30
+ const parser = new MarkdownIt({ html: true });
31
+ parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
32
+
33
+ /** How many recent sources keep their tokens. */
34
+ const RECENT = 16;
35
+
36
+ /** @type {Map<string, import("markdown-it").Token[]>} */
37
+ const recent = new Map();
38
+
39
+ /**
40
+ * The block tokens of a Markdown source. The array is shared between callers
41
+ * and must not be modified; an `inline` token carries no `children`.
42
+ *
43
+ * @param {unknown} source - Markdown.
44
+ * @returns {readonly import("markdown-it").Token[]} The block tokens, in order.
45
+ */
46
+ export function blockTokens(source) {
47
+ const text = String(source ?? "");
48
+ const hit = recent.get(text);
49
+ if (hit) {
50
+ recent.delete(text);
51
+ recent.set(text, hit);
52
+ return hit;
53
+ }
54
+ const tokens = parser.parse(text, {});
55
+ recent.set(text, tokens);
56
+ if (recent.size > RECENT) recent.delete(recent.keys().next().value);
57
+ return tokens;
58
+ }
@@ -773,7 +773,8 @@ const NAMES_FIELD = Object.freeze({
773
773
  'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
774
774
  "maps a gender to a list of given names, " +
775
775
  `${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
776
- "every other key is a list of names a placeholder of the same name draws from. A " +
776
+ "`en` holds groups of English names, and every other key is a list of names a " +
777
+ "placeholder of the same name draws from. A " +
777
778
  "language skill note only. See [name lists](format-details.md#name-lists).",
778
779
  fields: Object.freeze([
779
780
  Object.freeze({
@@ -783,7 +784,8 @@ const NAMES_FIELD = Object.freeze({
783
784
  shape: 'a pattern such as "{given} {clan}", a list of them, or a map of genders to either',
784
785
  describe:
785
786
  "How a name is built: each `{list}` placeholder is filled from the list of " +
786
- "that name, `{given.<gender>}` from that gender's given names, and the text " +
787
+ "that name, `{given.<gender>}` from that gender's given names, `{en.<list>}` from a " +
788
+ "list of English names, and the text " +
787
789
  "around them is written as it stands. A list states every form a name may " +
788
790
  "take; a map keyed by gender states each gender's forms, and a gender with " +
789
791
  "none takes every pattern stated.",
@@ -822,6 +824,24 @@ const NAMES_FIELD = Object.freeze({
822
824
  }),
823
825
  }),
824
826
  }),
827
+ Object.freeze({
828
+ name: "en",
829
+ kind: "map",
830
+ shape: "a map of list names to lists of English names",
831
+ describe:
832
+ "English names, never encoded in the tongue's script. A pattern draws from one " +
833
+ "list as `{en.<list>}`, as `{en.epithet}` does.",
834
+ values: Object.freeze({
835
+ kind: "list",
836
+ shape: "a list of English names",
837
+ describe: "A list of English names a placeholder draws from.",
838
+ entries: Object.freeze({
839
+ kind: "string",
840
+ shape: "a name",
841
+ describe: "One English name, as written.",
842
+ }),
843
+ }),
844
+ }),
825
845
  ]),
826
846
  values: Object.freeze({
827
847
  kind: "list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.1.1",
3
+ "version": "25.1.3",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -225,10 +225,12 @@ export function resolveCultures(records: Array<Record<string, any>>, { contentPa
225
225
  * @param {Map<string, {raw: string}>} [options.sources] - Selected captured source strings.
226
226
  * @param {import("./content-session.mjs").ContentSyntax} [options.syntax] - Session scanner projections.
227
227
  * @param {Iterable<string>} [options.nameTypes] - Additional bare name kinds from configured type declarations.
228
+ * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [options.timings] -
229
+ * Invocation stage accounting, which records each phase of the derivation.
228
230
  * @returns {Array<Record<string, any>>} The records, in a total order that does
229
231
  * not depend on directory-read order.
230
232
  */
231
- export function collectContentIndex(contentBase: string, { contentPackage, currentDate, defaultCalendar, configFile, rootDir, languages, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, }: {
233
+ export function collectContentIndex(contentBase: string, { contentPackage, currentDate, defaultCalendar, configFile, rootDir, languages, skipDirectories, assetsBase, manifest, problems, addressContext, foreignRecord, parsed, sources, syntax, nameTypes, timings, }: {
232
234
  contentPackage: string;
233
235
  skipDirectories: readonly string[];
234
236
  assetsBase?: string | undefined;
@@ -257,6 +259,17 @@ export function collectContentIndex(contentBase: string, { contentPackage, curre
257
259
  }> | undefined;
258
260
  syntax?: import("./content-session.mjs").ContentSyntax | undefined;
259
261
  nameTypes?: Iterable<string> | undefined;
262
+ timings?: {
263
+ sync<T>(name: string, work: () => T): T;
264
+ measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
265
+ start(name: string): () => void;
266
+ hit(name: string): void;
267
+ snapshot(): {
268
+ totalMs: number;
269
+ stages: any;
270
+ hits: any;
271
+ };
272
+ } | undefined;
260
273
  }): Array<Record<string, any>>;
261
274
  /**
262
275
  * Serialize records as JSON Lines.
@@ -301,9 +314,11 @@ export function serializeContentIndex(records: Array<Record<string, any>>): stri
301
314
  * @param {import("./content-session.mjs").ContentSyntax} [opts.syntax] - Session scanner projections.
302
315
  * @param {Iterable<string>} [opts.nameTypes] - Bare name kinds, defaulting to configured item types.
303
316
  * @param {Map<string, object>} [opts.foreignIndex] - Captured dependency address view.
317
+ * @param {ReturnType<typeof import("./content-timings.mjs").createContentTimings>} [opts.timings] -
318
+ * Invocation stage accounting.
304
319
  * @returns {object[]} One record per note, plus one per documentation entry.
305
320
  */
306
- export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectories, problems, parsed, sources, syntax, nameTypes, foreignIndex, }?: {
321
+ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectories, problems, parsed, sources, syntax, nameTypes, foreignIndex, timings, }?: {
307
322
  contentBase?: string | undefined;
308
323
  assetsBase?: string | undefined;
309
324
  config?: object | undefined;
@@ -322,6 +337,17 @@ export function indexRecordsFor({ contentBase, assetsBase, config, skipDirectori
322
337
  syntax?: import("./content-session.mjs").ContentSyntax | undefined;
323
338
  nameTypes?: Iterable<string> | undefined;
324
339
  foreignIndex?: Map<string, object> | undefined;
340
+ timings?: {
341
+ sync<T>(name: string, work: () => T): T;
342
+ measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
343
+ start(name: string): () => void;
344
+ hit(name: string): void;
345
+ snapshot(): {
346
+ totalMs: number;
347
+ stages: any;
348
+ hits: any;
349
+ };
350
+ } | undefined;
325
351
  }): object[];
326
352
  /**
327
353
  * Prepare the complete bodies a content-index publication describes.
@@ -27,6 +27,7 @@ export function createContentSession({ config, contentBase, assetsBase, skipDire
27
27
  timings?: {
28
28
  sync<T>(name: string, work: () => T): T;
29
29
  measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
30
+ start(name: string): () => void;
30
31
  hit(name: string): void;
31
32
  snapshot(): {
32
33
  totalMs: number;
@@ -4,6 +4,13 @@ export function createContentTimings(): {
4
4
  sync<T>(name: string, work: () => T): T;
5
5
  /** @template T @param {string} name @param {() => Promise<T>|T} work @returns {Promise<T>} */
6
6
  measure<T>(name: string, work: () => Promise<T> | T): Promise<T>;
7
+ /**
8
+ * Open a stage that runs until the returned function is called, or
9
+ * until the stage it was opened in ends.
10
+ * @param {string} name
11
+ * @returns {() => void}
12
+ */
13
+ start(name: string): () => void;
7
14
  /** @param {string} name */
8
15
  hit(name: string): void;
9
16
  snapshot(): {
@@ -59,24 +59,11 @@ export function languageNoteFindings(config: {
59
59
  * word's 0-based place in `data.words`, `native` the form's in its `native`.
60
60
  */
61
61
  export function nativeRows(records: Iterable<Record<string, any>>): Array<Record<string, string | number | null>>;
62
- /** Project only conversion-readable language data to a JSON-safe object. */
63
- export function serializeLexicon(lexicon: any): {
64
- language: any;
65
- data: {
66
- grammar?: {
67
- [k: string]: any;
68
- } | undefined;
69
- words: {
70
- [k: string]: any;
71
- }[];
72
- affixes: {
73
- [k: string]: any;
74
- }[];
75
- register: {
76
- [k: string]: any;
77
- }[];
78
- };
79
- } | undefined;
62
+ /**
63
+ * Project only conversion-readable language data to a JSON-safe object. The
64
+ * projection of one lexicon is made once and shared, so it must not be modified.
65
+ */
66
+ export function serializeLexicon(lexicon: any): any;
80
67
  /**
81
68
  * Resolve encoding, face and optional language input without loading files.
82
69
  *
@@ -129,23 +116,7 @@ export function encodingFor(ctx: import("./content-scripts.mjs").RunContext, { e
129
116
  };
130
117
  }>;
131
118
  font: any;
132
- lexicon: {
133
- language: any;
134
- data: {
135
- grammar?: {
136
- [k: string]: any;
137
- } | undefined;
138
- words: {
139
- [k: string]: any;
140
- }[];
141
- affixes: {
142
- [k: string]: any;
143
- }[];
144
- register: {
145
- [k: string]: any;
146
- }[];
147
- };
148
- } | undefined;
119
+ lexicon: any;
149
120
  } | undefined;
150
121
  /** Why a face is incompatible with the selected encoding. */
151
122
  export function fontProblem(encoding: any, font: any): string | undefined;
@@ -104,6 +104,8 @@ export function drawName(names: object, gender: string, next: () => number): str
104
104
  export function nameMisfit(full: string, names: object, gender: string): string | undefined;
105
105
  /** The `skill` subType whose notes may carry name lists. */
106
106
  export const NAMES_SUBTYPE: "language";
107
+ /** The key whose groups of lists hold English names. */
108
+ export const ENGLISH_GROUP: "en";
107
109
  /** The placeholder that draws from the given-name list for a gender. */
108
110
  export const GIVEN_LIST: "given";
109
111
  /**
@@ -115,6 +117,7 @@ export type PatternPart = {
115
117
  } | {
116
118
  list: string;
117
119
  gender?: string;
120
+ sub?: string;
118
121
  };
119
122
  /**
120
123
  * One thing wrong with a language's name lists.
@@ -16,7 +16,9 @@
16
16
  */
17
17
  export function nativeText(natives: unknown, enc: string, font: string | undefined): string | undefined;
18
18
  /**
19
- * The writings of a language's words under one encoding in one face.
19
+ * The writings of a language's words under one encoding in one face. One
20
+ * `data` yields one set of writings per encoding and face, and each word's
21
+ * writing is found once, so `data` must not be modified once it is read here.
20
22
  *
21
23
  * @param {Record<string, any>|undefined} data - The language note's `data`.
22
24
  * @param {string} enc - The encoding's name.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The block tokens of a Markdown source. The array is shared between callers
3
+ * and must not be modified; an `inline` token carries no `children`.
4
+ *
5
+ * @param {unknown} source - Markdown.
6
+ * @returns {readonly import("markdown-it").Token[]} The block tokens, in order.
7
+ */
8
+ export function blockTokens(source: unknown): readonly import("markdown-it").Token[];