@heroiclands/package-build 25.6.0 → 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.
@@ -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);
@@ -470,7 +491,11 @@ function checkGroupCase(enc, groups, text, at, bound) {
470
491
  * @param {string} text
471
492
  * @param {{font?: string, verse?: boolean, start?: number, end?: number}} [options]
472
493
  */
473
- function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text.length } = {}) {
494
+ function encodeRawPieces(
495
+ enc,
496
+ text,
497
+ { font, verse = false, start = 0, end = text.length, stops = [] } = {},
498
+ ) {
474
499
  const groups = tiers(enc, font);
475
500
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
476
501
  if (
@@ -485,9 +510,13 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
485
510
  });
486
511
  const bound = end;
487
512
  const out = [];
488
- let at = start;
513
+ let at = start,
514
+ stop = 0;
489
515
  while (at < bound) {
490
516
  const first = firstAt(text, at);
517
+ while (stop < stops.length && stops[stop] <= at) stop++;
518
+ // A row never spans the start of a diaeresis vowel written by its plain vowel.
519
+ const limit = Math.min(bound, stops[stop] ?? bound);
491
520
  if (first === OPEN) {
492
521
  const close = span(text.slice(0, bound), at, OPEN, CLOSE);
493
522
  out.push(
@@ -545,7 +574,7 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
545
574
  continue;
546
575
  }
547
576
  if (!enc.template) checkGroupCase(enc, groups, text, at, bound);
548
- const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, bound) : null;
577
+ const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, limit) : null;
549
578
  if (!row)
550
579
  refused(
551
580
  enc,
@@ -688,7 +717,13 @@ function sourceDetails(enc, text, options) {
688
717
  pointSlice(normalized.slice(0, bound), at, enc.template.maxVowelGroup)
689
718
  : "";
690
719
  const lower = lookahead.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
691
- const group = enc.template?.vowelGroups.find((value) => lower.startsWith(value));
720
+ const group = enc.template?.vowelGroups.find(
721
+ (value) =>
722
+ lower.startsWith(value) &&
723
+ [...lookahead]
724
+ .slice(1, [...value].length)
725
+ .every((letter) => !/[\u0308\u0300]/u.test(letter.normalize("NFD"))),
726
+ );
692
727
  if (group) {
693
728
  let cursor = at;
694
729
  for (let i = 0; i < [...group].length; i++) {
@@ -713,7 +748,16 @@ function sourceDetails(enc, text, options) {
713
748
  });
714
749
  throw error;
715
750
  }
716
- return projection;
751
+ const letters = enc.template?.apart?.letters;
752
+ if (!letters) return { ...projection, folded: normalized, stops: [] };
753
+ const stops = [];
754
+ let folded = "";
755
+ for (let at = 0; at < normalized.length; at++) {
756
+ const plain = at >= begin && at < bound ? letters[normalized[at]] : undefined;
757
+ if (plain) stops.push(at);
758
+ folded += plain ?? normalized[at];
759
+ }
760
+ return { ...projection, folded, stops };
717
761
  }
718
762
 
719
763
  /**
@@ -736,16 +780,49 @@ export function validateSource(enc, text, options = {}) {
736
780
  */
737
781
  export function encodePieces(enc, text, options = {}) {
738
782
  if (!enc?.template) return encodeRawPieces(enc, text, options);
739
- const { normalized, sourceAt, begin, bound } = sourceDetails(enc, text, options);
783
+ const { normalized, folded, stops, sourceAt, begin, bound } = sourceDetails(enc, text, options);
740
784
  try {
741
- return encodeRawPieces(enc, normalized, { ...options, start: begin, end: bound }).map(
742
- (one) => ({
743
- ...one,
744
- from: text.slice(sourceAt[one.start], sourceAt[one.end]),
745
- start: sourceAt[one.start],
746
- end: sourceAt[one.end],
747
- }),
748
- );
785
+ const pieces = encodeRawPieces(enc, folded, {
786
+ ...options,
787
+ start: begin,
788
+ end: bound,
789
+ stops,
790
+ });
791
+ if (enc.roundTrip === "exact" && stops.length) {
792
+ const native = pieces.map((one) => one.to).join("");
793
+ const decoded = decodeTemplatePieces(enc, native, options);
794
+ let nativeAt = 0;
795
+ for (const one of pieces) {
796
+ const expected = normalized.slice(one.start, one.end);
797
+ const marked = stops.find(
798
+ (at) =>
799
+ at >= one.start &&
800
+ at < one.end &&
801
+ splitsDeclaredGroup(enc, normalized, at, begin, bound),
802
+ );
803
+ if (
804
+ marked !== undefined &&
805
+ !decoded.some(
806
+ (step) =>
807
+ step.start === nativeAt &&
808
+ step.end === nativeAt + one.to.length &&
809
+ step.from === expected,
810
+ )
811
+ )
812
+ textError(
813
+ folded,
814
+ marked,
815
+ "The encoding cannot distinguish this diaeresis split in an exact round trip",
816
+ );
817
+ nativeAt += one.to.length;
818
+ }
819
+ }
820
+ return pieces.map((one) => ({
821
+ ...one,
822
+ from: text.slice(sourceAt[one.start], sourceAt[one.end]),
823
+ start: sourceAt[one.start],
824
+ end: sourceAt[one.end],
825
+ }));
749
826
  } catch (error) {
750
827
  if (error instanceof EncodingError && error.offset !== undefined)
751
828
  throw new EncodingError(error.message, {
@@ -845,6 +922,12 @@ function decodeRawText(enc, text, { font } = {}) {
845
922
  */
846
923
  export function decodeText(enc, text, options = {}) {
847
924
  if (!enc?.template) return decodeRawText(enc, text, options);
925
+ return decodeTemplatePieces(enc, text, options)
926
+ .map((one) => one.from)
927
+ .join("");
928
+ }
929
+
930
+ function decodeTemplatePieces(enc, text, options = {}) {
848
931
  const readings = tiers(enc, options.font)[0].readings;
849
932
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
850
933
  if (!templateReaders.has(enc)) {
@@ -905,7 +988,7 @@ export function decodeText(enc, text, options = {}) {
905
988
  failedContext = Math.min(failedContext, at);
906
989
  continue;
907
990
  }
908
- paths.set(at, { from: row.from, next });
991
+ paths.set(at, { from: row.from, row, next });
909
992
  }
910
993
  }
911
994
  if (!paths.has(0)) {
@@ -950,8 +1033,76 @@ export function decodeText(enc, text, options = {}) {
950
1033
  const out = [];
951
1034
  for (let at = 0; at < text.length;) {
952
1035
  const step = paths.get(at);
953
- out.push(step.from);
1036
+ out.push({ from: step.from, row: step.row, start: at, end: step.next });
954
1037
  at = step.next;
955
1038
  }
956
- return out.join("");
1039
+ const apart = enc.template.apart;
1040
+ if (!apart) return out;
1041
+ return restoreDiaeresis(enc, out, options);
1042
+ }
1043
+
1044
+ /** Restore only joins contradicted by the selected native row boundaries. */
1045
+ function restoreDiaeresis(enc, pieces, options) {
1046
+ const text = pieces.map((one) => one.from).join("");
1047
+ const letters = [];
1048
+ let offset = 0;
1049
+ for (const [part, piece] of pieces.entries()) {
1050
+ for (const char of piece.from) {
1051
+ letters.push({ char, part, offset, literal: !piece.row });
1052
+ offset += char.length;
1053
+ }
1054
+ }
1055
+ const groups = tiers(enc, options.font);
1056
+ for (let i = 0; i < letters.length; i++) {
1057
+ if (letters[i].literal) continue;
1058
+ const group = enc.template.apart.groups.find((value) => {
1059
+ const span = [...value];
1060
+ if (
1061
+ !span.every((char, k) => {
1062
+ const letter = letters[i + k];
1063
+ return (
1064
+ letter &&
1065
+ !letter.literal &&
1066
+ letter.char.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() ===
1067
+ char &&
1068
+ (k === 0 || letter.char.normalize("NFD").length === 1)
1069
+ );
1070
+ })
1071
+ )
1072
+ return false;
1073
+ const row = rowAt(enc, groups, text, letters[i].offset);
1074
+ return row && row.from.toLowerCase() === value;
1075
+ });
1076
+ if (!group) continue;
1077
+ const end = i + [...group].length;
1078
+ let boundary = i + 1;
1079
+ while (boundary < end && letters[boundary].part === letters[i].part) boundary++;
1080
+ if (boundary === end) {
1081
+ i = end - 1;
1082
+ continue;
1083
+ }
1084
+ const letter = letters[boundary];
1085
+ const marked = (letter.char + "\u0308").normalize("NFC");
1086
+ if (enc.template.apart.letters[marked]) {
1087
+ letter.char = marked;
1088
+ i = boundary - 1;
1089
+ }
1090
+ }
1091
+ const output = pieces.map((piece) => ({ ...piece, from: "" }));
1092
+ for (const letter of letters) output[letter.part].from += letter.char;
1093
+ return output;
1094
+ }
1095
+
1096
+ /** Whether a derived mark actually splits one of this note's declared groups. */
1097
+ function splitsDeclaredGroup(enc, text, at, begin, bound) {
1098
+ for (const group of enc.template.apart.groups) {
1099
+ const count = [...group].length;
1100
+ // The supported diaeresis letters are one NFC UTF-16 unit each.
1101
+ for (let start = Math.max(begin, at - count + 1); start < at; start++) {
1102
+ const span = pointSlice(text.slice(0, bound), start, count);
1103
+ if (start + span.length <= at) continue;
1104
+ if (span.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() === group) return true;
1105
+ }
1106
+ }
1107
+ return false;
957
1108
  }
@@ -154,10 +154,10 @@ export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
154
154
 
155
155
  /**
156
156
  * What a written mark says of its letter: `stress` falls on it, `length` holds
157
- * it longer, `apart` says it apart from its neighbour, and `quality` changes
158
- * which vowel is said.
157
+ * it longer, `apart` says it apart from its neighbour, `quality` changes
158
+ * which vowel is said, and `tone` marks a rising or falling pitch.
159
159
  */
160
- export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality"]);
160
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality", "tone"]);
161
161
 
162
162
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
163
163
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "25.6.0",
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",
@@ -261,6 +261,12 @@ export type RunContext = {
261
261
  maxVowelGroup: number;
262
262
  stress: "󠄔" | undefined;
263
263
  diaeresis: boolean;
264
+ apart: Readonly<{
265
+ letters: Readonly<{
266
+ [k: string]: string;
267
+ }>;
268
+ groups: any;
269
+ }> | undefined;
264
270
  grave: boolean;
265
271
  }> | undefined;
266
272
  fonts: readonly any[];
@@ -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;
@@ -90,6 +90,12 @@ export function encodingFor(ctx: import("./content-scripts.mjs").RunContext, { e
90
90
  maxVowelGroup: number;
91
91
  stress: "󠄔" | undefined;
92
92
  diaeresis: boolean;
93
+ apart: Readonly<{
94
+ letters: Readonly<{
95
+ [k: string]: string;
96
+ }>;
97
+ groups: any;
98
+ }> | undefined;
93
99
  grave: boolean;
94
100
  }> | undefined;
95
101
  fonts: readonly any[];
@@ -38,6 +38,12 @@ export function templateProgram(data: any, options: {
38
38
  maxVowelGroup: number;
39
39
  stress: "󠄔" | undefined;
40
40
  diaeresis: boolean;
41
+ apart: Readonly<{
42
+ letters: Readonly<{
43
+ [k: string]: string;
44
+ }>;
45
+ groups: any;
46
+ }> | undefined;
41
47
  grave: boolean;
42
48
  }>;
43
49
  };
@@ -16,6 +16,12 @@ export function compileEncoding(data: any, options?: {
16
16
  maxVowelGroup: number;
17
17
  stress: "󠄔" | undefined;
18
18
  diaeresis: boolean;
19
+ apart: Readonly<{
20
+ letters: Readonly<{
21
+ [k: string]: string;
22
+ }>;
23
+ groups: any;
24
+ }> | undefined;
19
25
  grave: boolean;
20
26
  }> | undefined;
21
27
  fonts: readonly any[];
@@ -59,6 +65,12 @@ export function compileRows(definition: any, metadata?: Partial<ReturnType<typeo
59
65
  maxVowelGroup: number;
60
66
  stress: "󠄔" | undefined;
61
67
  diaeresis: boolean;
68
+ apart: Readonly<{
69
+ letters: Readonly<{
70
+ [k: string]: string;
71
+ }>;
72
+ groups: any;
73
+ }> | undefined;
62
74
  grave: boolean;
63
75
  }> | undefined;
64
76
  fonts: readonly any[];
@@ -96,8 +96,8 @@ export const JOIN_MEETINGS: readonly string[];
96
96
  export const JOIN_RULES: readonly string[];
97
97
  /**
98
98
  * What a written mark says of its letter: `stress` falls on it, `length` holds
99
- * it longer, `apart` says it apart from its neighbour, and `quality` changes
100
- * which vowel is said.
99
+ * it longer, `apart` says it apart from its neighbour, `quality` changes
100
+ * which vowel is said, and `tone` marks a rising or falling pitch.
101
101
  */
102
102
  export const MARK_KINDS: readonly string[];
103
103
  /** How many of a mark a word may carry: `one` at most, or `any` number. */