@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.
@@ -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('<', '&lt;')
115
+ .replaceAll('>', '&gt;');
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
+ }
@@ -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
- minify: true,
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 script = resolveDeferredJavaScriptAssets(scriptOutput.text, deferredAssets, context);
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
- const createdElement = /^(?:(globalThis|self|window)\s*\.\s*)?document\s*\.\s*createElement\s*\(\s*\)\s*$/u.exec(normalizedInitializer);
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.28.1",
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-capacitor": "0.4.10",
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.14.0",
64
- "@mpgd/i18n": "0.6.0",
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.14.0",
66
+ "@mpgd/target-config": "0.15.0",
67
67
  "@mpgd/platform": "0.12.0"
68
68
  },
69
69
  "main": "./dist/index.js",
@@ -36,6 +36,10 @@
36
36
  "id": "platform.gateway.bootstrap-intents",
37
37
  "owner": "src/main.ts"
38
38
  },
39
+ {
40
+ "id": "platform.ui.text-selection",
41
+ "owner": "src/platform/textSelection.ts"
42
+ },
39
43
  {
40
44
  "id": "runtime.viewport.orientation-policy",
41
45
  "owner": "src/main.ts"
@@ -5,6 +5,9 @@
5
5
  "backgroundColor": "#0f172a"
6
6
  }
7
7
  },
8
+ "ui": {
9
+ "textSelection": "disabled"
10
+ },
8
11
  "acceptance": {
9
12
  "gameplay": {
10
13
  "schemaVersion": 1,
@@ -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
+ }