@mpgd/cli 0.28.1 → 0.30.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/dist/cloudflare-pages-static.d.ts +53 -0
- package/dist/cloudflare-pages-static.js +236 -0
- package/dist/hosted-pwa-deployment.d.ts +42 -0
- package/dist/hosted-pwa-deployment.js +1264 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +387 -2
- package/dist/microsoft-store-package-acceptance.js +19 -8
- package/dist/microsoft-store-pwa-release.d.ts +58 -0
- package/dist/microsoft-store-pwa-release.js +343 -0
- package/dist/offline-playtest.js +20 -4
- package/package.json +7 -7
- package/templates/phaser-game/agent/game-manifest.json +4 -0
- package/templates/phaser-game/mpgd.game.json +3 -0
- package/templates/phaser-game/src/entry.ts +10 -0
- package/templates/phaser-game/src/gameEntry.ts +10 -0
- package/templates/phaser-game/src/platform/textSelection.ts +121 -0
- package/templates/phaser-game/vite.shared.ts +14 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type MicrosoftStorePwaReleaseEvidence } from './microsoft-store-pwa-e2e.js';
|
|
2
|
+
/**
|
|
3
|
+
* Release-evidence primitives shared by the build-time artifact writer and the
|
|
4
|
+
* hosted-deployment verifier. Kept free of repo-internal imports so the public
|
|
5
|
+
* CLI can verify deployments without the mpgd-kit checkout.
|
|
6
|
+
*/
|
|
7
|
+
export interface PrecacheEntry {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly source: string | Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
export interface MicrosoftStorePwaProvenance {
|
|
12
|
+
readonly appVersion: string;
|
|
13
|
+
readonly buildId: string;
|
|
14
|
+
readonly sourceGitSha: string;
|
|
15
|
+
readonly kitGitSha: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function assertMicrosoftStorePwaProvenance(input: MicrosoftStorePwaProvenance): MicrosoftStorePwaProvenance;
|
|
18
|
+
export declare function createMicrosoftStorePwaRevision(input: {
|
|
19
|
+
readonly appVersion: string;
|
|
20
|
+
readonly buildId: string;
|
|
21
|
+
readonly sourceGitSha: string;
|
|
22
|
+
readonly kitGitSha: string;
|
|
23
|
+
readonly precacheEntries: readonly PrecacheEntry[];
|
|
24
|
+
}): string;
|
|
25
|
+
/**
|
|
26
|
+
* Recompute a release revision by streaming each precached file through the
|
|
27
|
+
* digest instead of materializing every payload in memory, for verifiers
|
|
28
|
+
* that only have the artifact root and the recorded URL list.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createMicrosoftStorePwaRevisionFromFiles(input: {
|
|
31
|
+
readonly appVersion: string;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
readonly sourceGitSha: string;
|
|
34
|
+
readonly kitGitSha: string;
|
|
35
|
+
readonly precacheUrls: readonly string[];
|
|
36
|
+
readonly artifactRoot: string;
|
|
37
|
+
}): string;
|
|
38
|
+
export declare function createMicrosoftStorePwaReleaseEvidence(input: {
|
|
39
|
+
readonly pwaId: string;
|
|
40
|
+
readonly appVersion: string;
|
|
41
|
+
readonly buildId: string;
|
|
42
|
+
readonly sourceGitSha: string;
|
|
43
|
+
readonly kitGitSha: string;
|
|
44
|
+
readonly revision: string;
|
|
45
|
+
readonly precacheUrls: readonly string[];
|
|
46
|
+
}): MicrosoftStorePwaReleaseEvidence;
|
|
47
|
+
export declare function createMicrosoftStorePwaServiceWorker(evidence: MicrosoftStorePwaReleaseEvidence): string;
|
|
48
|
+
export declare function readMicrosoftStorePwaReleaseEvidence(path: string): MicrosoftStorePwaReleaseEvidence;
|
|
49
|
+
/**
|
|
50
|
+
* Enumerate the precacheable files of a PWA artifact. Sourcemaps, the release
|
|
51
|
+
* evidence document, and the service worker itself are excluded because the
|
|
52
|
+
* release revision only covers the precached payload. Symbolic links are
|
|
53
|
+
* rejected so callers cannot be tricked into hashing files outside the root.
|
|
54
|
+
*/
|
|
55
|
+
/** Enumerate only precache URLs without reading any payload bytes. */
|
|
56
|
+
export declare function listPrecacheUrls(artifactRoot: string): readonly string[];
|
|
57
|
+
export declare function listPrecacheEntries(artifactRoot: string): readonly PrecacheEntry[];
|
|
58
|
+
export declare function requireGameSpecificPwaId(value: unknown): string;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, } from 'node:fs';
|
|
3
|
+
import { relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, microsoftStorePwaCacheSchema, } from './microsoft-store-pwa-e2e.js';
|
|
5
|
+
export function assertMicrosoftStorePwaProvenance(input) {
|
|
6
|
+
return {
|
|
7
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
8
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
9
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
10
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function createMicrosoftStorePwaRevision(input) {
|
|
14
|
+
const hash = createHash('sha256');
|
|
15
|
+
const release = {
|
|
16
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
17
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
18
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
19
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
20
|
+
};
|
|
21
|
+
updateRevisionField(hash, microsoftStorePwaCacheSchema);
|
|
22
|
+
updateRevisionField(hash, JSON.stringify(release));
|
|
23
|
+
for (const entry of normalizePrecacheEntries(input.precacheEntries)) {
|
|
24
|
+
updateRevisionField(hash, entry.url);
|
|
25
|
+
updateRevisionField(hash, entry.source);
|
|
26
|
+
}
|
|
27
|
+
return hash.digest('hex').slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Recompute a release revision by streaming each precached file through the
|
|
31
|
+
* digest instead of materializing every payload in memory, for verifiers
|
|
32
|
+
* that only have the artifact root and the recorded URL list.
|
|
33
|
+
*/
|
|
34
|
+
export function createMicrosoftStorePwaRevisionFromFiles(input) {
|
|
35
|
+
const hash = createHash('sha256');
|
|
36
|
+
const release = {
|
|
37
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
38
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
39
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
40
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
41
|
+
};
|
|
42
|
+
updateRevisionField(hash, microsoftStorePwaCacheSchema);
|
|
43
|
+
updateRevisionField(hash, JSON.stringify(release));
|
|
44
|
+
const urls = [...input.precacheUrls]
|
|
45
|
+
.filter((url) => url !== './pwa-release.json' && !url.endsWith('.map'))
|
|
46
|
+
.map(assertMicrosoftStorePwaPrecacheUrl)
|
|
47
|
+
.sort(compareCodeUnits);
|
|
48
|
+
for (const url of urls) {
|
|
49
|
+
const file = resolve(input.artifactRoot, url.replace(/^\.\//u, ''));
|
|
50
|
+
if (!existsSync(file)) {
|
|
51
|
+
throw new Error(`PWA precache URL has no artifact file: ${url}`);
|
|
52
|
+
}
|
|
53
|
+
updateRevisionField(hash, url);
|
|
54
|
+
hash.update(`${String(statSync(file).size)}:`);
|
|
55
|
+
hashStreamInto(hash, file);
|
|
56
|
+
}
|
|
57
|
+
return hash.digest('hex').slice(0, 16);
|
|
58
|
+
}
|
|
59
|
+
function hashStreamInto(hash, file) {
|
|
60
|
+
const handle = openSync(file, 'r');
|
|
61
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
62
|
+
try {
|
|
63
|
+
let read = readSync(handle, chunk, 0, chunk.length, null);
|
|
64
|
+
while (read > 0) {
|
|
65
|
+
hash.update(chunk.subarray(0, read));
|
|
66
|
+
read = readSync(handle, chunk, 0, chunk.length, null);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
closeSync(handle);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function createMicrosoftStorePwaReleaseEvidence(input) {
|
|
74
|
+
const pwaId = requireGameSpecificPwaId(input.pwaId);
|
|
75
|
+
const revision = requireRevision(input.revision);
|
|
76
|
+
const cachePrefix = `mpgd-pwa-${createHash('sha256').update(pwaId).digest('hex').slice(0, 12)}-`;
|
|
77
|
+
return {
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
cacheSchema: microsoftStorePwaCacheSchema,
|
|
80
|
+
pwaId,
|
|
81
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
82
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
83
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
84
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
85
|
+
configTarget: 'microsoft-store',
|
|
86
|
+
revision,
|
|
87
|
+
cachePrefix,
|
|
88
|
+
cacheNamePattern: `${cachePrefix}{scope}-${revision}`,
|
|
89
|
+
precacheUrls: normalizePrecacheUrls(input.precacheUrls),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function createMicrosoftStorePwaServiceWorker(evidence) {
|
|
93
|
+
const revision = requireRevision(evidence.revision);
|
|
94
|
+
const cachePrefix = requireNonEmptyString(evidence.cachePrefix, 'PWA cache prefix');
|
|
95
|
+
const cacheNamePattern = requireNonEmptyString(evidence.cacheNamePattern, 'PWA cache name pattern');
|
|
96
|
+
const precacheUrls = normalizePrecacheUrls(evidence.precacheUrls);
|
|
97
|
+
if (cacheNamePattern !== `${cachePrefix}{scope}-${revision}`) {
|
|
98
|
+
throw new Error('PWA cache name pattern is inconsistent.');
|
|
99
|
+
}
|
|
100
|
+
return `'use strict';
|
|
101
|
+
|
|
102
|
+
const CACHE_NAMESPACE = ${JSON.stringify(cachePrefix)};
|
|
103
|
+
const CACHE_SCOPE = encodeURIComponent(self.registration.scope);
|
|
104
|
+
const CACHE_PREFIX = CACHE_NAMESPACE + CACHE_SCOPE + '-';
|
|
105
|
+
const CACHE_NAME = ${JSON.stringify(cacheNamePattern)}.replace('{scope}', CACHE_SCOPE);
|
|
106
|
+
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
|
|
107
|
+
const INDEX_URL = new URL('./index.html', self.registration.scope).href;
|
|
108
|
+
const APP_BASE_URL = self.registration.scope;
|
|
109
|
+
|
|
110
|
+
function escapeHtmlAttribute(value) {
|
|
111
|
+
return value
|
|
112
|
+
.replaceAll('&', '&')
|
|
113
|
+
.replaceAll('"', '"')
|
|
114
|
+
.replaceAll('<', '<')
|
|
115
|
+
.replaceAll('>', '>');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function normalizeCachedNavigationResponse(response) {
|
|
119
|
+
if (!response.redirected) {
|
|
120
|
+
return response;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Static hosts commonly canonicalize /index.html to /. A redirected response
|
|
124
|
+
// can be stored by Cache API, but Chromium rejects it when a service worker
|
|
125
|
+
// later returns it for a navigation whose URL is different. Reconstructing
|
|
126
|
+
// clears Response.url, so inject an explicit deployment-scope base before
|
|
127
|
+
// returning the same-origin app shell for a nested SPA route.
|
|
128
|
+
const html = await response.text();
|
|
129
|
+
const headPattern = /<head(?:\\s[^>]*)?>/iu;
|
|
130
|
+
|
|
131
|
+
if (!headPattern.test(html)) {
|
|
132
|
+
throw new Error('Cached PWA app shell is missing a head element.');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const headers = new Headers(response.headers);
|
|
136
|
+
headers.delete('content-encoding');
|
|
137
|
+
headers.delete('content-length');
|
|
138
|
+
const base = '<base href="' + escapeHtmlAttribute(APP_BASE_URL) + '">';
|
|
139
|
+
return new Response(html.replace(headPattern, (head) => head + base), {
|
|
140
|
+
headers,
|
|
141
|
+
status: response.status,
|
|
142
|
+
statusText: response.statusText,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
self.addEventListener('install', (event) => {
|
|
147
|
+
event.waitUntil((async () => {
|
|
148
|
+
const cache = await caches.open(CACHE_NAME);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const requests = PRECACHE_URLS.map((url) => new Request(
|
|
152
|
+
new URL(url, self.registration.scope),
|
|
153
|
+
{ cache: 'reload' },
|
|
154
|
+
));
|
|
155
|
+
await cache.addAll(requests);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
try {
|
|
158
|
+
await caches.delete(CACHE_NAME);
|
|
159
|
+
} catch {
|
|
160
|
+
// Preserve the original installation error when cleanup also fails.
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
})());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
self.addEventListener('activate', (event) => {
|
|
168
|
+
event.waitUntil((async () => {
|
|
169
|
+
const cacheNames = await caches.keys();
|
|
170
|
+
await Promise.all(
|
|
171
|
+
cacheNames
|
|
172
|
+
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
|
|
173
|
+
.map((name) => caches.delete(name)),
|
|
174
|
+
);
|
|
175
|
+
await self.clients.claim();
|
|
176
|
+
})());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
self.addEventListener('fetch', (event) => {
|
|
180
|
+
const request = event.request;
|
|
181
|
+
|
|
182
|
+
if (request.method !== 'GET') {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const url = new URL(request.url);
|
|
187
|
+
|
|
188
|
+
if (url.origin !== self.location.origin) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (request.mode === 'navigate') {
|
|
193
|
+
event.respondWith((async () => {
|
|
194
|
+
const cache = await caches.open(CACHE_NAME);
|
|
195
|
+
const cachedIndex = await cache.match(INDEX_URL);
|
|
196
|
+
return cachedIndex === undefined
|
|
197
|
+
? fetch(request)
|
|
198
|
+
: normalizeCachedNavigationResponse(cachedIndex);
|
|
199
|
+
})());
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
event.respondWith((async () => {
|
|
204
|
+
const cache = await caches.open(CACHE_NAME);
|
|
205
|
+
return await cache.match(request, { ignoreSearch: true }) ?? fetch(request);
|
|
206
|
+
})());
|
|
207
|
+
});
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
export function readMicrosoftStorePwaReleaseEvidence(path) {
|
|
211
|
+
return assertMicrosoftStorePwaReleaseEvidence(readJsonFile(path));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Enumerate the precacheable files of a PWA artifact. Sourcemaps, the release
|
|
215
|
+
* evidence document, and the service worker itself are excluded because the
|
|
216
|
+
* release revision only covers the precached payload. Symbolic links are
|
|
217
|
+
* rejected so callers cannot be tricked into hashing files outside the root.
|
|
218
|
+
*/
|
|
219
|
+
/** Enumerate only precache URLs without reading any payload bytes. */
|
|
220
|
+
export function listPrecacheUrls(artifactRoot) {
|
|
221
|
+
const files = [];
|
|
222
|
+
const pendingDirectories = [artifactRoot];
|
|
223
|
+
while (pendingDirectories.length > 0) {
|
|
224
|
+
const directory = pendingDirectories.pop();
|
|
225
|
+
if (directory === undefined) {
|
|
226
|
+
throw new Error('PWA artifact traversal lost its directory.');
|
|
227
|
+
}
|
|
228
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
229
|
+
const path = resolve(directory, entry.name);
|
|
230
|
+
if (entry.isSymbolicLink()) {
|
|
231
|
+
throw new Error(`PWA artifact must not contain symbolic links: ${path}`);
|
|
232
|
+
}
|
|
233
|
+
if (entry.isDirectory()) {
|
|
234
|
+
pendingDirectories.push(path);
|
|
235
|
+
}
|
|
236
|
+
else if (entry.isFile()) {
|
|
237
|
+
files.push(path);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return files
|
|
242
|
+
.filter((path) => !path.endsWith('.map')
|
|
243
|
+
&& !path.endsWith(`${sep}pwa-release.json`)
|
|
244
|
+
&& !path.endsWith(`${sep}service-worker.js`))
|
|
245
|
+
.map((path) => toPrecacheUrl(artifactRoot, path))
|
|
246
|
+
.sort(compareCodeUnits);
|
|
247
|
+
}
|
|
248
|
+
export function listPrecacheEntries(artifactRoot) {
|
|
249
|
+
const files = [];
|
|
250
|
+
const pendingDirectories = [artifactRoot];
|
|
251
|
+
while (pendingDirectories.length > 0) {
|
|
252
|
+
const directory = pendingDirectories.pop();
|
|
253
|
+
if (directory === undefined) {
|
|
254
|
+
throw new Error('PWA artifact traversal lost its directory.');
|
|
255
|
+
}
|
|
256
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
257
|
+
const path = resolve(directory, entry.name);
|
|
258
|
+
if (entry.isSymbolicLink()) {
|
|
259
|
+
throw new Error(`PWA artifact must not contain symbolic links: ${path}`);
|
|
260
|
+
}
|
|
261
|
+
if (entry.isDirectory()) {
|
|
262
|
+
pendingDirectories.push(path);
|
|
263
|
+
}
|
|
264
|
+
else if (entry.isFile()) {
|
|
265
|
+
files.push(path);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return files
|
|
270
|
+
.filter((path) => !path.endsWith('.map')
|
|
271
|
+
&& !path.endsWith(`${sep}pwa-release.json`)
|
|
272
|
+
&& !path.endsWith(`${sep}service-worker.js`))
|
|
273
|
+
.map((path) => ({
|
|
274
|
+
url: toPrecacheUrl(artifactRoot, path),
|
|
275
|
+
source: readFileSync(path),
|
|
276
|
+
}))
|
|
277
|
+
.sort((left, right) => compareCodeUnits(left.url, right.url));
|
|
278
|
+
}
|
|
279
|
+
function normalizePrecacheEntries(entries) {
|
|
280
|
+
const entriesByUrl = new Map();
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const url = assertMicrosoftStorePwaPrecacheUrl(entry.url);
|
|
283
|
+
if (entriesByUrl.has(url)) {
|
|
284
|
+
throw new Error(`Duplicate PWA precache URL: ${url}`);
|
|
285
|
+
}
|
|
286
|
+
if (typeof entry.source !== 'string' && !(entry.source instanceof Uint8Array)) {
|
|
287
|
+
throw new Error(`PWA precache source must be bytes or text: ${url}`);
|
|
288
|
+
}
|
|
289
|
+
entriesByUrl.set(url, entry.source);
|
|
290
|
+
}
|
|
291
|
+
return [...entriesByUrl.entries()]
|
|
292
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
293
|
+
.map(([url, source]) => ({ url, source }));
|
|
294
|
+
}
|
|
295
|
+
function normalizePrecacheUrls(urls) {
|
|
296
|
+
const normalized = urls.map(assertMicrosoftStorePwaPrecacheUrl);
|
|
297
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
298
|
+
throw new Error('PWA precache URLs must be unique.');
|
|
299
|
+
}
|
|
300
|
+
return normalized.sort(compareCodeUnits);
|
|
301
|
+
}
|
|
302
|
+
function toPrecacheUrl(root, path) {
|
|
303
|
+
const portablePath = relative(root, path).split(sep).join('/');
|
|
304
|
+
return assertMicrosoftStorePwaPrecacheUrl(`./${portablePath}`);
|
|
305
|
+
}
|
|
306
|
+
export function requireGameSpecificPwaId(value) {
|
|
307
|
+
const pwaId = requireNonEmptyString(value, 'Microsoft Store PWA manifest id');
|
|
308
|
+
if (pwaId === '.' || pwaId === './' || pwaId === '/') {
|
|
309
|
+
throw new Error('Microsoft Store PWA manifest id must be game-specific.');
|
|
310
|
+
}
|
|
311
|
+
return pwaId;
|
|
312
|
+
}
|
|
313
|
+
function updateRevisionField(hash, value) {
|
|
314
|
+
const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
|
|
315
|
+
hash.update(`${String(bytes.byteLength)}:`);
|
|
316
|
+
hash.update(bytes);
|
|
317
|
+
}
|
|
318
|
+
function requireGitSha(value, label) {
|
|
319
|
+
const sha = requireNonEmptyString(value, label);
|
|
320
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) {
|
|
321
|
+
throw new Error(`${label} must be a full 40-character hexadecimal SHA.`);
|
|
322
|
+
}
|
|
323
|
+
return sha;
|
|
324
|
+
}
|
|
325
|
+
function requireRevision(value) {
|
|
326
|
+
const revision = requireNonEmptyString(value, 'PWA revision');
|
|
327
|
+
if (!/^[0-9a-f]{16}$/u.test(revision)) {
|
|
328
|
+
throw new Error('PWA revision must be a 16-character hexadecimal digest.');
|
|
329
|
+
}
|
|
330
|
+
return revision;
|
|
331
|
+
}
|
|
332
|
+
function requireNonEmptyString(value, label) {
|
|
333
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
334
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
335
|
+
}
|
|
336
|
+
return value.trim();
|
|
337
|
+
}
|
|
338
|
+
function compareCodeUnits(left, right) {
|
|
339
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
340
|
+
}
|
|
341
|
+
function readJsonFile(path) {
|
|
342
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
343
|
+
}
|
package/dist/offline-playtest.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { decodeHTMLAttribute } from 'entities';
|
|
5
|
-
import { build } from 'esbuild';
|
|
5
|
+
import { build, transform } from 'esbuild';
|
|
6
6
|
export const defaultOfflinePlaytestArtifactDir = 'artifacts/web-preview';
|
|
7
7
|
export const defaultOfflinePlaytestOutputDir = 'artifacts/offline-playtest';
|
|
8
8
|
export const defaultOfflinePlaytestMaximumBytes = 25 * 1024 * 1024;
|
|
@@ -530,7 +530,11 @@ async function bundleEntry(entryFile, context) {
|
|
|
530
530
|
platform: 'browser',
|
|
531
531
|
target: 'es2022',
|
|
532
532
|
keepNames: true,
|
|
533
|
-
|
|
533
|
+
minifyWhitespace: true,
|
|
534
|
+
minifySyntax: true,
|
|
535
|
+
// Random deferred markers must not influence esbuild's short identifier choices.
|
|
536
|
+
// Keep their collision resistance; rename identifiers after resolving the markers.
|
|
537
|
+
minifyIdentifiers: false,
|
|
534
538
|
metafile: true,
|
|
535
539
|
sourcemap: false,
|
|
536
540
|
legalComments: 'none',
|
|
@@ -542,7 +546,18 @@ async function bundleEntry(entryFile, context) {
|
|
|
542
546
|
throw new Error('The offline playtest bundler did not produce JavaScript.');
|
|
543
547
|
}
|
|
544
548
|
recordRetainedBundledAssets(result.metafile, context);
|
|
545
|
-
const
|
|
549
|
+
const resolvedScript = resolveDeferredJavaScriptAssets(scriptOutput.text, deferredAssets, context);
|
|
550
|
+
const { code: script } = await transform(resolvedScript, {
|
|
551
|
+
loader: 'js',
|
|
552
|
+
format: 'esm',
|
|
553
|
+
target: 'es2022',
|
|
554
|
+
keepNames: true,
|
|
555
|
+
minifyIdentifiers: true,
|
|
556
|
+
minifyWhitespace: true,
|
|
557
|
+
minifySyntax: false,
|
|
558
|
+
legalComments: 'none',
|
|
559
|
+
sourcemap: false,
|
|
560
|
+
});
|
|
546
561
|
const stylesheetOutput = result.outputFiles.find((file) => file.path.endsWith('.css'));
|
|
547
562
|
assertNoEntryImportMetaUrl(script);
|
|
548
563
|
assertSupportedBundledRuntime(script);
|
|
@@ -1271,7 +1286,8 @@ function parseStaticElementConstructor(initializer, rawInitializer, allowAnyCrea
|
|
|
1271
1286
|
if (constructor?.[2] === undefined) {
|
|
1272
1287
|
const initializerCodePositions = createCodePositionMap(rawInitializer, true);
|
|
1273
1288
|
const normalizedInitializer = maskNonCode(rawInitializer, initializerCodePositions).trim();
|
|
1274
|
-
|
|
1289
|
+
// The code mask retains the opening quote of a literal so token boundaries survive.
|
|
1290
|
+
const createdElement = /^(?:(globalThis|self|window)\s*\.\s*)?document\s*\.\s*createElement\s*\(\s*["'`]\s*\)\s*$/u.exec(normalizedInitializer);
|
|
1275
1291
|
let openingParenthesis = -1;
|
|
1276
1292
|
for (let index = 0; index < rawInitializer.length; index += 1) {
|
|
1277
1293
|
if (rawInitializer[index] === '(' && initializerCodePositions[index] === 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,17 +53,17 @@
|
|
|
53
53
|
"ttsc": "0.18.4",
|
|
54
54
|
"typescript": "7.0.2",
|
|
55
55
|
"@mpgd/adapter-ait": "0.12.1",
|
|
56
|
-
"@mpgd/adapter-
|
|
57
|
-
"@mpgd/adapter-devvit": "0.9.6",
|
|
58
|
-
"@mpgd/adapter-verse8": "0.3.4",
|
|
56
|
+
"@mpgd/adapter-devvit": "0.9.7",
|
|
59
57
|
"@mpgd/adapter-browser": "0.7.3",
|
|
58
|
+
"@mpgd/adapter-verse8": "0.3.5",
|
|
59
|
+
"@mpgd/adapter-capacitor": "0.4.10",
|
|
60
60
|
"@mpgd/analytics": "0.3.12",
|
|
61
61
|
"@mpgd/bridge": "0.9.0",
|
|
62
62
|
"@mpgd/catalog": "0.7.2",
|
|
63
|
-
"@mpgd/game-services": "0.
|
|
64
|
-
"@mpgd/i18n": "0.6.
|
|
63
|
+
"@mpgd/game-services": "0.15.0",
|
|
64
|
+
"@mpgd/i18n": "0.6.1",
|
|
65
65
|
"@mpgd/phaser-assets": "0.4.1",
|
|
66
|
-
"@mpgd/target-config": "0.
|
|
66
|
+
"@mpgd/target-config": "0.15.0",
|
|
67
67
|
"@mpgd/platform": "0.12.0"
|
|
68
68
|
},
|
|
69
69
|
"main": "./dist/index.js",
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import gameProjectConfig from '../mpgd.game.json';
|
|
2
|
+
|
|
1
3
|
import { renderEntryFailure } from './runtime/renderEntryFailure';
|
|
4
|
+
import {
|
|
5
|
+
installTextSelectionPolicy,
|
|
6
|
+
resolveTextSelectionMode,
|
|
7
|
+
} from './platform/textSelection';
|
|
2
8
|
|
|
3
9
|
try {
|
|
10
|
+
installTextSelectionPolicy(
|
|
11
|
+
resolveTextSelectionMode((gameProjectConfig as { readonly ui?: unknown }).ui),
|
|
12
|
+
);
|
|
13
|
+
|
|
4
14
|
if (__APP_TARGET__ === 'reddit') {
|
|
5
15
|
await import('./platform/devvitEntrypoint');
|
|
6
16
|
} else {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import gameProjectConfig from '../mpgd.game.json';
|
|
2
|
+
|
|
1
3
|
import { renderEntryFailure } from './runtime/renderEntryFailure';
|
|
4
|
+
import {
|
|
5
|
+
installTextSelectionPolicy,
|
|
6
|
+
resolveTextSelectionMode,
|
|
7
|
+
} from './platform/textSelection';
|
|
2
8
|
|
|
3
9
|
try {
|
|
10
|
+
installTextSelectionPolicy(
|
|
11
|
+
resolveTextSelectionMode((gameProjectConfig as { readonly ui?: unknown }).ui),
|
|
12
|
+
);
|
|
13
|
+
|
|
4
14
|
await import('./main');
|
|
5
15
|
} catch (error) {
|
|
6
16
|
renderEntryFailure(error);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Game-wide text selection policy.
|
|
3
|
+
*
|
|
4
|
+
* Games render tap targets, not documents, so text selection is disabled
|
|
5
|
+
* globally by default and re-enabled only for input fields and elements that
|
|
6
|
+
* explicitly opt in through {@link markSelectable}.
|
|
7
|
+
*
|
|
8
|
+
* Under the disabled policy the stylesheet layers, in ascending precedence:
|
|
9
|
+
* default suppression, editable contenteditable hosts (any value except
|
|
10
|
+
* explicit `false`, descendants included), explicit non-editable islands, and
|
|
11
|
+
* finally form fields plus the {@link selectableElementClassName} opt-in.
|
|
12
|
+
* All selectors are :where() normalized, so game CSS can still override the
|
|
13
|
+
* policy for bespoke elements.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type TextSelectionMode = 'disabled' | 'enabled';
|
|
17
|
+
|
|
18
|
+
/** Class that re-enables text selection on an element and its descendants. */
|
|
19
|
+
export const selectableElementClassName = 'mpgd-selectable';
|
|
20
|
+
|
|
21
|
+
const textSelectionStylesheetId = 'mpgd-text-selection-policy';
|
|
22
|
+
|
|
23
|
+
export interface SelectableElement {
|
|
24
|
+
readonly classList: {
|
|
25
|
+
add(...tokens: string[]): void;
|
|
26
|
+
remove(...tokens: string[]): void;
|
|
27
|
+
contains(token: string): boolean;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse the `ui.textSelection` game setting, defaulting to disabled. */
|
|
32
|
+
export function resolveTextSelectionMode(uiConfig: unknown): TextSelectionMode {
|
|
33
|
+
if (uiConfig === undefined) {
|
|
34
|
+
return 'disabled';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof uiConfig !== 'object' || uiConfig === null || Array.isArray(uiConfig)) {
|
|
38
|
+
throw new Error('mpgd.game.json ui must be an object when present.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const key of Object.keys(uiConfig)) {
|
|
42
|
+
if (key !== 'textSelection') {
|
|
43
|
+
throw new Error(`mpgd.game.json ui.${key} is not supported.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const textSelection = (uiConfig as Record<string, unknown>).textSelection;
|
|
48
|
+
|
|
49
|
+
if (textSelection === undefined) {
|
|
50
|
+
return 'disabled';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (textSelection !== 'disabled' && textSelection !== 'enabled') {
|
|
54
|
+
throw new Error("mpgd.game.json ui.textSelection must be 'disabled' or 'enabled'.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return textSelection;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build the stylesheet that enforces the selection policy. */
|
|
61
|
+
export function buildTextSelectionStylesheet(mode: TextSelectionMode): string {
|
|
62
|
+
if (mode === 'enabled') {
|
|
63
|
+
return '/* mpgd: text selection enabled; browser default applies. */\n';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every selector is wrapped in :where() so all rules share zero specificity
|
|
67
|
+
// and source order defines the precedence:
|
|
68
|
+
// default suppression < editable hosts < explicit non-editable islands
|
|
69
|
+
// < form fields and the selectable opt-in utility.
|
|
70
|
+
return [
|
|
71
|
+
'*, *::before, *::after {',
|
|
72
|
+
' -webkit-user-select: none;',
|
|
73
|
+
' user-select: none;',
|
|
74
|
+
' -webkit-touch-callout: none;',
|
|
75
|
+
'}',
|
|
76
|
+
":where([contenteditable]:not([contenteditable='false'])),",
|
|
77
|
+
":where([contenteditable]:not([contenteditable='false'])) * {",
|
|
78
|
+
' -webkit-user-select: text;',
|
|
79
|
+
' user-select: text;',
|
|
80
|
+
' -webkit-touch-callout: default;',
|
|
81
|
+
'}',
|
|
82
|
+
":where([contenteditable='false']), :where([contenteditable='false']) * {",
|
|
83
|
+
' -webkit-user-select: none;',
|
|
84
|
+
' user-select: none;',
|
|
85
|
+
' -webkit-touch-callout: none;',
|
|
86
|
+
'}',
|
|
87
|
+
`:where(input, textarea, .${selectableElementClassName}),`,
|
|
88
|
+
`:where(input, textarea, .${selectableElementClassName}) * {`,
|
|
89
|
+
' -webkit-user-select: text;',
|
|
90
|
+
' user-select: text;',
|
|
91
|
+
' -webkit-touch-callout: default;',
|
|
92
|
+
'}',
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Install (or refresh) the global selection policy stylesheet. */
|
|
97
|
+
export function installTextSelectionPolicy(mode: TextSelectionMode): void {
|
|
98
|
+
if (typeof document === 'undefined') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let stylesheet = document.getElementById(textSelectionStylesheetId);
|
|
103
|
+
|
|
104
|
+
if (stylesheet === null) {
|
|
105
|
+
stylesheet = document.createElement('style');
|
|
106
|
+
stylesheet.id = textSelectionStylesheetId;
|
|
107
|
+
document.head.append(stylesheet);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
stylesheet.textContent = buildTextSelectionStylesheet(mode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Re-enable text selection for a specific UI element and its descendants. */
|
|
114
|
+
export function markSelectable(element: SelectableElement): void {
|
|
115
|
+
element.classList.add(selectableElementClassName);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Withdraw a previous {@link markSelectable} opt-in. */
|
|
119
|
+
export function unmarkSelectable(element: SelectableElement): void {
|
|
120
|
+
element.classList.remove(selectableElementClassName);
|
|
121
|
+
}
|