@datamoc/mw_games 0.6.0 → 0.7.1

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 (56) hide show
  1. package/README.md +66 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/mw_games.global.js +22 -19
  7. package/dist/mwl/actors.d.ts +16 -0
  8. package/dist/mwl/actors.d.ts.map +1 -0
  9. package/dist/mwl/actors.js +33 -0
  10. package/dist/mwl/actors.js.map +1 -0
  11. package/dist/mwl/catalog.d.ts +13 -0
  12. package/dist/mwl/catalog.d.ts.map +1 -0
  13. package/dist/mwl/catalog.js +67 -0
  14. package/dist/mwl/catalog.js.map +1 -0
  15. package/dist/mwl/compiler.d.ts +51 -0
  16. package/dist/mwl/compiler.d.ts.map +1 -0
  17. package/dist/mwl/compiler.js +119 -0
  18. package/dist/mwl/compiler.js.map +1 -0
  19. package/dist/mwl/content.d.ts +90 -0
  20. package/dist/mwl/content.d.ts.map +1 -0
  21. package/dist/mwl/content.js +146 -0
  22. package/dist/mwl/content.js.map +1 -0
  23. package/dist/mwl/expression.d.ts +21 -0
  24. package/dist/mwl/expression.d.ts.map +1 -0
  25. package/dist/mwl/expression.js +110 -0
  26. package/dist/mwl/expression.js.map +1 -0
  27. package/dist/mwl/grammar.d.ts +39 -0
  28. package/dist/mwl/grammar.d.ts.map +1 -0
  29. package/dist/mwl/grammar.js +204 -0
  30. package/dist/mwl/grammar.js.map +1 -0
  31. package/dist/mwl/hooks.d.ts +81 -0
  32. package/dist/mwl/hooks.d.ts.map +1 -0
  33. package/dist/mwl/hooks.js +78 -0
  34. package/dist/mwl/hooks.js.map +1 -0
  35. package/dist/mwl/index.d.ts +21 -0
  36. package/dist/mwl/index.d.ts.map +1 -0
  37. package/dist/mwl/index.js +11 -0
  38. package/dist/mwl/index.js.map +1 -0
  39. package/dist/mwl/persistence.d.ts +15 -0
  40. package/dist/mwl/persistence.d.ts.map +1 -0
  41. package/dist/mwl/persistence.js +41 -0
  42. package/dist/mwl/persistence.js.map +1 -0
  43. package/dist/mwl/runtime.d.ts +196 -0
  44. package/dist/mwl/runtime.d.ts.map +1 -0
  45. package/dist/mwl/runtime.js +587 -0
  46. package/dist/mwl/runtime.js.map +1 -0
  47. package/dist/mwl/schema.d.ts +20 -0
  48. package/dist/mwl/schema.d.ts.map +1 -0
  49. package/dist/mwl/schema.js +379 -0
  50. package/dist/mwl/schema.js.map +1 -0
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +15 -4
  54. package/tools/extract-html.d.mts +18 -0
  55. package/tools/extract-html.mjs +235 -0
  56. package/tools/mwl.mjs +197 -0
@@ -0,0 +1,235 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import crypto from 'node:crypto';
5
+
6
+ const MIME_EXTENSIONS = {
7
+ 'text/javascript': '.js',
8
+ 'application/javascript': '.js',
9
+ 'application/json': '.json',
10
+ 'text/css': '.css',
11
+ 'image/svg+xml': '.svg',
12
+ 'image/png': '.png',
13
+ 'image/jpeg': '.jpg',
14
+ 'image/gif': '.gif',
15
+ 'image/webp': '.webp',
16
+ 'audio/wav': '.wav',
17
+ 'audio/ogg': '.ogg',
18
+ 'audio/mpeg': '.mp3',
19
+ 'font/woff2': '.woff2',
20
+ };
21
+
22
+ /**
23
+ * Extract inline HTML resources without parsing the document through a DOM.
24
+ * This keeps the operation usable for large self-contained pages and preserves
25
+ * all unrelated bytes in the source as-is.
26
+ */
27
+ export async function extractHtml(inputFile, outputDirectory) {
28
+ const source = await readFile(inputFile, 'utf8');
29
+ const output = resolve(outputDirectory ?? join(dirname(resolve(inputFile)), 'extracted'));
30
+ const assetDirectory = join(output, 'assets');
31
+ await mkdir(output, { recursive: true });
32
+ await rm(assetDirectory, { recursive: true, force: true });
33
+ await mkdir(assetDirectory, { recursive: true });
34
+
35
+ const resources = [];
36
+ const warnings = [];
37
+ const bySource = new Map();
38
+ let html = source;
39
+ const inlineBlocks = /<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1\s*>/gi;
40
+ const dataUri = /(\b(?:src|href|poster|content)\s*=\s*["'])(data:([^"']+?))["']/gi;
41
+ const srcset = /(\bsrcset\s*=\s*["'])([^"']+)(["'])/gi;
42
+ const inlineMatches = [...source.matchAll(inlineBlocks)];
43
+ const dataMatches = [...source.matchAll(dataUri)].filter(
44
+ (match) =>
45
+ !inlineMatches.some((block) => match.index >= block.index && match.index < block.index + block[0].length),
46
+ );
47
+ const srcsetMatches = [...source.matchAll(srcset)].filter((match) => match[2].includes('data:'));
48
+ const occurrences = [...inlineMatches.filter((match) => match[3].trim()), ...dataMatches, ...srcsetMatches].sort(
49
+ (a, b) => a.index - b.index,
50
+ );
51
+ const order = new Map(occurrences.map((match, index) => [match.index, index + 1]));
52
+ let nextId = occurrences.length + 1;
53
+ const usedIds = new Set();
54
+
55
+ const writeResource = async ({ kind, mime, data, sourceOffset, sourceValue }) => {
56
+ const key = `${mime}\0${data.toString('base64')}`;
57
+ const existing = bySource.get(key);
58
+ if (existing) return existing.path;
59
+ const extension = MIME_EXTENSIONS[mime] ?? extensionForKind(kind);
60
+ let id = order.get(sourceOffset);
61
+ if (id === undefined || usedIds.has(id)) id = nextId++;
62
+ usedIds.add(id);
63
+ const filename = `inline-${String(id).padStart(4, '0')}${extension}`;
64
+ const relativePath = `assets/${filename}`;
65
+ await writeFile(join(assetDirectory, filename), data);
66
+ const resource = {
67
+ kind,
68
+ path: relativePath,
69
+ mime,
70
+ bytes: data.length,
71
+ sourceOffset,
72
+ sourceValue,
73
+ };
74
+ resources.push(resource);
75
+ bySource.set(key, resource);
76
+ return relativePath;
77
+ };
78
+
79
+ const replacements = [];
80
+ for (const match of inlineMatches) {
81
+ const tag = match[1].toLowerCase();
82
+ const attributes = match[2];
83
+ const body = match[3];
84
+ if (!body.trim()) continue;
85
+ const type = attributeValue(attributes, 'type');
86
+ const mime =
87
+ tag === 'style'
88
+ ? 'text/css'
89
+ : type?.toLowerCase() === 'application/json'
90
+ ? 'application/json'
91
+ : 'text/javascript';
92
+ const kind = tag === 'style' ? 'style' : mime === 'application/json' ? 'json' : 'script';
93
+ const path = await writeResource({
94
+ kind,
95
+ mime,
96
+ data: Buffer.from(body),
97
+ sourceOffset: match.index,
98
+ sourceValue: `<${tag}>`,
99
+ });
100
+ if (tag === 'style') {
101
+ let css = body;
102
+ const cssDataUri = /url\(\s*(["']?)(data:[^\)"']+)\1\s*\)/gi;
103
+ for (const cssMatch of [...body.matchAll(cssDataUri)]) {
104
+ const parsed = decodeDataUri(cssMatch[2]);
105
+ if (!parsed) {
106
+ warnings.push(`unsupported CSS data URI at offset ${match.index + cssMatch.index}`);
107
+ continue;
108
+ }
109
+ const assetPath = await writeResource({
110
+ kind: 'css-data-uri',
111
+ mime: parsed.mime,
112
+ data: parsed.data,
113
+ sourceOffset: match.index + cssMatch.index,
114
+ sourceValue: 'data:',
115
+ });
116
+ css = css.replace(cssMatch[2], `./${assetPath.slice('assets/'.length)}`);
117
+ }
118
+ if (css !== body) await writeFile(join(assetDirectory, path.slice('assets/'.length)), css);
119
+ }
120
+ if (kind === 'script' && type?.toLowerCase() === 'module' && /(?:^|[^\w])(?:import|export)\s/.test(body)) {
121
+ warnings.push(`inline module at offset ${match.index} was moved under assets/; verify relative imports`);
122
+ }
123
+ const replacement =
124
+ tag === 'style'
125
+ ? `<link${copyStyleAttributes(attributes)} rel="stylesheet" href="./${path}">`
126
+ : `<script${copyScriptAttributes(attributes)} src="./${path}"></script>`;
127
+ replacements.push({ start: match.index, end: match.index + match[0].length, replacement });
128
+ }
129
+ for (const match of dataMatches) {
130
+ const parsed = decodeDataUri(match[2]);
131
+ if (!parsed) {
132
+ warnings.push(`unsupported data URI at offset ${match.index}`);
133
+ continue;
134
+ }
135
+ const path = await writeResource({
136
+ kind: 'data-uri',
137
+ mime: parsed.mime,
138
+ data: parsed.data,
139
+ sourceOffset: match.index,
140
+ sourceValue: 'data:',
141
+ });
142
+ replacements.push({
143
+ start: match.index + match[1].length,
144
+ end: match.index + match[0].length - 1,
145
+ replacement: `./${path}`,
146
+ });
147
+ }
148
+ for (const match of srcsetMatches) {
149
+ const candidates = [];
150
+ for (const candidate of match[2].split(/,\s*(?=data:)/i)) {
151
+ const parts = candidate.trim().split(/\s+/);
152
+ const parsed = decodeDataUri(parts.shift() ?? '');
153
+ if (!parsed) {
154
+ warnings.push(`unsupported srcset data URI at offset ${match.index}`);
155
+ candidates.push(candidate);
156
+ continue;
157
+ }
158
+ const path = await writeResource({
159
+ kind: 'data-uri',
160
+ mime: parsed.mime,
161
+ data: parsed.data,
162
+ sourceOffset: match.index,
163
+ sourceValue: 'data:',
164
+ });
165
+ candidates.push([`./${path}`, ...parts].join(' '));
166
+ }
167
+ replacements.push({
168
+ start: match.index + match[1].length,
169
+ end: match.index + match[0].length - 1,
170
+ replacement: candidates.join(', '),
171
+ });
172
+ }
173
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
174
+ html = html.slice(0, replacement.start) + replacement.replacement + html.slice(replacement.end);
175
+
176
+ resources.sort((a, b) => a.sourceOffset - b.sourceOffset || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
177
+ const manifest = {
178
+ version: 1,
179
+ source: basename(inputFile),
180
+ resources,
181
+ warnings,
182
+ fingerprint: crypto.createHash('sha256').update(source).digest('hex'),
183
+ };
184
+ await writeFile(join(output, 'index.html'), html, 'utf8');
185
+ await writeFile(join(output, 'manifest.json'), `${JSON.stringify(manifest, null, '\t')}\n`, 'utf8');
186
+ return { output, html, resources, warnings, manifest };
187
+ }
188
+
189
+ function extensionForKind(kind) {
190
+ return kind === 'style' ? '.css' : kind === 'json' ? '.json' : '.js';
191
+ }
192
+
193
+ function attributeValue(attributes, name) {
194
+ const match = attributes.match(new RegExp(`\\b${name}\\s*=\\s*[\\"']([^\\"']*)[\\"']`, 'i'));
195
+ return match?.[1];
196
+ }
197
+
198
+ function copyScriptAttributes(attributes) {
199
+ return attributes.replace(/\s+(?:src|type)\s*=\s*["'][^"']*["']/gi, '').replace(/\s+$/, '');
200
+ }
201
+
202
+ function copyStyleAttributes(attributes) {
203
+ return attributes.replace(/\s+type\s*=\s*["'][^"']*["']/gi, '').replace(/\s+$/, '');
204
+ }
205
+
206
+ function decodeDataUri(value) {
207
+ const comma = value.indexOf(',');
208
+ if (comma === -1) return null;
209
+ const header = value.slice(5, comma);
210
+ const payload = value.slice(comma + 1);
211
+ const fields = header.split(';');
212
+ const mime = fields.shift() || 'text/plain';
213
+ try {
214
+ return {
215
+ mime,
216
+ data: fields.includes('base64') ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload)),
217
+ };
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
224
+ const input = process.argv[2];
225
+ const outputFlag = process.argv.indexOf('-o');
226
+ const output = outputFlag === -1 ? undefined : process.argv[outputFlag + 1];
227
+ if (!input || (outputFlag !== -1 && !output)) {
228
+ console.error('Usage: node tools/extract-html.mjs input.html [-o output-directory]');
229
+ process.exitCode = 2;
230
+ } else {
231
+ const result = await extractHtml(input, output);
232
+ console.log(`extracted ${result.resources.length} resource(s) into ${result.output}`);
233
+ for (const warning of result.warnings) console.warn(`warning: ${warning}`);
234
+ }
235
+ }
package/tools/mwl.mjs ADDED
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { pathToFileURL } from 'node:url';
6
+ import {
7
+ collectHookReferences,
8
+ compile,
9
+ compileSources,
10
+ emitHooksDeclaration,
11
+ emitModule,
12
+ extractCatalog,
13
+ parse,
14
+ preprocess,
15
+ validate,
16
+ validateHookReferences,
17
+ } from '../dist/mwl/index.js';
18
+
19
+ /**
20
+ * Command line for MWL: validate, compile, extract-i18n, assets, hooks, build.
21
+ *
22
+ * Reading files happens here, not in the published module, so the library
23
+ * itself stays free of node builtins. `hooks` needs esbuild and says so
24
+ * clearly when it is not installed; the framework does not depend on it, so a
25
+ * project without hooks pays nothing for the feature.
26
+ */
27
+
28
+ const args = process.argv.slice(2);
29
+ const commands = ['validate', 'compile', 'extract-i18n', 'assets', 'hooks', 'build'];
30
+ const [command, input] = args;
31
+ const outputFlag = args.indexOf('-o');
32
+ const output = outputFlag === -1 ? undefined : args[outputFlag + 1];
33
+ const manifestFlag = args.indexOf('--manifest');
34
+ const manifestPath = manifestFlag === -1 ? undefined : args[manifestFlag + 1];
35
+
36
+ if (!command || !input || !commands.includes(command)) {
37
+ console.error(
38
+ 'Usage: mwl <validate|compile|extract-i18n|assets|hooks|build> input.mwl [-o output] [--manifest hooks.json]',
39
+ );
40
+ process.exitCode = 2;
41
+ } else {
42
+ const files = fs.statSync(input).isDirectory()
43
+ ? collectSources(input)
44
+ : [{ file: sourceLabel(input), source: fs.readFileSync(input, 'utf8') }];
45
+ if (command === 'validate') {
46
+ const diagnostics = files.flatMap((file) =>
47
+ validate(parse(preprocess(file.source, { file: file.file }), file.file)),
48
+ );
49
+ if (diagnostics.length) {
50
+ for (const diagnostic of diagnostics)
51
+ console.error(
52
+ `${diagnostic.location.file}:${diagnostic.location.line}:${diagnostic.location.column}: ${diagnostic.message}`,
53
+ );
54
+ process.exitCode = 1;
55
+ } else {
56
+ try {
57
+ compileSources(files);
58
+ console.log(`${input}: valid MWL`);
59
+ } catch (error) {
60
+ console.error(error instanceof Error ? error.message : String(error));
61
+ process.exitCode = 1;
62
+ }
63
+ }
64
+ } else if (command === 'hooks') {
65
+ await hooks(files);
66
+ } else if (command === 'build') {
67
+ build(files);
68
+ } else {
69
+ const result = compileSources(files);
70
+ const values =
71
+ command === 'extract-i18n' ? extractCatalog(result) : command === 'assets' ? result.assets : result;
72
+ const text = command === 'compile' ? emitModule(result) : `${JSON.stringify(values, null, '\t')}\n`;
73
+ if (output) {
74
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
75
+ fs.writeFileSync(output, text);
76
+ } else process.stdout.write(text);
77
+ }
78
+ }
79
+
80
+ function collectSources(directory) {
81
+ const files = [];
82
+ const root = path.resolve(directory);
83
+ const label = sourceLabel(root);
84
+ const visit = (current) => {
85
+ for (const entry of fs
86
+ .readdirSync(current, { withFileTypes: true })
87
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
88
+ const full = path.join(current, entry.name);
89
+ if (entry.isDirectory()) visit(full);
90
+ else if (/\.mwl$/i.test(entry.name)) {
91
+ const relative = path.relative(root, full);
92
+ files.push({
93
+ file: path.join(label, relative).split(path.sep).join('/'),
94
+ source: fs.readFileSync(full, 'utf8'),
95
+ });
96
+ }
97
+ }
98
+ };
99
+ visit(root);
100
+ if (!files.length) throw new Error(`no .mwl files found in ${directory}`);
101
+ return files;
102
+ }
103
+
104
+ function sourceLabel(file) {
105
+ const relative = path.relative(process.cwd(), path.resolve(file));
106
+ return (relative || path.basename(path.resolve(file))).split(path.sep).join('/');
107
+ }
108
+
109
+ /** One deterministic build step for the three files every MWL game consumes. */
110
+ function build(files) {
111
+ const game = compileSources(files);
112
+ const directory = path.resolve(output ?? 'generated');
113
+ fs.mkdirSync(directory, { recursive: true });
114
+ fs.writeFileSync(path.join(directory, 'game-data.ts'), emitModule(game));
115
+ fs.writeFileSync(path.join(directory, 'i18n.json'), `${JSON.stringify(extractCatalog(game), null, '\t')}\n`);
116
+ fs.writeFileSync(path.join(directory, 'assets.json'), `${JSON.stringify({ assets: game.assets }, null, '\t')}\n`);
117
+ console.log(`built MWL into ${directory}`);
118
+ }
119
+
120
+ /**
121
+ * `mwl hooks game.mwl` prints the hook references content needs.
122
+ * `mwl hooks game.mwl --manifest hooks.json -o generated/hooks.mjs` resolves
123
+ * each reference against the manifest, bundles the hook sources with esbuild,
124
+ * and writes the matching declaration next to the bundle.
125
+ */
126
+ async function hooks(files) {
127
+ const game = compileSources(files);
128
+ const references = collectHookReferences(game);
129
+ if (!output) {
130
+ process.stdout.write(
131
+ `${JSON.stringify(
132
+ references.map((reference) => `${reference.type}:${reference.name}`),
133
+ null,
134
+ '\t',
135
+ )}\n`,
136
+ );
137
+ return;
138
+ }
139
+ const manifest = manifestPath ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {};
140
+ const diagnostics = validateHookReferences(references, Object.keys(manifest));
141
+ if (diagnostics.length) {
142
+ for (const diagnostic of diagnostics)
143
+ console.error(`${diagnostic.location.file}:${diagnostic.location.line}: ${diagnostic.message}`);
144
+ console.error(`${diagnostics.length} hook reference(s) have no manifest entry; nothing was bundled`);
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+
149
+ // Resolve esbuild from the game project (the working directory), not from
150
+ // the framework package: the framework does not depend on esbuild, and the
151
+ // project that writes hooks is the one that installs the bundler.
152
+ const requireFromProject = createRequire(pathToFileURL(path.join(process.cwd(), 'package.json')));
153
+ let esbuild = null;
154
+ try {
155
+ esbuild = requireFromProject('esbuild');
156
+ } catch {
157
+ esbuild = null;
158
+ }
159
+ if (!esbuild) {
160
+ console.error('mwl hooks needs esbuild. Install it in the game project: npm install --save-dev esbuild');
161
+ process.exitCode = 2;
162
+ return;
163
+ }
164
+
165
+ const manifestDir = manifestPath ? path.dirname(path.resolve(manifestPath)) : process.cwd();
166
+ // The entry file lives beside the hooks so its imports stay relative;
167
+ // esbuild does not resolve `file://` import specifiers.
168
+ const entryFile = path.join(manifestDir, `.mwl-hooks-${process.pid}-${Date.now()}.ts`);
169
+ const imports = [];
170
+ const exports = [];
171
+ references.forEach((reference, index) => {
172
+ const id = `${reference.type}:${reference.name}`;
173
+ const [file, exportName = reference.name] = String(manifest[id]).split('#');
174
+ const relative = path.relative(manifestDir, path.resolve(manifestDir, file)).split(path.sep).join('/');
175
+ const specifier = relative.startsWith('.') ? relative : `./${relative}`;
176
+ imports.push(`import { ${exportName} as hook${index} } from ${JSON.stringify(specifier)};`);
177
+ exports.push(`\t${JSON.stringify(id)}: hook${index},`);
178
+ });
179
+ const entry = `${imports.join('\n')}\n\nexport const hooks = {\n${exports.join('\n')}\n};\n`;
180
+ fs.writeFileSync(entryFile, entry);
181
+ try {
182
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
183
+ await esbuild.build({
184
+ entryPoints: [entryFile],
185
+ bundle: true,
186
+ format: 'esm',
187
+ platform: 'neutral',
188
+ target: 'es2022',
189
+ outfile: output,
190
+ logLevel: 'silent',
191
+ });
192
+ fs.writeFileSync(`${output}.d.ts`, emitHooksDeclaration(references));
193
+ console.log(`bundled ${references.length} hook(s) into ${output}`);
194
+ } finally {
195
+ fs.rmSync(entryFile, { force: true });
196
+ }
197
+ }