@datamoc/mw_games 0.7.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 (41) hide show
  1. package/README.md +18 -3
  2. package/dist/mw_games.global.js +25 -25
  3. package/dist/mwl/actors.d.ts.map +1 -1
  4. package/dist/mwl/actors.js +10 -2
  5. package/dist/mwl/actors.js.map +1 -1
  6. package/dist/mwl/catalog.d.ts.map +1 -1
  7. package/dist/mwl/catalog.js +18 -3
  8. package/dist/mwl/catalog.js.map +1 -1
  9. package/dist/mwl/compiler.d.ts +6 -0
  10. package/dist/mwl/compiler.d.ts.map +1 -1
  11. package/dist/mwl/compiler.js +53 -3
  12. package/dist/mwl/compiler.js.map +1 -1
  13. package/dist/mwl/content.d.ts +36 -0
  14. package/dist/mwl/content.d.ts.map +1 -1
  15. package/dist/mwl/content.js +49 -0
  16. package/dist/mwl/content.js.map +1 -1
  17. package/dist/mwl/expression.d.ts.map +1 -1
  18. package/dist/mwl/expression.js +16 -6
  19. package/dist/mwl/expression.js.map +1 -1
  20. package/dist/mwl/hooks.d.ts.map +1 -1
  21. package/dist/mwl/hooks.js.map +1 -1
  22. package/dist/mwl/index.d.ts +4 -4
  23. package/dist/mwl/index.d.ts.map +1 -1
  24. package/dist/mwl/index.js +1 -1
  25. package/dist/mwl/index.js.map +1 -1
  26. package/dist/mwl/persistence.d.ts.map +1 -1
  27. package/dist/mwl/persistence.js +10 -2
  28. package/dist/mwl/persistence.js.map +1 -1
  29. package/dist/mwl/runtime.d.ts +28 -1
  30. package/dist/mwl/runtime.d.ts.map +1 -1
  31. package/dist/mwl/runtime.js +80 -12
  32. package/dist/mwl/runtime.js.map +1 -1
  33. package/dist/mwl/schema.d.ts.map +1 -1
  34. package/dist/mwl/schema.js +30 -2
  35. package/dist/mwl/schema.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +9 -4
  39. package/tools/extract-html.d.mts +18 -0
  40. package/tools/extract-html.mjs +235 -0
  41. package/tools/mwl.mjs +55 -11
@@ -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 CHANGED
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
6
6
  import {
7
7
  collectHookReferences,
8
8
  compile,
9
+ compileSources,
9
10
  emitHooksDeclaration,
10
11
  emitModule,
11
12
  extractCatalog,
@@ -33,25 +34,39 @@ const manifestFlag = args.indexOf('--manifest');
33
34
  const manifestPath = manifestFlag === -1 ? undefined : args[manifestFlag + 1];
34
35
 
35
36
  if (!command || !input || !commands.includes(command)) {
36
- console.error('Usage: mwl <validate|compile|extract-i18n|assets|hooks|build> input.mwl [-o output] [--manifest hooks.json]');
37
+ console.error(
38
+ 'Usage: mwl <validate|compile|extract-i18n|assets|hooks|build> input.mwl [-o output] [--manifest hooks.json]',
39
+ );
37
40
  process.exitCode = 2;
38
41
  } else {
39
- const source = fs.readFileSync(input, 'utf8');
42
+ const files = fs.statSync(input).isDirectory()
43
+ ? collectSources(input)
44
+ : [{ file: sourceLabel(input), source: fs.readFileSync(input, 'utf8') }];
40
45
  if (command === 'validate') {
41
- const diagnostics = validate(parse(preprocess(source, { file: input }), input));
46
+ const diagnostics = files.flatMap((file) =>
47
+ validate(parse(preprocess(file.source, { file: file.file }), file.file)),
48
+ );
42
49
  if (diagnostics.length) {
43
50
  for (const diagnostic of diagnostics)
44
51
  console.error(
45
52
  `${diagnostic.location.file}:${diagnostic.location.line}:${diagnostic.location.column}: ${diagnostic.message}`,
46
53
  );
47
54
  process.exitCode = 1;
48
- } else console.log(`${input}: valid MWL`);
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
+ }
49
64
  } else if (command === 'hooks') {
50
- await hooks(source);
65
+ await hooks(files);
51
66
  } else if (command === 'build') {
52
- build(source);
67
+ build(files);
53
68
  } else {
54
- const result = compile(source, { file: input });
69
+ const result = compileSources(files);
55
70
  const values =
56
71
  command === 'extract-i18n' ? extractCatalog(result) : command === 'assets' ? result.assets : result;
57
72
  const text = command === 'compile' ? emitModule(result) : `${JSON.stringify(values, null, '\t')}\n`;
@@ -62,9 +77,38 @@ if (!command || !input || !commands.includes(command)) {
62
77
  }
63
78
  }
64
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
+
65
109
  /** One deterministic build step for the three files every MWL game consumes. */
66
- function build(source) {
67
- const game = compile(source, { file: input });
110
+ function build(files) {
111
+ const game = compileSources(files);
68
112
  const directory = path.resolve(output ?? 'generated');
69
113
  fs.mkdirSync(directory, { recursive: true });
70
114
  fs.writeFileSync(path.join(directory, 'game-data.ts'), emitModule(game));
@@ -79,8 +123,8 @@ function build(source) {
79
123
  * each reference against the manifest, bundles the hook sources with esbuild,
80
124
  * and writes the matching declaration next to the bundle.
81
125
  */
82
- async function hooks(source) {
83
- const game = compile(source, { file: input });
126
+ async function hooks(files) {
127
+ const game = compileSources(files);
84
128
  const references = collectHookReferences(game);
85
129
  if (!output) {
86
130
  process.stdout.write(