@datamoc/mw_games 0.8.2 → 0.9.0
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 +78 -0
- package/README.md +11 -4
- package/dist/core/ActionJournal.d.ts +2 -0
- package/dist/core/ActionJournal.d.ts.map +1 -1
- package/dist/core/ActionJournal.js +6 -0
- package/dist/core/ActionJournal.js.map +1 -1
- package/dist/core/Multiplayer.d.ts +21 -4
- package/dist/core/Multiplayer.d.ts.map +1 -1
- package/dist/core/Multiplayer.js +31 -4
- package/dist/core/Multiplayer.js.map +1 -1
- package/dist/core/State.d.ts +38 -0
- package/dist/core/State.d.ts.map +1 -1
- package/dist/core/State.js +76 -0
- package/dist/core/State.js.map +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/mw_games.global.js +107 -91
- package/dist/mwl/catalog.d.ts +7 -0
- package/dist/mwl/catalog.d.ts.map +1 -1
- package/dist/mwl/catalog.js +0 -0
- package/dist/mwl/catalog.js.map +1 -1
- package/dist/mwl/compiler.d.ts +10 -7
- package/dist/mwl/compiler.d.ts.map +1 -1
- package/dist/mwl/compiler.js +17 -10
- package/dist/mwl/compiler.js.map +1 -1
- package/dist/mwl/content.d.ts +1 -1
- package/dist/mwl/content.js +1 -1
- package/dist/mwl/grammar.d.ts +25 -8
- package/dist/mwl/grammar.d.ts.map +1 -1
- package/dist/mwl/grammar.js +408 -105
- package/dist/mwl/grammar.js.map +1 -1
- package/dist/mwl/hooks.d.ts +35 -2
- package/dist/mwl/hooks.d.ts.map +1 -1
- package/dist/mwl/hooks.js +8 -6
- package/dist/mwl/hooks.js.map +1 -1
- package/dist/mwl/index.d.ts +6 -6
- package/dist/mwl/index.d.ts.map +1 -1
- package/dist/mwl/index.js +3 -3
- package/dist/mwl/index.js.map +1 -1
- package/dist/mwl/persistence.d.ts +18 -1
- package/dist/mwl/persistence.d.ts.map +1 -1
- package/dist/mwl/persistence.js +59 -3
- package/dist/mwl/persistence.js.map +1 -1
- package/dist/mwl/readers.d.ts +39 -2
- package/dist/mwl/readers.d.ts.map +1 -1
- package/dist/mwl/readers.js +71 -2
- package/dist/mwl/readers.js.map +1 -1
- package/dist/mwl/report.d.ts +2 -2
- package/dist/mwl/report.js +2 -2
- package/dist/mwl/runtime.d.ts +46 -2
- package/dist/mwl/runtime.d.ts.map +1 -1
- package/dist/mwl/runtime.js +73 -9
- package/dist/mwl/runtime.js.map +1 -1
- package/dist/mwl/schema.d.ts +32 -1
- package/dist/mwl/schema.d.ts.map +1 -1
- package/dist/mwl/schema.js +58 -3
- package/dist/mwl/schema.js.map +1 -1
- package/dist/simulation/Runtime.d.ts +66 -1
- package/dist/simulation/Runtime.d.ts.map +1 -1
- package/dist/simulation/Runtime.js +131 -3
- package/dist/simulation/Runtime.js.map +1 -1
- package/dist/simulation/index.d.ts +2 -2
- package/dist/simulation/index.d.ts.map +1 -1
- package/dist/simulation/index.js +1 -1
- package/dist/simulation/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/tools/mwl.mjs +85 -4
package/dist/mwl/grammar.js
CHANGED
|
@@ -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('
|
|
34
|
+
* console.log(preprocess("[{ tag: 'game', schema: 0.1 }]"));
|
|
31
35
|
* ```
|
|
32
36
|
*/
|
|
33
37
|
export function preprocess(source, options = {}) {
|
|
@@ -104,152 +108,442 @@ 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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const keyword = parts.find((part) => part.startsWith(`${parameter}=`));
|
|
119
|
-
const value = keyword ? keyword.slice(parameter.length + 1) : (parts[i + 1] ?? '');
|
|
120
|
-
body = body.replaceAll(`{${parameter}}`, value);
|
|
114
|
+
let out = '';
|
|
115
|
+
let index = 0;
|
|
116
|
+
while (index < source.length) {
|
|
117
|
+
const stop = skipStringOrComment(source, index);
|
|
118
|
+
if (stop !== index) {
|
|
119
|
+
out += source.slice(index, stop);
|
|
120
|
+
index = stop;
|
|
121
|
+
continue;
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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('#'))
|
|
123
|
+
const character = source[index];
|
|
124
|
+
if (character !== '{') {
|
|
125
|
+
out += character;
|
|
126
|
+
index++;
|
|
133
127
|
continue;
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
stack.at(-1).children.push(node);
|
|
140
|
-
else
|
|
141
|
-
roots.push(node);
|
|
142
|
-
stack.push(node);
|
|
128
|
+
}
|
|
129
|
+
const close = source.indexOf('}', index + 1);
|
|
130
|
+
if (close < 0) {
|
|
131
|
+
out += character;
|
|
132
|
+
index++;
|
|
143
133
|
continue;
|
|
144
134
|
}
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
135
|
+
const whole = source.slice(index, close + 1);
|
|
136
|
+
const inner = source.slice(index + 1, close);
|
|
137
|
+
const parts = splitWords(inner);
|
|
138
|
+
const definition = macros.get(parts[0]);
|
|
139
|
+
if (definition) {
|
|
140
|
+
let body = definition.body;
|
|
141
|
+
for (let parameterIndex = 0; parameterIndex < definition.parameters.length; parameterIndex++) {
|
|
142
|
+
const parameter = definition.parameters[parameterIndex];
|
|
143
|
+
const keyword = parts.find((part) => part.startsWith(`${parameter}=`));
|
|
144
|
+
const value = keyword ? keyword.slice(parameter.length + 1) : (parts[parameterIndex + 1] ?? '');
|
|
145
|
+
body = body.replaceAll(`{${parameter}}`, value);
|
|
146
|
+
}
|
|
147
|
+
out += expandMacros(body, macros, options);
|
|
148
|
+
}
|
|
149
|
+
else if (options.macroPolicy !== 'ignore' && isMacroInvocation(parts[0])) {
|
|
150
|
+
throw syntaxAt('MWL_MACRO', `unknown macro "${parts[0]}"`, source, options.file, index);
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
out += whole;
|
|
154
|
+
index = close + 1;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function isMacroInvocation(name) {
|
|
159
|
+
return name !== undefined && namePattern.test(name);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Rewrites `_("text")` to `{"$gettext": "text"}` before the JSON5 parser runs.
|
|
163
|
+
* The scan skips string literals and `//`/`block` comments, so prose that merely
|
|
164
|
+
* mentions the marker (a dialogue line about the syntax itself) survives intact.
|
|
165
|
+
* An `_("...")` the rewrite does not match, such as one with an unquoted string,
|
|
166
|
+
* is left for the JSON5 parser to reject with its own line and column.
|
|
167
|
+
*/
|
|
168
|
+
function rewriteGettextMarkers(source) {
|
|
169
|
+
const marker = /^_\(\s*("(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*')\s*\)/;
|
|
170
|
+
let out = '';
|
|
171
|
+
let index = 0;
|
|
172
|
+
while (index < source.length) {
|
|
173
|
+
const stop = skipStringOrComment(source, index);
|
|
174
|
+
if (stop !== index) {
|
|
175
|
+
out += source.slice(index, stop);
|
|
176
|
+
index = stop;
|
|
155
177
|
continue;
|
|
156
178
|
}
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (block.gettext)
|
|
164
|
-
node.gettext.push(attribute[1]);
|
|
165
|
-
lineIndex += block.extraLines;
|
|
179
|
+
const character = source[index];
|
|
180
|
+
if (character === '_') {
|
|
181
|
+
const match = marker.exec(source.slice(index));
|
|
182
|
+
if (match) {
|
|
183
|
+
out += `{"$gettext": ${match[1]}}`;
|
|
184
|
+
index += match[0].length;
|
|
166
185
|
continue;
|
|
167
186
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
}
|
|
188
|
+
out += character;
|
|
189
|
+
index++;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/** Copies one JSON5 string literal verbatim, escapes included, so the rewrite never edits inside it. */
|
|
194
|
+
function copyJson5String(source, from) {
|
|
195
|
+
const quote = source[from];
|
|
196
|
+
let index = from + 1;
|
|
197
|
+
while (index < source.length) {
|
|
198
|
+
const character = source[index];
|
|
199
|
+
if (character === '\\') {
|
|
200
|
+
index += 2;
|
|
171
201
|
continue;
|
|
172
202
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
203
|
+
if (character === quote)
|
|
204
|
+
return index + 1;
|
|
205
|
+
// A raw newline ends the literal here; the JSON5 parser reports it.
|
|
206
|
+
if (character === '\n')
|
|
207
|
+
return index;
|
|
208
|
+
index++;
|
|
179
209
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
210
|
+
return index;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* If `index` starts a JSON5 string or a `//`/`` /* `` comment, returns the index just past it;
|
|
214
|
+
* otherwise returns `index` unchanged. Every hand-rolled scan over raw MWL text (macro
|
|
215
|
+
* expansion, the gettext rewrite, tag-site collection) shares this, so none of them mistakes a
|
|
216
|
+
* `{`, `}`, `tag`, or macro name that only appears inside a string or a comment for the real thing.
|
|
217
|
+
*/
|
|
218
|
+
function skipStringOrComment(source, index) {
|
|
219
|
+
const character = source[index];
|
|
220
|
+
if (character === '"' || character === "'")
|
|
221
|
+
return copyJson5String(source, index);
|
|
222
|
+
const next = source[index + 1];
|
|
223
|
+
if (character === '/' && (next === '/' || next === '*')) {
|
|
224
|
+
const end = next === '/' ? source.indexOf('\n', index) : source.indexOf('*/', index + 2);
|
|
225
|
+
return end < 0 ? source.length : next === '/' ? end : end + 2;
|
|
183
226
|
}
|
|
184
|
-
return
|
|
227
|
+
return index;
|
|
185
228
|
}
|
|
186
229
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
230
|
+
* Parses MWL source (a top-level array of `{ tag, ...attrs, children }` objects, or the
|
|
231
|
+
* equivalent named-node sugar such as `{ unit_type: { id: 'hero' } }`)
|
|
232
|
+
* into nodes. Numbers and booleans become their string form on the node, so the
|
|
233
|
+
* schema, compiler, and runtime see exactly the strings they always have; the
|
|
234
|
+
* JSON5 literal only decides what an author types and what a JSON5-aware tool
|
|
235
|
+
* can check before validation runs.
|
|
191
236
|
*/
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
end = text.indexOf('"""');
|
|
237
|
+
export function parse(source, file = '<mwl>') {
|
|
238
|
+
// The marker rewrite is idempotent, so parsing already-preprocessed source
|
|
239
|
+
// (the `compile` path) is a no-op while a direct `parse` of authored source
|
|
240
|
+
// keeps working, the way the old tag parser read `_ "..."` inline.
|
|
241
|
+
const rewritten = rewriteGettextMarkers(source);
|
|
242
|
+
if (rewritten.trim() === '')
|
|
243
|
+
return [];
|
|
244
|
+
let value;
|
|
245
|
+
try {
|
|
246
|
+
value = JSON5.parse(rewritten);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
throw json5SyntaxError(error, rewritten, file);
|
|
206
250
|
}
|
|
207
|
-
if (
|
|
251
|
+
if (value === undefined)
|
|
252
|
+
return [];
|
|
253
|
+
if (!Array.isArray(value))
|
|
254
|
+
throw new MwlSyntaxError({
|
|
255
|
+
code: 'MWL_DOCUMENT',
|
|
256
|
+
message: 'MWL document must be a top-level array of nodes',
|
|
257
|
+
location: { file, line: 1, column: 1 },
|
|
258
|
+
lineText: rewritten.split(/\r?\n/)[0] ?? '',
|
|
259
|
+
});
|
|
260
|
+
const context = {
|
|
261
|
+
file,
|
|
262
|
+
sites: collectTagSites(rewritten, file),
|
|
263
|
+
cursor: { index: 0 },
|
|
264
|
+
parent: undefined,
|
|
265
|
+
};
|
|
266
|
+
return normalizeNamedDocument(value).map((entry) => toNode(entry, context));
|
|
267
|
+
}
|
|
268
|
+
function normalizeNamedDocument(value) {
|
|
269
|
+
const nodes = [];
|
|
270
|
+
for (const entry of value) {
|
|
271
|
+
const normalized = normalizeNamedNode(entry);
|
|
272
|
+
if (Array.isArray(normalized))
|
|
273
|
+
nodes.push(...normalized);
|
|
274
|
+
else
|
|
275
|
+
nodes.push(normalized);
|
|
276
|
+
}
|
|
277
|
+
return nodes;
|
|
278
|
+
}
|
|
279
|
+
/** Converts schema-neutral `{ tag: { ... } }` sugar into the canonical node envelope. */
|
|
280
|
+
function normalizeNamedNode(value) {
|
|
281
|
+
if (Array.isArray(value))
|
|
282
|
+
return value.flatMap((entry) => normalizeNamedNode(entry));
|
|
283
|
+
if (typeof value !== 'object' || value === null)
|
|
284
|
+
return value;
|
|
285
|
+
const record = value;
|
|
286
|
+
if (typeof record.tag !== 'string') {
|
|
287
|
+
const keys = Object.keys(record);
|
|
288
|
+
if (keys.length !== 1 || !namePattern.test(keys[0]))
|
|
289
|
+
return value;
|
|
290
|
+
const tag = keys[0];
|
|
291
|
+
const body = record[tag];
|
|
292
|
+
if (Array.isArray(body))
|
|
293
|
+
return body.map((entry) => namedNode(tag, entry));
|
|
294
|
+
return namedNode(tag, body);
|
|
295
|
+
}
|
|
296
|
+
const normalized = { ...record };
|
|
297
|
+
const children = [];
|
|
298
|
+
if (Array.isArray(record.children))
|
|
299
|
+
children.push(...record.children.flatMap((child) => normalizeNamedNode(child)));
|
|
300
|
+
for (const [key, child] of Object.entries(record)) {
|
|
301
|
+
if (key === 'tag' || key === 'children' || !namePattern.test(key) || !isNamedChild(child))
|
|
302
|
+
continue;
|
|
303
|
+
const entries = Array.isArray(child) ? child : [child];
|
|
304
|
+
children.push(...entries.map((entry) => namedNode(key, entry)));
|
|
305
|
+
delete normalized[key];
|
|
306
|
+
}
|
|
307
|
+
if (children.length)
|
|
308
|
+
normalized.children = children;
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
function namedNode(tag, body) {
|
|
312
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
313
|
+
return { tag, value: body };
|
|
314
|
+
return normalizeNamedNode({ tag, ...body });
|
|
315
|
+
}
|
|
316
|
+
function isNamedChild(value) {
|
|
317
|
+
return ((typeof value === 'object' && value !== null && !Array.isArray(value) && !('$gettext' in value)) ||
|
|
318
|
+
(Array.isArray(value) &&
|
|
319
|
+
value.every((entry) => typeof entry === 'object' && entry !== null && !Array.isArray(entry))));
|
|
320
|
+
}
|
|
321
|
+
function toNode(entry, context) {
|
|
322
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
323
|
+
throw new MwlSyntaxError({
|
|
324
|
+
code: 'MWL_TAG',
|
|
325
|
+
message: 'MWL node must be an object with a tag',
|
|
326
|
+
location: context.parent ?? { file: context.file, line: 1, column: 1 },
|
|
327
|
+
});
|
|
328
|
+
const record = entry;
|
|
329
|
+
if (typeof record.tag !== 'string' || !namePattern.test(record.tag))
|
|
330
|
+
throw new MwlSyntaxError({
|
|
331
|
+
code: 'MWL_TAG',
|
|
332
|
+
message: 'MWL node needs a tag name',
|
|
333
|
+
location: context.parent ?? { file: context.file, line: 1, column: 1 },
|
|
334
|
+
});
|
|
335
|
+
const tag = record.tag;
|
|
336
|
+
const site = locate(context, tag);
|
|
337
|
+
const location = site.location;
|
|
338
|
+
const rawChildren = record.children ?? [];
|
|
339
|
+
if (!Array.isArray(rawChildren))
|
|
208
340
|
throw new MwlSyntaxError({
|
|
209
|
-
code: '
|
|
210
|
-
message:
|
|
341
|
+
code: 'MWL_TAG',
|
|
342
|
+
message: `children of [${tag}] must be an array`,
|
|
211
343
|
location,
|
|
212
344
|
});
|
|
213
|
-
|
|
345
|
+
const attributes = {};
|
|
346
|
+
const attributeLocations = {};
|
|
347
|
+
const valueLocations = {};
|
|
348
|
+
const gettext = [];
|
|
349
|
+
for (const [name, raw] of Object.entries(record)) {
|
|
350
|
+
if (name === 'tag' || name === 'children')
|
|
351
|
+
continue;
|
|
352
|
+
attributes[name] = toAttribute(tag, name, raw, location, gettext);
|
|
353
|
+
const property = site.properties?.get(name);
|
|
354
|
+
if (property) {
|
|
355
|
+
attributeLocations[name] = property.location;
|
|
356
|
+
valueLocations[name] = property.valueLocation;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const childContext = { ...context, parent: location };
|
|
360
|
+
const node = {
|
|
361
|
+
tag,
|
|
362
|
+
attributes,
|
|
363
|
+
children: rawChildren.map((child) => toNode(child, childContext)),
|
|
364
|
+
location,
|
|
365
|
+
gettext,
|
|
366
|
+
};
|
|
367
|
+
Object.defineProperties(node, {
|
|
368
|
+
attributeLocations: { value: attributeLocations, enumerable: false },
|
|
369
|
+
valueLocations: { value: valueLocations, enumerable: false },
|
|
370
|
+
});
|
|
371
|
+
return node;
|
|
372
|
+
}
|
|
373
|
+
function toAttribute(tag, name, raw, location, gettext) {
|
|
374
|
+
if (!namePattern.test(name) || name.startsWith('$') || name.startsWith('_'))
|
|
375
|
+
throw new MwlSyntaxError({
|
|
376
|
+
code: 'MWL_ATTRIBUTE',
|
|
377
|
+
message: `unknown attribute "${name}" on [${tag}]`,
|
|
378
|
+
location,
|
|
379
|
+
});
|
|
380
|
+
if (typeof raw === 'string')
|
|
381
|
+
return raw;
|
|
382
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
383
|
+
return String(raw);
|
|
384
|
+
if (typeof raw === 'boolean')
|
|
385
|
+
return raw ? 'true' : 'false';
|
|
386
|
+
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
|
|
387
|
+
const keys = Object.keys(raw);
|
|
388
|
+
const text = raw.$gettext;
|
|
389
|
+
if (keys.length === 1 && keys[0] === '$gettext' && typeof text === 'string') {
|
|
390
|
+
gettext.push(name);
|
|
391
|
+
return text;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
throw new MwlSyntaxError({
|
|
395
|
+
code: 'MWL_ATTRIBUTE',
|
|
396
|
+
message: `attribute "${name}" on [${tag}] must be a string, number, boolean, or _("...")`,
|
|
397
|
+
location,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const namePattern = /^[A-Za-z_][\w-]*$/;
|
|
401
|
+
/** Every authored `tag` object and its direct JSON5 properties, in source order. */
|
|
402
|
+
function collectTagSites(source, file) {
|
|
403
|
+
const sites = [];
|
|
404
|
+
const stack = [];
|
|
405
|
+
for (let index = 0; index < source.length;) {
|
|
406
|
+
const stop = skipStringOrComment(source, index);
|
|
407
|
+
if (stop !== index) {
|
|
408
|
+
index = stop;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const character = source[index];
|
|
412
|
+
if (character === '{') {
|
|
413
|
+
const object = { start: index, properties: new Map() };
|
|
414
|
+
stack.push(object);
|
|
415
|
+
index++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (character === '}') {
|
|
419
|
+
stack.pop();
|
|
420
|
+
index++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const key = /[A-Za-z_$][\w$-]*/y;
|
|
424
|
+
key.lastIndex = index;
|
|
425
|
+
const match = key.exec(source);
|
|
426
|
+
if (!match) {
|
|
427
|
+
index++;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
let colon = match.index + match[0].length;
|
|
431
|
+
while (/\s/.test(source[colon] ?? ''))
|
|
432
|
+
colon++;
|
|
433
|
+
if (source[colon] !== ':') {
|
|
434
|
+
index = match.index + match[0].length;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
let valueStart = colon + 1;
|
|
438
|
+
while (/\s/.test(source[valueStart] ?? ''))
|
|
439
|
+
valueStart++;
|
|
440
|
+
const current = stack.at(-1);
|
|
441
|
+
if (current) {
|
|
442
|
+
current.properties.set(match[0], {
|
|
443
|
+
location: offsetToLocation(source, file, match.index),
|
|
444
|
+
valueLocation: offsetToLocation(source, file, valueStart),
|
|
445
|
+
});
|
|
446
|
+
if (match[0] === 'tag' && (source[valueStart] === '"' || source[valueStart] === "'")) {
|
|
447
|
+
const stop = copyJson5String(source, valueStart);
|
|
448
|
+
const literal = source.slice(valueStart, stop);
|
|
449
|
+
let tag;
|
|
450
|
+
try {
|
|
451
|
+
tag = JSON5.parse(literal);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
tag = undefined;
|
|
455
|
+
}
|
|
456
|
+
if (typeof tag === 'string')
|
|
457
|
+
sites.push({
|
|
458
|
+
tag,
|
|
459
|
+
location: offsetToLocation(source, file, match.index),
|
|
460
|
+
properties: current.properties,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
index = match.index + match[0].length;
|
|
465
|
+
}
|
|
466
|
+
return sites;
|
|
467
|
+
}
|
|
468
|
+
function locate(context, tag) {
|
|
469
|
+
for (let index = context.cursor.index; index < context.sites.length; index++) {
|
|
470
|
+
if (context.sites[index].tag === tag) {
|
|
471
|
+
context.cursor.index = index + 1;
|
|
472
|
+
return context.sites[index];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { tag, location: context.parent ?? { file: context.file, line: 1, column: 1 } };
|
|
476
|
+
}
|
|
477
|
+
function offsetToLocation(source, file, offset) {
|
|
478
|
+
let line = 1;
|
|
479
|
+
let column = 1;
|
|
480
|
+
for (let index = 0; index < offset; index++) {
|
|
481
|
+
if (source[index] === '\n') {
|
|
482
|
+
line++;
|
|
483
|
+
column = 1;
|
|
484
|
+
}
|
|
485
|
+
else
|
|
486
|
+
column++;
|
|
487
|
+
}
|
|
488
|
+
return { file, line, column };
|
|
489
|
+
}
|
|
490
|
+
function json5SyntaxError(error, source, file) {
|
|
491
|
+
const lines = source.split(/\r?\n/);
|
|
492
|
+
const detail = error;
|
|
493
|
+
const line = typeof detail?.lineNumber === 'number' ? detail.lineNumber : 1;
|
|
494
|
+
const column = typeof detail?.columnNumber === 'number' ? detail.columnNumber : 1;
|
|
495
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
496
|
+
if (message.includes("'_'"))
|
|
497
|
+
message += ' (write translatable text as _("..."))';
|
|
498
|
+
return new MwlSyntaxError({
|
|
499
|
+
code: 'MWL_SYNTAX',
|
|
500
|
+
message,
|
|
501
|
+
location: { file, line: Math.max(1, line), column: Math.max(1, column) },
|
|
502
|
+
lineText: lines[Math.max(1, line) - 1],
|
|
503
|
+
});
|
|
214
504
|
}
|
|
215
505
|
/**
|
|
216
|
-
* Unquotes one raw attribute value and strips the `_
|
|
506
|
+
* Unquotes one raw attribute value and strips the `_("...")` gettext marker.
|
|
217
507
|
*
|
|
218
508
|
* @example
|
|
219
509
|
* ```ts
|
|
220
510
|
* import { parseValue } from '@datamoc/mw_games/mwl';
|
|
221
511
|
*
|
|
222
|
-
* console.log(parseValue('_
|
|
512
|
+
* console.log(parseValue('_("Hold the line")', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
|
|
223
513
|
* ```
|
|
224
514
|
*/
|
|
225
515
|
export function parseValue(raw, location, lineText = raw) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
516
|
+
const value = raw.trim();
|
|
517
|
+
const marker = /^_\(\s*([\s\S]*?)\s*\)$/.exec(value);
|
|
518
|
+
const literal = marker ? marker[1] : value;
|
|
519
|
+
if (literal.startsWith('"') || literal.startsWith("'")) {
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON5.parse(literal);
|
|
522
|
+
if (typeof parsed === 'string')
|
|
523
|
+
return parsed;
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Fall through to the syntax error below.
|
|
527
|
+
}
|
|
528
|
+
throw new MwlSyntaxError({ code: 'MWL_STRING', message: 'unterminated string value', location, lineText });
|
|
237
529
|
}
|
|
530
|
+
if (marker)
|
|
531
|
+
throw new MwlSyntaxError({ code: 'MWL_STRING', message: '_("...") needs a quoted string', location, lineText });
|
|
238
532
|
return value;
|
|
239
533
|
}
|
|
240
534
|
/**
|
|
241
|
-
* True when a raw attribute value carries the gettext marker `_
|
|
535
|
+
* True when a raw attribute value carries the gettext marker `_("...")`.
|
|
242
536
|
*
|
|
243
537
|
* @example
|
|
244
538
|
* ```ts
|
|
245
539
|
* import { isGettext } from '@datamoc/mw_games/mwl';
|
|
246
540
|
*
|
|
247
|
-
* console.log(isGettext('_
|
|
541
|
+
* console.log(isGettext('_("Hello")')); // true
|
|
248
542
|
* console.log(isGettext('Hello')); // false
|
|
249
543
|
* ```
|
|
250
544
|
*/
|
|
251
545
|
export function isGettext(raw) {
|
|
252
|
-
return /^_\s*"/.test(raw.trim());
|
|
546
|
+
return /^_\(\s*["']/.test(raw.trim());
|
|
253
547
|
}
|
|
254
548
|
function splitWords(value) {
|
|
255
549
|
const result = [];
|
|
@@ -282,4 +576,13 @@ function syntax(code, message, file, index, lineText) {
|
|
|
282
576
|
lineText,
|
|
283
577
|
});
|
|
284
578
|
}
|
|
579
|
+
function syntaxAt(code, message, source, file, offset) {
|
|
580
|
+
const location = offsetToLocation(source, file ?? '<mwl>', offset);
|
|
581
|
+
return new MwlSyntaxError({
|
|
582
|
+
code,
|
|
583
|
+
message,
|
|
584
|
+
location,
|
|
585
|
+
lineText: source.split(/\r?\n/)[location.line - 1],
|
|
586
|
+
});
|
|
587
|
+
}
|
|
285
588
|
//# sourceMappingURL=grammar.js.map
|