@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +11 -4
  3. package/dist/core/ActionJournal.d.ts +2 -0
  4. package/dist/core/ActionJournal.d.ts.map +1 -1
  5. package/dist/core/ActionJournal.js +6 -0
  6. package/dist/core/ActionJournal.js.map +1 -1
  7. package/dist/core/Multiplayer.d.ts +21 -4
  8. package/dist/core/Multiplayer.d.ts.map +1 -1
  9. package/dist/core/Multiplayer.js +31 -4
  10. package/dist/core/Multiplayer.js.map +1 -1
  11. package/dist/core/State.d.ts +38 -0
  12. package/dist/core/State.d.ts.map +1 -1
  13. package/dist/core/State.js +76 -0
  14. package/dist/core/State.js.map +1 -1
  15. package/dist/core/index.d.ts +3 -3
  16. package/dist/core/index.d.ts.map +1 -1
  17. package/dist/core/index.js +1 -1
  18. package/dist/core/index.js.map +1 -1
  19. package/dist/mw_games.global.js +107 -91
  20. package/dist/mwl/catalog.d.ts +7 -0
  21. package/dist/mwl/catalog.d.ts.map +1 -1
  22. package/dist/mwl/catalog.js +0 -0
  23. package/dist/mwl/catalog.js.map +1 -1
  24. package/dist/mwl/compiler.d.ts +10 -7
  25. package/dist/mwl/compiler.d.ts.map +1 -1
  26. package/dist/mwl/compiler.js +17 -10
  27. package/dist/mwl/compiler.js.map +1 -1
  28. package/dist/mwl/content.d.ts +1 -1
  29. package/dist/mwl/content.js +1 -1
  30. package/dist/mwl/grammar.d.ts +25 -8
  31. package/dist/mwl/grammar.d.ts.map +1 -1
  32. package/dist/mwl/grammar.js +408 -105
  33. package/dist/mwl/grammar.js.map +1 -1
  34. package/dist/mwl/hooks.d.ts +35 -2
  35. package/dist/mwl/hooks.d.ts.map +1 -1
  36. package/dist/mwl/hooks.js +8 -6
  37. package/dist/mwl/hooks.js.map +1 -1
  38. package/dist/mwl/index.d.ts +6 -6
  39. package/dist/mwl/index.d.ts.map +1 -1
  40. package/dist/mwl/index.js +3 -3
  41. package/dist/mwl/index.js.map +1 -1
  42. package/dist/mwl/persistence.d.ts +18 -1
  43. package/dist/mwl/persistence.d.ts.map +1 -1
  44. package/dist/mwl/persistence.js +59 -3
  45. package/dist/mwl/persistence.js.map +1 -1
  46. package/dist/mwl/readers.d.ts +39 -2
  47. package/dist/mwl/readers.d.ts.map +1 -1
  48. package/dist/mwl/readers.js +71 -2
  49. package/dist/mwl/readers.js.map +1 -1
  50. package/dist/mwl/report.d.ts +2 -2
  51. package/dist/mwl/report.js +2 -2
  52. package/dist/mwl/runtime.d.ts +46 -2
  53. package/dist/mwl/runtime.d.ts.map +1 -1
  54. package/dist/mwl/runtime.js +73 -9
  55. package/dist/mwl/runtime.js.map +1 -1
  56. package/dist/mwl/schema.d.ts +32 -1
  57. package/dist/mwl/schema.d.ts.map +1 -1
  58. package/dist/mwl/schema.js +58 -3
  59. package/dist/mwl/schema.js.map +1 -1
  60. package/dist/simulation/Runtime.d.ts +66 -1
  61. package/dist/simulation/Runtime.d.ts.map +1 -1
  62. package/dist/simulation/Runtime.js +131 -3
  63. package/dist/simulation/Runtime.js.map +1 -1
  64. package/dist/simulation/index.d.ts +2 -2
  65. package/dist/simulation/index.d.ts.map +1 -1
  66. package/dist/simulation/index.js +1 -1
  67. package/dist/simulation/index.js.map +1 -1
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +2 -1
  71. package/tools/mwl.mjs +85 -4
@@ -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,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
- return source.replace(/\{([^{}]*)\}/g, (whole, inner) => {
111
- const parts = splitWords(inner);
112
- const definition = macros.get(parts[0]);
113
- if (!definition)
114
- return whole;
115
- let body = definition.body;
116
- for (let i = 0; i < definition.parameters.length; i++) {
117
- const parameter = definition.parameters[i];
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
- return expandMacros(body, macros, options);
123
- });
124
- }
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('#'))
123
+ const character = source[index];
124
+ if (character !== '{') {
125
+ out += character;
126
+ index++;
133
127
  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);
128
+ }
129
+ const close = source.indexOf('}', index + 1);
130
+ if (close < 0) {
131
+ out += character;
132
+ index++;
143
133
  continue;
144
134
  }
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
- });
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 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;
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
- if (isGettext(attribute[2]))
169
- node.gettext.push(attribute[1]);
170
- node.attributes[attribute[1]] = parseValue(attribute[2], location, raw);
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
- throw new MwlSyntaxError({
174
- code: 'MWL_TOKEN',
175
- message: 'expected a tag or key=value attribute',
176
- location,
177
- lineText: raw,
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
- if (stack.length) {
181
- const node = stack.at(-1);
182
- throw new MwlSyntaxError({ code: 'MWL_TAG', message: `unclosed tag ${node.tag}`, location: node.location });
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 roots;
227
+ return index;
185
228
  }
186
229
  /**
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.
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 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('"""');
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 (text.slice(end + 3).trim() !== '')
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: 'MWL_STRING',
210
- message: 'unexpected text after a """ value',
341
+ code: 'MWL_TAG',
342
+ message: `children of [${tag}] must be an array`,
211
343
  location,
212
344
  });
213
- return { text: text.slice(0, end), gettext, extraLines: index - from };
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 `_ "..."` gettext marker.
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('_ "Hold the line"', { file: 'a.mwl', line: 1, column: 1 })); // 'Hold the line'
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
- // 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('""', '"');
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('_ "Hello"')); // true
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