@datamoc/mw_games 0.8.1 → 0.9.0-alpha

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 (47) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/MWG - Best Practices.md +97 -0
  3. package/MWG - Deep Code and Architecture Review.md +238 -0
  4. package/README.md +7 -4
  5. package/dist/core/Sanitize.d.ts +8 -0
  6. package/dist/core/Sanitize.d.ts.map +1 -1
  7. package/dist/core/Sanitize.js +20 -0
  8. package/dist/core/Sanitize.js.map +1 -1
  9. package/dist/core/Save.d.ts +8 -1
  10. package/dist/core/Save.d.ts.map +1 -1
  11. package/dist/core/Save.js +26 -7
  12. package/dist/core/Save.js.map +1 -1
  13. package/dist/mw_games.global.js +107 -91
  14. package/dist/mwl/compiler.d.ts +7 -7
  15. package/dist/mwl/compiler.js +7 -7
  16. package/dist/mwl/content.d.ts +1 -1
  17. package/dist/mwl/content.js +1 -1
  18. package/dist/mwl/grammar.d.ts +18 -8
  19. package/dist/mwl/grammar.d.ts.map +1 -1
  20. package/dist/mwl/grammar.js +227 -92
  21. package/dist/mwl/grammar.js.map +1 -1
  22. package/dist/mwl/persistence.d.ts +4 -1
  23. package/dist/mwl/persistence.d.ts.map +1 -1
  24. package/dist/mwl/persistence.js +6 -1
  25. package/dist/mwl/persistence.js.map +1 -1
  26. package/dist/mwl/readers.d.ts +2 -2
  27. package/dist/mwl/readers.js +2 -2
  28. package/dist/mwl/report.d.ts +2 -2
  29. package/dist/mwl/report.js +2 -2
  30. package/dist/mwl/runtime.d.ts +30 -1
  31. package/dist/mwl/runtime.d.ts.map +1 -1
  32. package/dist/mwl/runtime.js +204 -209
  33. package/dist/mwl/runtime.js.map +1 -1
  34. package/dist/mwl/schema.d.ts +1 -1
  35. package/dist/mwl/schema.js +1 -1
  36. package/dist/two-d/Game.d.ts +10 -4
  37. package/dist/two-d/Game.d.ts.map +1 -1
  38. package/dist/two-d/Game.js.map +1 -1
  39. package/dist/two-d/render/Types2D.d.ts +6 -4
  40. package/dist/two-d/render/Types2D.d.ts.map +1 -1
  41. package/dist/two-d/render/Types2D.js +6 -4
  42. package/dist/two-d/render/Types2D.js.map +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.d.ts.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/version.js.map +1 -1
  47. package/package.json +5 -2
@@ -35,7 +35,7 @@ export interface MwlEmitOptions {
35
35
  * ```ts
36
36
  * import { compile } from '@datamoc/mw_games/mwl';
37
37
  *
38
- * const game = compile('[game]\nschema=0.1\n[/game]');
38
+ * const game = compile("[{ tag: 'game', schema: 0.1 }]");
39
39
  * console.log(game.schema); // '0.1'
40
40
  * ```
41
41
  */
@@ -51,7 +51,7 @@ export interface MwlSourceFile {
51
51
  * ```ts
52
52
  * import { compileSources } from '@datamoc/mw_games/mwl';
53
53
  *
54
- * const game = compileSources([{ file: 'a.mwl', source: '[game]\nschema=0.1\n[/game]' }]);
54
+ * const game = compileSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
55
55
  * console.log(game.roots.length); // 1
56
56
  * ```
57
57
  */
@@ -65,7 +65,7 @@ export declare function compileSources(files: readonly MwlSourceFile[], options?
65
65
  * ```ts
66
66
  * import { compileNodes, parse } from '@datamoc/mw_games/mwl';
67
67
  *
68
- * const game = compileNodes(parse('[game]\nschema=0.1\n[/game]'));
68
+ * const game = compileNodes(parse("[{ tag: 'game', schema: 0.1 }]"));
69
69
  * console.log(game.schema); // '0.1'
70
70
  * ```
71
71
  */
@@ -89,7 +89,7 @@ export interface MwlCatalog {
89
89
  * ```ts
90
90
  * import { compile, extractCatalog } from '@datamoc/mw_games/mwl';
91
91
  *
92
- * const game = compile('[game]\nschema=0.1\n[/game]');
92
+ * const game = compile("[{ tag: 'game', schema: 0.1 }]");
93
93
  * console.log(extractCatalog(game, { locale: 'fr' }).locale); // 'fr'
94
94
  * ```
95
95
  */
@@ -101,7 +101,7 @@ export declare function extractCatalog(game: MwlCompiledGame, options?: MwlCatal
101
101
  * ```ts
102
102
  * import { compile, emitModule } from '@datamoc/mw_games/mwl';
103
103
  *
104
- * console.log(emitModule(compile('[game]\nschema=0.1\n[/game]')).startsWith('export const gameData')); // true
104
+ * console.log(emitModule(compile("[{ tag: 'game', schema: 0.1 }]")).startsWith('export const gameData')); // true
105
105
  * ```
106
106
  */
107
107
  export declare function emitModule(game: MwlCompiledGame, variable?: string): string;
@@ -112,7 +112,7 @@ export declare function emitModule(game: MwlCompiledGame, variable?: string): st
112
112
  * ```ts
113
113
  * import { compile, emitArtifacts } from '@datamoc/mw_games/mwl';
114
114
  *
115
- * const artifacts = emitArtifacts(compile('[game]\nschema=0.1\n[/game]'));
115
+ * const artifacts = emitArtifacts(compile("[{ tag: 'game', schema: 0.1 }]"));
116
116
  * console.log(artifacts.map((artifact) => artifact.name)); // ['game-data.ts', 'i18n.json', 'assets.json']
117
117
  * ```
118
118
  */
@@ -124,7 +124,7 @@ export declare function emitArtifacts(game: MwlCompiledGame, options?: MwlEmitOp
124
124
  * ```ts
125
125
  * import { compileAndEmitSources } from '@datamoc/mw_games/mwl';
126
126
  *
127
- * const artifacts = compileAndEmitSources([{ file: 'a.mwl', source: '[game]\nschema=0.1\n[/game]' }]);
127
+ * const artifacts = compileAndEmitSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
128
128
  * console.log(artifacts.length); // 3
129
129
  * ```
130
130
  */
@@ -7,7 +7,7 @@ import { validate } from './schema.js';
7
7
  * ```ts
8
8
  * import { compile } from '@datamoc/mw_games/mwl';
9
9
  *
10
- * const game = compile('[game]\nschema=0.1\n[/game]');
10
+ * const game = compile("[{ tag: 'game', schema: 0.1 }]");
11
11
  * console.log(game.schema); // '0.1'
12
12
  * ```
13
13
  */
@@ -22,7 +22,7 @@ export function compile(source, options = {}) {
22
22
  * ```ts
23
23
  * import { compileSources } from '@datamoc/mw_games/mwl';
24
24
  *
25
- * const game = compileSources([{ file: 'a.mwl', source: '[game]\nschema=0.1\n[/game]' }]);
25
+ * const game = compileSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
26
26
  * console.log(game.roots.length); // 1
27
27
  * ```
28
28
  */
@@ -43,7 +43,7 @@ export function compileSources(files, options = {}) {
43
43
  * ```ts
44
44
  * import { compileNodes, parse } from '@datamoc/mw_games/mwl';
45
45
  *
46
- * const game = compileNodes(parse('[game]\nschema=0.1\n[/game]'));
46
+ * const game = compileNodes(parse("[{ tag: 'game', schema: 0.1 }]"));
47
47
  * console.log(game.schema); // '0.1'
48
48
  * ```
49
49
  */
@@ -140,7 +140,7 @@ function stripAssetTransform(value) {
140
140
  * ```ts
141
141
  * import { compile, extractCatalog } from '@datamoc/mw_games/mwl';
142
142
  *
143
- * const game = compile('[game]\nschema=0.1\n[/game]');
143
+ * const game = compile("[{ tag: 'game', schema: 0.1 }]");
144
144
  * console.log(extractCatalog(game, { locale: 'fr' }).locale); // 'fr'
145
145
  * ```
146
146
  */
@@ -157,7 +157,7 @@ export function extractCatalog(game, options = {}) {
157
157
  * ```ts
158
158
  * import { compile, emitModule } from '@datamoc/mw_games/mwl';
159
159
  *
160
- * console.log(emitModule(compile('[game]\nschema=0.1\n[/game]')).startsWith('export const gameData')); // true
160
+ * console.log(emitModule(compile("[{ tag: 'game', schema: 0.1 }]")).startsWith('export const gameData')); // true
161
161
  * ```
162
162
  */
163
163
  export function emitModule(game, variable = 'gameData') {
@@ -170,7 +170,7 @@ export function emitModule(game, variable = 'gameData') {
170
170
  * ```ts
171
171
  * import { compile, emitArtifacts } from '@datamoc/mw_games/mwl';
172
172
  *
173
- * const artifacts = emitArtifacts(compile('[game]\nschema=0.1\n[/game]'));
173
+ * const artifacts = emitArtifacts(compile("[{ tag: 'game', schema: 0.1 }]"));
174
174
  * console.log(artifacts.map((artifact) => artifact.name)); // ['game-data.ts', 'i18n.json', 'assets.json']
175
175
  * ```
176
176
  */
@@ -194,7 +194,7 @@ export function emitArtifacts(game, options = {}) {
194
194
  * ```ts
195
195
  * import { compileAndEmitSources } from '@datamoc/mw_games/mwl';
196
196
  *
197
- * const artifacts = compileAndEmitSources([{ file: 'a.mwl', source: '[game]\nschema=0.1\n[/game]' }]);
197
+ * const artifacts = compileAndEmitSources([{ file: 'a.mwl', source: "[{ tag: 'game', schema: 0.1 }]" }]);
198
198
  * console.log(artifacts.length); // 3
199
199
  * ```
200
200
  */
@@ -133,7 +133,7 @@ export interface MwlContentCatalog {
133
133
  * ```ts
134
134
  * import { compile, contentCatalog } from '@datamoc/mw_games/mwl';
135
135
  *
136
- * console.log(contentCatalog(compile('[game]\nschema=0.1\n[/game]')).campaigns); // []
136
+ * console.log(contentCatalog(compile("[{ tag: 'game', schema: 0.1 }]")).campaigns); // []
137
137
  * ```
138
138
  */
139
139
  export declare function contentCatalog(game: MwlCompiledGame): MwlContentCatalog;
@@ -13,7 +13,7 @@ const enumValue = enumAttribute;
13
13
  * ```ts
14
14
  * import { compile, contentCatalog } from '@datamoc/mw_games/mwl';
15
15
  *
16
- * console.log(contentCatalog(compile('[game]\nschema=0.1\n[/game]')).campaigns); // []
16
+ * console.log(contentCatalog(compile("[{ tag: 'game', schema: 0.1 }]")).campaigns); // []
17
17
  * ```
18
18
  */
19
19
  export function contentCatalog(game) {
@@ -9,7 +9,7 @@ export interface MwlNode {
9
9
  readonly children: readonly MwlNode[];
10
10
  readonly location: MwlLocation;
11
11
  /**
12
- * Names of the attributes that carried the gettext marker `_ "..."`.
12
+ * Names of the attributes that carried the gettext marker `_("...")`.
13
13
  * Present (possibly empty) on nodes parsed from text, so the compiler can
14
14
  * extract exactly the author-marked display strings; absent on nodes built
15
15
  * programmatically by a converter.
@@ -30,7 +30,7 @@ export interface MwlDiagnostic {
30
30
  * import { parse, MwlSyntaxError } from '@datamoc/mw_games/mwl';
31
31
  *
32
32
  * try {
33
- * parse('[game', 'broken.mwl');
33
+ * parse('[{ tag: "game" }]', 'broken.mwl');
34
34
  * } catch (error) {
35
35
  * if (error instanceof MwlSyntaxError) console.log(error.diagnostic.code);
36
36
  * }
@@ -46,36 +46,46 @@ export interface MwlPreprocessOptions {
46
46
  readonly includes?: Readonly<Record<string, string>>;
47
47
  }
48
48
  /**
49
- * Expands MWL macros, `#ifdef` blocks and `#include`s into flat source text.
49
+ * Expands MWL macros, `#ifdef` blocks and `#include`s into flat source text,
50
+ * then rewrites the `_("...")` gettext marker into a form the JSON5 parser
51
+ * accepts. The marker keeps working because this pass already runs before
52
+ * structural parsing, the same reason macros survive a change of grammar.
50
53
  *
51
54
  * @example
52
55
  * ```ts
53
56
  * import { preprocess } from '@datamoc/mw_games/mwl';
54
57
  *
55
- * console.log(preprocess('[game]\nschema=0.1\n[/game]'));
58
+ * console.log(preprocess("[{ tag: 'game', schema: 0.1 }]"));
56
59
  * ```
57
60
  */
58
61
  export declare function preprocess(source: string, options?: MwlPreprocessOptions): string;
62
+ /**
63
+ * Parses MWL source (a top-level array of `{ tag, ...attrs, children }` objects)
64
+ * into nodes. Numbers and booleans become their string form on the node, so the
65
+ * schema, compiler, and runtime see exactly the strings they always have; the
66
+ * JSON5 literal only decides what an author types and what a JSON5-aware tool
67
+ * can check before validation runs.
68
+ */
59
69
  export declare function parse(source: string, file?: string): MwlNode[];
60
70
  /**
61
- * Unquotes one raw attribute value and strips the `_ "..."` gettext marker.
71
+ * Unquotes one raw attribute value and strips the `_("...")` gettext marker.
62
72
  *
63
73
  * @example
64
74
  * ```ts
65
75
  * import { parseValue } from '@datamoc/mw_games/mwl';
66
76
  *
67
- * console.log(parseValue('_ "Hold the line"', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
77
+ * console.log(parseValue('_("Hold the line")', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
68
78
  * ```
69
79
  */
70
80
  export declare function parseValue(raw: string, location: MwlLocation, lineText?: string): string;
71
81
  /**
72
- * True when a raw attribute value carries the gettext marker `_ "..."`.
82
+ * True when a raw attribute value carries the gettext marker `_("...")`.
73
83
  *
74
84
  * @example
75
85
  * ```ts
76
86
  * import { isGettext } from '@datamoc/mw_games/mwl';
77
87
  *
78
- * console.log(isGettext('_ "Hello"')); // true
88
+ * console.log(isGettext('_("Hello")')); // true
79
89
  * console.log(isGettext('Hello')); // false
80
90
  * ```
81
91
  */
@@ -1 +1 @@
1
- {"version":3,"file":"grammar.d.ts","sourceRoot":"","sources":["../../src/mwl/grammar.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACxC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;IAEnC,YAAY,UAAU,EAAE,aAAa,EAMpC;CACD;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAUD;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,CA0ErF;AAkBD,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,SAAU,GAAG,OAAO,EAAE,CA6D/D;AAoCD;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,SAAM,GAAG,MAAM,CAarF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9C"}
1
+ {"version":3,"file":"grammar.d.ts","sourceRoot":"","sources":["../../src/mwl/grammar.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACxC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;IAEnC,YAAY,UAAU,EAAE,aAAa,EAMpC;CACD;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrD;AAUD;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,CA0ErF;AA6ED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,SAAU,GAAG,OAAO,EAAE,CA2B/D;AAsID;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,SAAM,GAAG,MAAM,CAgBrF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9C"}
@@ -1,3 +1,4 @@
1
+ import JSON5 from 'json5';
1
2
  /**
2
3
  * The error a malformed MWL document throws, carrying the diagnostic that describes it.
3
4
  *
@@ -6,7 +7,7 @@
6
7
  * import { parse, MwlSyntaxError } from '@datamoc/mw_games/mwl';
7
8
  *
8
9
  * try {
9
- * parse('[game', 'broken.mwl');
10
+ * parse('[{ tag: "game" }]', 'broken.mwl');
10
11
  * } catch (error) {
11
12
  * if (error instanceof MwlSyntaxError) console.log(error.diagnostic.code);
12
13
  * }
@@ -21,13 +22,16 @@ export class MwlSyntaxError extends Error {
21
22
  }
22
23
  const directiveNames = new Set(['define', 'enddef', 'arg', 'ifdef', 'ifndef', 'else', 'endif', 'include']);
23
24
  /**
24
- * Expands MWL macros, `#ifdef` blocks and `#include`s into flat source text.
25
+ * Expands MWL macros, `#ifdef` blocks and `#include`s into flat source text,
26
+ * then rewrites the `_("...")` gettext marker into a form the JSON5 parser
27
+ * accepts. The marker keeps working because this pass already runs before
28
+ * structural parsing, the same reason macros survive a change of grammar.
25
29
  *
26
30
  * @example
27
31
  * ```ts
28
32
  * import { preprocess } from '@datamoc/mw_games/mwl';
29
33
  *
30
- * console.log(preprocess('[game]\nschema=0.1\n[/game]'));
34
+ * console.log(preprocess("[{ tag: 'game', schema: 0.1 }]"));
31
35
  * ```
32
36
  */
33
37
  export function preprocess(source, options = {}) {
@@ -104,7 +108,7 @@ export function preprocess(source, options = {}) {
104
108
  throw syntax('MWL_ENDDEF', `macro ${macro.name} is missing #enddef`, options.file, lines.length - 1, lines.at(-1) ?? '');
105
109
  if (active.length !== 1)
106
110
  throw syntax('MWL_ENDIF', 'conditional is missing #endif', options.file, lines.length - 1, lines.at(-1) ?? '');
107
- return expandMacros(output.join('\n'), macros, options);
111
+ return rewriteGettextMarkers(expandMacros(output.join('\n'), macros, options));
108
112
  }
109
113
  function expandMacros(source, macros, options) {
110
114
  return source.replace(/\{([^{}]*)\}/g, (whole, inner) => {
@@ -122,134 +126,265 @@ function expandMacros(source, macros, options) {
122
126
  return expandMacros(body, macros, options);
123
127
  });
124
128
  }
125
- export function parse(source, file = '<mwl>') {
126
- const roots = [];
127
- const stack = [];
128
- const lines = source.split(/\r?\n/);
129
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
130
- const raw = lines[lineIndex];
131
- const line = raw.trim();
132
- if (!line || line.startsWith('#'))
133
- continue;
134
- const location = { file, line: lineIndex + 1, column: Math.max(1, raw.indexOf(line) + 1) };
135
- const opening = /^\[([A-Za-z_][\w-]*)\]$/.exec(line);
136
- if (opening) {
137
- const node = { tag: opening[1], attributes: {}, children: [], location, gettext: [] };
138
- if (stack.length)
139
- stack.at(-1).children.push(node);
140
- else
141
- roots.push(node);
142
- stack.push(node);
129
+ /**
130
+ * Rewrites `_("text")` to `{"$gettext": "text"}` before the JSON5 parser runs.
131
+ * The scan skips string literals and `//`/`block` comments, so prose that merely
132
+ * mentions the marker (a dialogue line about the syntax itself) survives intact.
133
+ * An `_("...")` the rewrite does not match, such as one with an unquoted string,
134
+ * is left for the JSON5 parser to reject with its own line and column.
135
+ */
136
+ function rewriteGettextMarkers(source) {
137
+ const marker = /^_\(\s*("(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')\s*\)/;
138
+ let out = '';
139
+ let index = 0;
140
+ while (index < source.length) {
141
+ const character = source[index];
142
+ const comment = source[index + 1];
143
+ if (character === '/' && (comment === '/' || comment === '*')) {
144
+ const end = comment === '/' ? source.indexOf('\n', index) : source.indexOf('*/', index + 2);
145
+ const stop = end < 0 ? source.length : comment === '/' ? end : end + 2;
146
+ out += source.slice(index, stop);
147
+ index = stop;
143
148
  continue;
144
149
  }
145
- const closing = /^\[\/([A-Za-z_][\w-]*)\]$/.exec(line);
146
- if (closing) {
147
- const node = stack.pop();
148
- if (!node || node.tag !== closing[1])
149
- throw new MwlSyntaxError({
150
- code: 'MWL_TAG',
151
- message: `mismatched closing tag ${closing[1]}`,
152
- location,
153
- lineText: raw,
154
- });
150
+ if (character === '"' || character === "'") {
151
+ const stop = copyJson5String(source, index);
152
+ out += source.slice(index, stop);
153
+ index = stop;
155
154
  continue;
156
155
  }
157
- const attribute = /^([A-Za-z_][\w-]*)\s*=\s*(.*)$/.exec(line);
158
- if (attribute && stack.length) {
159
- const node = stack.at(-1);
160
- const block = readBlockValue(attribute[2], location, lines, lineIndex);
161
- if (block) {
162
- node.attributes[attribute[1]] = block.text;
163
- if (block.gettext)
164
- node.gettext.push(attribute[1]);
165
- lineIndex += block.extraLines;
156
+ if (character === '_') {
157
+ const match = marker.exec(source.slice(index));
158
+ if (match) {
159
+ out += `{"$gettext": ${match[1]}}`;
160
+ index += match[0].length;
166
161
  continue;
167
162
  }
168
- if (isGettext(attribute[2]))
169
- node.gettext.push(attribute[1]);
170
- node.attributes[attribute[1]] = parseValue(attribute[2], location, raw);
163
+ }
164
+ out += character;
165
+ index++;
166
+ }
167
+ return out;
168
+ }
169
+ /** Copies one JSON5 string literal verbatim, escapes included, so the rewrite never edits inside it. */
170
+ function copyJson5String(source, from) {
171
+ const quote = source[from];
172
+ let index = from + 1;
173
+ while (index < source.length) {
174
+ const character = source[index];
175
+ if (character === '\\') {
176
+ index += 2;
171
177
  continue;
172
178
  }
179
+ if (character === quote)
180
+ return index + 1;
181
+ // A raw newline ends the literal here; the JSON5 parser reports it.
182
+ if (character === '\n')
183
+ return index;
184
+ index++;
185
+ }
186
+ return index;
187
+ }
188
+ /**
189
+ * Parses MWL source (a top-level array of `{ tag, ...attrs, children }` objects)
190
+ * into nodes. Numbers and booleans become their string form on the node, so the
191
+ * schema, compiler, and runtime see exactly the strings they always have; the
192
+ * JSON5 literal only decides what an author types and what a JSON5-aware tool
193
+ * can check before validation runs.
194
+ */
195
+ export function parse(source, file = '<mwl>') {
196
+ // The marker rewrite is idempotent, so parsing already-preprocessed source
197
+ // (the `compile` path) is a no-op while a direct `parse` of authored source
198
+ // keeps working, the way the old tag parser read `_ "..."` inline.
199
+ const rewritten = rewriteGettextMarkers(source);
200
+ if (rewritten.trim() === '')
201
+ return [];
202
+ let value;
203
+ try {
204
+ value = JSON5.parse(rewritten);
205
+ }
206
+ catch (error) {
207
+ throw json5SyntaxError(error, rewritten, file);
208
+ }
209
+ if (value === undefined)
210
+ return [];
211
+ if (!Array.isArray(value))
212
+ throw new MwlSyntaxError({
213
+ code: 'MWL_DOCUMENT',
214
+ message: 'MWL document must be a top-level array of nodes',
215
+ location: { file, line: 1, column: 1 },
216
+ lineText: rewritten.split(/\r?\n/)[0] ?? '',
217
+ });
218
+ const context = {
219
+ file,
220
+ sites: collectTagSites(rewritten, file),
221
+ cursor: { index: 0 },
222
+ parent: undefined,
223
+ };
224
+ return value.map((entry) => toNode(entry, context));
225
+ }
226
+ function toNode(entry, context) {
227
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
173
228
  throw new MwlSyntaxError({
174
- code: 'MWL_TOKEN',
175
- message: 'expected a tag or key=value attribute',
229
+ code: 'MWL_TAG',
230
+ message: 'MWL node must be an object with a tag',
231
+ location: context.parent ?? { file: context.file, line: 1, column: 1 },
232
+ });
233
+ const record = entry;
234
+ if (typeof record.tag !== 'string' || !namePattern.test(record.tag))
235
+ throw new MwlSyntaxError({
236
+ code: 'MWL_TAG',
237
+ message: 'MWL node needs a tag name',
238
+ location: context.parent ?? { file: context.file, line: 1, column: 1 },
239
+ });
240
+ const tag = record.tag;
241
+ const location = locate(context, tag);
242
+ const rawChildren = record.children ?? [];
243
+ if (!Array.isArray(rawChildren))
244
+ throw new MwlSyntaxError({
245
+ code: 'MWL_TAG',
246
+ message: `children of [${tag}] must be an array`,
176
247
  location,
177
- lineText: raw,
178
248
  });
249
+ const attributes = {};
250
+ const gettext = [];
251
+ for (const [name, raw] of Object.entries(record)) {
252
+ if (name === 'tag' || name === 'children')
253
+ continue;
254
+ attributes[name] = toAttribute(tag, name, raw, location, gettext);
179
255
  }
180
- if (stack.length) {
181
- const node = stack.at(-1);
182
- throw new MwlSyntaxError({ code: 'MWL_TAG', message: `unclosed tag ${node.tag}`, location: node.location });
256
+ const childContext = { ...context, parent: location };
257
+ return {
258
+ tag,
259
+ attributes,
260
+ children: rawChildren.map((child) => toNode(child, childContext)),
261
+ location,
262
+ gettext,
263
+ };
264
+ }
265
+ function toAttribute(tag, name, raw, location, gettext) {
266
+ if (!namePattern.test(name) || name.startsWith('$') || name.startsWith('_'))
267
+ throw new MwlSyntaxError({
268
+ code: 'MWL_ATTRIBUTE',
269
+ message: `unknown attribute "${name}" on [${tag}]`,
270
+ location,
271
+ });
272
+ if (typeof raw === 'string')
273
+ return raw;
274
+ if (typeof raw === 'number' && Number.isFinite(raw))
275
+ return String(raw);
276
+ if (typeof raw === 'boolean')
277
+ return raw ? 'true' : 'false';
278
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
279
+ const keys = Object.keys(raw);
280
+ const text = raw.$gettext;
281
+ if (keys.length === 1 && keys[0] === '$gettext' && typeof text === 'string') {
282
+ gettext.push(name);
283
+ return text;
284
+ }
183
285
  }
184
- return roots;
286
+ throw new MwlSyntaxError({
287
+ code: 'MWL_ATTRIBUTE',
288
+ message: `attribute "${name}" on [${tag}] must be a string, number, boolean, or _("...")`,
289
+ location,
290
+ });
185
291
  }
292
+ const namePattern = /^[A-Za-z_][\w-]*$/;
186
293
  /**
187
- * Reads the `"""..."""` value form, the one that may span lines. Its text is exactly what sits
188
- * between the delimiters, so wrapped message text survives a round trip; a `"""` block may
189
- * carry the `_` gettext marker like any other value. Returns null for every other form, which
190
- * `parseValue` handles on the one line it was given.
294
+ * Every `tag: 'name'` occurrence in source order. Pre-order node traversal visits
295
+ * tags in that same order (a parent literal opens before its children), so `locate`
296
+ * can zip the two sequences and recover a useful line and column per node without
297
+ * a schema-aware parser. A `tag: '...'` string inside prose can shift a location,
298
+ * never a value, which is why this stays best-effort rather than load-bearing.
191
299
  */
192
- function readBlockValue(raw, location, lines, from) {
193
- const gettext = /^_\s*"""/.test(raw);
194
- const source = gettext ? raw.replace(/^_\s*/, '') : raw;
195
- if (!source.startsWith('"""'))
196
- return null;
197
- let text = source.slice(3);
198
- let index = from;
199
- let end = text.indexOf('"""');
200
- while (end < 0) {
201
- index++;
202
- if (index >= lines.length)
203
- throw new MwlSyntaxError({ code: 'MWL_STRING', message: 'unterminated """ value', location });
204
- text += `\n${lines[index]}`;
205
- end = text.indexOf('"""');
300
+ function collectTagSites(source, file) {
301
+ const sites = [];
302
+ const pattern = /\btag\s*:\s*(['"])([A-Za-z_][\w-]*)\1/g;
303
+ let match;
304
+ while ((match = pattern.exec(source)) !== null) {
305
+ sites.push({ tag: match[2], location: offsetToLocation(source, file, match.index) });
206
306
  }
207
- if (text.slice(end + 3).trim() !== '')
208
- throw new MwlSyntaxError({
209
- code: 'MWL_STRING',
210
- message: 'unexpected text after a """ value',
211
- location,
212
- });
213
- return { text: text.slice(0, end), gettext, extraLines: index - from };
307
+ return sites;
308
+ }
309
+ function locate(context, tag) {
310
+ for (let index = context.cursor.index; index < context.sites.length; index++) {
311
+ if (context.sites[index].tag === tag) {
312
+ context.cursor.index = index + 1;
313
+ return context.sites[index].location;
314
+ }
315
+ }
316
+ return context.parent ?? { file: context.file, line: 1, column: 1 };
317
+ }
318
+ function offsetToLocation(source, file, offset) {
319
+ let line = 1;
320
+ let column = 1;
321
+ for (let index = 0; index < offset; index++) {
322
+ if (source[index] === '\n') {
323
+ line++;
324
+ column = 1;
325
+ }
326
+ else
327
+ column++;
328
+ }
329
+ return { file, line, column };
330
+ }
331
+ function json5SyntaxError(error, source, file) {
332
+ const lines = source.split(/\r?\n/);
333
+ const detail = error;
334
+ const line = typeof detail?.lineNumber === 'number' ? detail.lineNumber : 1;
335
+ const column = typeof detail?.columnNumber === 'number' ? detail.columnNumber : 1;
336
+ let message = error instanceof Error ? error.message : String(error);
337
+ if (message.includes("'_'"))
338
+ message += ' (write translatable text as _("..."))';
339
+ return new MwlSyntaxError({
340
+ code: 'MWL_SYNTAX',
341
+ message,
342
+ location: { file, line: Math.max(1, line), column: Math.max(1, column) },
343
+ lineText: lines[Math.max(1, line) - 1],
344
+ });
214
345
  }
215
346
  /**
216
- * Unquotes one raw attribute value and strips the `_ "..."` gettext marker.
347
+ * Unquotes one raw attribute value and strips the `_("...")` gettext marker.
217
348
  *
218
349
  * @example
219
350
  * ```ts
220
351
  * import { parseValue } from '@datamoc/mw_games/mwl';
221
352
  *
222
- * console.log(parseValue('_ "Hold the line"', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
353
+ * console.log(parseValue('_("Hold the line")', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
223
354
  * ```
224
355
  */
225
356
  export function parseValue(raw, location, lineText = raw) {
226
- // The gettext marker is `_` followed by a quoted string. Requiring the
227
- // quote is deliberate: values such as `aliasof=_bas` start with an
228
- // underscore and must survive unchanged.
229
- let value = raw.trim();
230
- const marker = /^_\s*"/.exec(value);
231
- if (marker)
232
- value = value.slice(marker[0].length - 1);
233
- if (value.startsWith('"')) {
234
- if (!value.endsWith('"') || value.length < 2)
235
- throw new MwlSyntaxError({ code: 'MWL_STRING', message: 'unterminated quoted value', location, lineText });
236
- return value.slice(1, -1).replaceAll('""', '"');
357
+ const value = raw.trim();
358
+ const marker = /^_\(\s*([\s\S]*?)\s*\)$/.exec(value);
359
+ const literal = marker ? marker[1] : value;
360
+ if (literal.startsWith('"') || literal.startsWith("'")) {
361
+ try {
362
+ const parsed = JSON5.parse(literal);
363
+ if (typeof parsed === 'string')
364
+ return parsed;
365
+ }
366
+ catch {
367
+ // Fall through to the syntax error below.
368
+ }
369
+ throw new MwlSyntaxError({ code: 'MWL_STRING', message: 'unterminated string value', location, lineText });
237
370
  }
371
+ if (marker)
372
+ throw new MwlSyntaxError({ code: 'MWL_STRING', message: '_("...") needs a quoted string', location, lineText });
238
373
  return value;
239
374
  }
240
375
  /**
241
- * True when a raw attribute value carries the gettext marker `_ "..."`.
376
+ * True when a raw attribute value carries the gettext marker `_("...")`.
242
377
  *
243
378
  * @example
244
379
  * ```ts
245
380
  * import { isGettext } from '@datamoc/mw_games/mwl';
246
381
  *
247
- * console.log(isGettext('_ "Hello"')); // true
382
+ * console.log(isGettext('_("Hello")')); // true
248
383
  * console.log(isGettext('Hello')); // false
249
384
  * ```
250
385
  */
251
386
  export function isGettext(raw) {
252
- return /^_\s*"/.test(raw.trim());
387
+ return /^_\(\s*["']/.test(raw.trim());
253
388
  }
254
389
  function splitWords(value) {
255
390
  const result = [];