@heroiclands/package-build 25.6.1 → 25.6.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 25.6.2
4
+
5
+ **Languages and scripts**
6
+
7
+ - Encoding documents can choose a mapping according to the letter before it, as well as the following letter and word position.
8
+
3
9
  ## 25.6.1
4
10
 
5
11
  **Languages and scripts**
package/docs/api.md CHANGED
@@ -2891,8 +2891,8 @@ expose their schema `key`.
2891
2891
  Encoding rules default to exact, uniquely decodable outputs. A note may state
2892
2892
  `rules.roundTrip: canonical` to permit aliases and empty code sequences;
2893
2893
  `decodeText` then returns the authored conventional reading without restoring
2894
- erased Roman characters. Contextual rules accept `before: vowel|consonant`
2895
- and `at: start|end|line-start`; supplied conditions combine with AND. Accent
2894
+ erased Roman characters. Contextual rules accept `after` (one exact NFC Unicode letter, `vowel` or
2895
+ `consonant`), `before: vowel|consonant` and `at: start|end|line-start`; supplied conditions combine with AND. `after` governs forward encoding only. Accent
2896
2896
  permission blocks accept explicit Boolean values. See [Encoding text](encoding-core.md).
2897
2897
 
2898
2898
  ### `engine.encodingLanguage`
@@ -55,7 +55,7 @@ second behaviour.
55
55
  | `vowel-groups` | `{reading: established, allow-splitting: false, unrecognized-adjacent-vowels: separate, groups: [...]}`; the explicit group list determines where an acute belongs on its first vowel. |
56
56
  | `diaeresis` | `{allowed: boolean}`; false forbids diaeresis characters; true derives plain-vowel mappings and restores splits of distinctly mapped declared groups. |
57
57
  | `grave-accent` | `{allowed: boolean}` permits or forbids explicitly mapped grave-accent characters. |
58
- | `contextual-mappings` | A list of `{match, emit, before?, at?}`; following-letter or source-position conditions precede unconditional mappings. |
58
+ | `contextual-mappings` | A list of `{match, emit, after?, before?, at?}`; preceding-letter, following-letter or source-position conditions precede unconditional mappings. |
59
59
  | `y` | `{kind: consonant, sound: "y in yes", vowel-reading: false}`; y cannot also appear in `data.vowels`. |
60
60
 
61
61
  The accent blocks accept an explicit Boolean `allowed`. False forbids that
@@ -80,13 +80,28 @@ empty outputs or store invisible metadata to restore the original spelling.
80
80
  A language's authoritative vocabulary can recover fuller word spellings.
81
81
  Shared word spaces remain spaces in both modes.
82
82
 
83
- Contextual entries use `{match, emit, before?, at?}` and require at least one
84
- condition. `before` is `vowel` or `consonant`; `at` is `start`, `end` or
83
+ Contextual entries use `{match, emit, after?, before?, at?}` and require at least one
84
+ condition. `after` tests the immediately preceding Unicode scalar in the NFC
85
+ matching stream (including derived diaeresis-to-plain vowel projection). It is
86
+ `vowel`, `consonant`, or one literal NFC Unicode letter, compared exactly and
87
+ case-sensitively; astral letters are permitted. Category conditions require
88
+ `data.vowels`; a literal does not. Neither category matches a missing predecessor, punctuation or space. A literal
89
+ matches only that letter. `after` is a
90
+ positive condition, so refusing an unlisted predecessor requires authored
91
+ allowed alternatives with no unconditional fallback. For example,
92
+ `{match: k, after: a, emit: K_AFTER_A}` selects that font code after `a`.
93
+
94
+ `after` governs forward encoding only, like `at`; decoding does not validate a
95
+ preceding-source condition. Existing inverse `before` checks remain strict in
96
+ exact mode. Source validation permits spellings without mapping coverage for
97
+ authoritative native entries; `encodeText` refuses uncovered source text.
98
+
99
+ `before` is `vowel` or `consonant`; `at` is `start`, `end` or
85
100
  `line-start`. `start` and `end` refer to word boundaries. A consonant is a
86
101
  letter outside the declared `data.vowels`. Line start follows a newline or
87
102
  verse slash and permits preceding indentation carets. Multiple conditions
88
103
  combine with AND. Entries for the same source may use different conditions;
89
- repeating the same source and condition tuple is an error. Equal-length
104
+ repeating the same `(match, after, before, at)` tuple is an error. Equal-length
90
105
  contextual matches retain authored order.
91
106
 
92
107
  ```yaml
@@ -153,8 +153,14 @@ An encoding's output must decode unambiguously, including across adjacent
153
153
  mappings and spaces. The compiler refuses indistinguishable spellings rather
154
154
  than choosing a guessed reading. See [the executable rules](../encoding-core.md).
155
155
 
156
- Mappings match longest first; contextual before-vowel rules precede their
157
- unconditional alternatives. A case rule may allow lowercase and initial-capital
156
+ Mappings match longest first; contextual rules precede their unconditional
157
+ alternatives. A contextual mapping may use `after: vowel`, `after: consonant`
158
+ or one exact NFC letter such as `after: a` to test the preceding source letter.
159
+ It may combine this with `before` and `at`; all supplied conditions must hold.
160
+ The categories use `data.vowels`. These positive conditions need explicit
161
+ allowed alternatives to refuse a predecessor, with no unconditional fallback.
162
+ `after` governs encoding only; see [the executable rules](../encoding-core.md)
163
+ for normalization and decoding details. A case rule may allow lowercase and initial-capital
158
164
  groups while refusing other mixed or uppercase group spellings. Encoding keeps
159
165
  authored case and never rewrites a word's Roman spelling.
160
166
 
@@ -8352,8 +8352,9 @@ did not foresee. Holes found in play are fixed in the scenario itself.
8352
8352
  | `rules.diaeresis.allowed` | true or false | False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading. |
8353
8353
  | `rules.grave-accent` | a map | Whether grave-accent mappings are allowed. |
8354
8354
  | `rules.grave-accent.allowed` | true or false | False forbids the accent; true permits explicitly mapped and accepted characters. |
8355
- | `rules.contextual-mappings` | a list of conditional mappings | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
8355
+ | `rules.contextual-mappings` | a list of conditional mappings | Alternatives conditioned on the preceding letter, following letter or source position, applied before unconditional mappings. |
8356
8356
  | `rules.contextual-mappings[].match` | a string | The source sequence. |
8357
+ | `rules.contextual-mappings[].after` | one NFC Unicode letter, vowel or consonant | Match after one exact NFC Unicode letter, a declared vowel or another letter. |
8357
8358
  | `rules.contextual-mappings[].before` | a string | Match before a declared vowel or another letter. |
8358
8359
  | `rules.contextual-mappings[].at` | a string | Match at word start, word end or line start. |
8359
8360
  | `rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | The font codes written by this alternative. |
@@ -1054,8 +1054,9 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
1054
1054
  | `data.rules.diaeresis.allowed` | true or false | yes | False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading. |
1055
1055
  | `data.rules.grave-accent` | a map | no | Whether grave-accent mappings are allowed. |
1056
1056
  | `data.rules.grave-accent.allowed` | true or false | yes | False forbids the accent; true permits explicitly mapped and accepted characters. |
1057
- | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the following letter or source position, applied before unconditional mappings. |
1057
+ | `data.rules.contextual-mappings` | a list of conditional mappings | no | Alternatives conditioned on the preceding letter, following letter or source position, applied before unconditional mappings. |
1058
1058
  | `data.rules.contextual-mappings[].match` | a string | yes | The source sequence. |
1059
+ | `data.rules.contextual-mappings[].after` | one NFC Unicode letter, vowel or consonant | no | Match after one exact NFC Unicode letter, a declared vowel or another letter. |
1059
1060
  | `data.rules.contextual-mappings[].before` | a string | no | Match before a declared vowel or another letter. |
1060
1061
  | `data.rules.contextual-mappings[].at` | a string | no | Match at word start, word end or line start. |
1061
1062
  | `data.rules.contextual-mappings[].emit` | a glyph code or list of codes (empty only with canonical round trips) | yes | The font codes written by this alternative. |
@@ -16,20 +16,20 @@
16
16
  /**
17
17
  * Check codeword integrity with a trie and finite Sardinas–Patterson residuals.
18
18
  * Source-equivalent compositions are redundant rules, not distinct spellings.
19
- * @param {Array<{from:string,to:string,key:string,before?:string,at?:string}>} rows - Validated ordinary rows.
19
+ * @param {Array<{from:string,to:string,key:string,after?:string,before?:string,at?:string}>} rows - Validated ordinary rows.
20
20
  * @param {(key:string,message:string)=>never} fault - Positioned schema-error constructor.
21
21
  */
22
22
  export function assertUniqueOutputs(rows, fault) {
23
23
  const bySource = new Map();
24
24
  for (const row of rows) {
25
- if (row.before || row.at) continue;
25
+ if (row.after || row.before || row.at) continue;
26
26
  const first = [...row.from][0];
27
27
  if (!bySource.has(first)) bySource.set(first, []);
28
28
  bySource.get(first).push(row);
29
29
  }
30
30
  for (const group of bySource.values()) group.sort((a, b) => b.from.length - a.from.length);
31
31
  const redundant = (row) => {
32
- if (row.before || row.at || [...row.from].length < 2) return false;
32
+ if (row.after || row.before || row.at || [...row.from].length < 2) return false;
33
33
  let at = 0,
34
34
  output = "";
35
35
  while (at < row.from.length) {
@@ -271,11 +271,25 @@ export function templateProgram(data, options, fault) {
271
271
  const seen = new Set();
272
272
  for (const [i, row] of rules["contextual-mappings"].entries()) {
273
273
  const key = `rules.contextual-mappings.${i}`;
274
- closed(row, ["match", "before", "at", "emit"], key);
274
+ closed(row, ["match", "after", "before", "at", "emit"], key);
275
275
  if (!scalar(row.match) || !row.match || row.match !== row.match.normalize("NFC"))
276
276
  fault(`${key}.match`, "A contextual match must be nonempty NFC text");
277
- if (row.before === undefined && row.at === undefined)
278
- fault(key, "A contextual mapping requires before or at");
277
+ if (row.after === undefined && row.before === undefined && row.at === undefined)
278
+ fault(key, "A contextual mapping requires after, before or at");
279
+ if (row.after !== undefined) {
280
+ if (
281
+ !["vowel", "consonant"].includes(row.after) &&
282
+ (typeof row.after !== "string" ||
283
+ !/^\p{L}$/u.test(row.after) ||
284
+ row.after !== row.after.normalize("NFC"))
285
+ )
286
+ fault(`${key}.after`, "after is one NFC Unicode letter, vowel or consonant");
287
+ if (["vowel", "consonant"].includes(row.after) && !vowels)
288
+ fault(
289
+ `${key}.after`,
290
+ "After-vowel or after-consonant mappings require declared vowels",
291
+ );
292
+ }
279
293
  if (row.before !== undefined && !["vowel", "consonant"].includes(row.before))
280
294
  fault(`${key}.before`, "before is vowel or consonant");
281
295
  if (row.at !== undefined && !["start", "end", "line-start"].includes(row.at))
@@ -285,7 +299,12 @@ export function templateProgram(data, options, fault) {
285
299
  `${key}.before`,
286
300
  "Before-vowel or before-consonant mappings require declared vowels",
287
301
  );
288
- const condition = JSON.stringify([row.match, row.before ?? null, row.at ?? null]);
302
+ const condition = JSON.stringify([
303
+ row.match,
304
+ row.after ?? null,
305
+ row.before ?? null,
306
+ row.at ?? null,
307
+ ]);
289
308
  if (seen.has(condition))
290
309
  fault(
291
310
  `${key}.match`,
@@ -298,6 +317,7 @@ export function templateProgram(data, options, fault) {
298
317
  contextual.push({
299
318
  from: row.match,
300
319
  to,
320
+ ...(row.after !== undefined ? { after: row.after } : {}),
301
321
  ...(row.before !== undefined ? { before: row.before } : {}),
302
322
  ...(row.at !== undefined ? { at: row.at } : {}),
303
323
  });
@@ -114,12 +114,19 @@ export const ENCODING_RULE_FIELDS = Object.freeze([
114
114
  kind: "list",
115
115
  shape: "a list of conditional mappings",
116
116
  describe:
117
- "Alternatives conditioned on the following letter or source position, applied before unconditional mappings.",
117
+ "Alternatives conditioned on the preceding letter, following letter or source position, applied before unconditional mappings.",
118
118
  entries: {
119
119
  kind: "map",
120
120
  shape: "a contextual mapping",
121
121
  fields: [
122
122
  text("match", "The source sequence.", true),
123
+ {
124
+ ...text(
125
+ "after",
126
+ "Match after one exact NFC Unicode letter, a declared vowel or another letter.",
127
+ ),
128
+ shape: "one NFC Unicode letter, vowel or consonant",
129
+ },
123
130
  {
124
131
  ...text("before", "Match before a declared vowel or another letter."),
125
132
  allowed: ["vowel", "consonant"],
@@ -114,14 +114,14 @@ function rows(value, key, vowels, allowEmpty) {
114
114
  if (!Array.isArray(value)) fault(key, `\`${key}\` must be a list of rows`);
115
115
  return value.map((value, order) => {
116
116
  const at = `${key}.${order}`;
117
- let from, to, before, position;
117
+ let from, to, after, before, position;
118
118
  if (Array.isArray(value)) {
119
119
  if (value.length !== 2) fault(at, "A row is two strings, [from, to]");
120
120
  [from, to] = value;
121
121
  } else if (object(value)) {
122
- keys(value, new Set(["from", "to", "before", "at"]), at);
123
- ({ from, to, before, at: position } = value);
124
- } else fault(at, "A row is [from, to] or {from, to, before?, at?}");
122
+ keys(value, new Set(["from", "to", "after", "before", "at"]), at);
123
+ ({ from, to, after, before, at: position } = value);
124
+ } else fault(at, "A row is [from, to] or {from, to, after?, before?, at?}");
125
125
  string(from, `${at}.from`, true);
126
126
  string(to, `${at}.to`);
127
127
  if (from.includes(OPEN) || from.includes(CLOSE))
@@ -130,12 +130,23 @@ function rows(value, key, vowels, allowEmpty) {
130
130
  fault(`${at}.from`, "Whitespace is stated by separator");
131
131
  if (!to && !allowEmpty && from !== " ")
132
132
  fault(`${at}.to`, "An exact row must write a nonempty output");
133
+ if (after !== undefined) {
134
+ if (
135
+ !["vowel", "consonant"].includes(after) &&
136
+ (typeof after !== "string" ||
137
+ !LETTER.test(after) ||
138
+ after !== after.normalize("NFC"))
139
+ )
140
+ fault(`${at}.after`, "after is one NFC Unicode letter, vowel or consonant");
141
+ if (["vowel", "consonant"].includes(after) && !vowels)
142
+ fault(`${at}.after`, "after categories need vowels");
143
+ }
133
144
  if (before !== undefined && !["vowel", "consonant"].includes(before))
134
145
  fault(`${at}.before`, "before is vowel or consonant");
135
146
  if (before && !vowels) fault(`${at}.before`, "before needs vowels");
136
147
  if (position !== undefined && !["start", "end", "line-start"].includes(position))
137
148
  fault(`${at}.at`, "at is start, end or line-start");
138
- return Object.freeze({ from, to, before, at: position, order, key: at });
149
+ return Object.freeze({ from, to, after, before, at: position, order, key: at });
139
150
  });
140
151
  }
141
152
  function indexRows(rows) {
@@ -149,7 +160,8 @@ function indexRows(rows) {
149
160
  group.sort(
150
161
  (a, b) =>
151
162
  length(b.from) - length(a.from) ||
152
- Number(Boolean(b.before || b.at)) - Number(Boolean(a.before || a.at)) ||
163
+ Number(Boolean(b.after || b.before || b.at)) -
164
+ Number(Boolean(a.after || a.before || a.at)) ||
153
165
  a.order - b.order,
154
166
  );
155
167
  return index;
@@ -328,6 +340,15 @@ function beforeHolds(condition, char, vowels) {
328
340
  );
329
341
  }
330
342
  function holds(row, text, start, end, vowels) {
343
+ if (row.after) {
344
+ const preceding = start > 0 ? previousAt(text, start) : undefined;
345
+ if (
346
+ ["vowel", "consonant"].includes(row.after) ?
347
+ !beforeHolds(row.after, preceding, vowels)
348
+ : preceding !== row.after
349
+ )
350
+ return false;
351
+ }
331
352
  if (row.before && !beforeHolds(row.before, firstAt(text, end), vowels)) return false;
332
353
  if (row.at === "line-start") {
333
354
  let slash = text.lastIndexOf("/", start - 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.6.1",
3
+ "version": "25.6.2",
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",
@@ -2,13 +2,14 @@
2
2
  /**
3
3
  * Check codeword integrity with a trie and finite Sardinas–Patterson residuals.
4
4
  * Source-equivalent compositions are redundant rules, not distinct spellings.
5
- * @param {Array<{from:string,to:string,key:string,before?:string,at?:string}>} rows - Validated ordinary rows.
5
+ * @param {Array<{from:string,to:string,key:string,after?:string,before?:string,at?:string}>} rows - Validated ordinary rows.
6
6
  * @param {(key:string,message:string)=>never} fault - Positioned schema-error constructor.
7
7
  */
8
8
  export function assertUniqueOutputs(rows: Array<{
9
9
  from: string;
10
10
  to: string;
11
11
  key: string;
12
+ after?: string;
12
13
  before?: string;
13
14
  at?: string;
14
15
  }>, fault: (key: string, message: string) => never): void;