@geastack/core 0.1.39 → 0.1.40

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,155 @@
1
+ import fs from "node:fs";
2
+
3
+ function integer(value, name, minimum = 0, maximum = 65535) {
4
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
5
+ throw new Error(`Invalid bitmap font ${name}`);
6
+ }
7
+
8
+ return value;
9
+ }
10
+
11
+ export function loadBitmapFontSource(filename) {
12
+ const source = JSON.parse(fs.readFileSync(filename, "utf8"));
13
+
14
+ if (
15
+ source.schemaVersion !== 1 ||
16
+ source.format !== "gea-bitmap-font" ||
17
+ !Array.isArray(source.fonts)
18
+ ) {
19
+ throw new Error(`Invalid bitmap font source: ${filename}`);
20
+ }
21
+
22
+ return source;
23
+ }
24
+
25
+ export function buildBitmapFont(source, tuple, packGlyphAtlas) {
26
+ const face = source.fonts.find((font) => font.sizePx === tuple.sizePx);
27
+
28
+ if (!face)
29
+ throw new Error(
30
+ `Bitmap font ${tuple.family} has no exact ${tuple.sizePx}px face`,
31
+ );
32
+ const lineHeight = integer(face.lineHeight, "lineHeight", 1, 1024);
33
+ const ascender = integer(face.ascender, "ascender", 0, lineHeight);
34
+ const descender = integer(face.descender, "descender", 0, lineHeight);
35
+ if (ascender + descender !== lineHeight)
36
+ throw new Error("Invalid bitmap font line metrics");
37
+ const glyphs = [];
38
+ const bitmaps = [];
39
+ const available = new Set();
40
+ const requested = tuple.charset;
41
+ const fallbackCodepoint =
42
+ face.fallbackCodepoint === undefined
43
+ ? -1
44
+ : integer(face.fallbackCodepoint, "fallbackCodepoint", 0, 0x10ffff);
45
+
46
+ if (!Array.isArray(face.glyphs) || face.glyphs.length === 0)
47
+ throw new Error("Bitmap font has no glyphs");
48
+ for (const item of face.glyphs) {
49
+ const cp = integer(item.codepoint, "codepoint", 0, 0x10ffff);
50
+
51
+ if (available.has(cp)) throw new Error("Duplicate bitmap font glyph");
52
+ available.add(cp);
53
+ const width = integer(item.width, "glyph width", 0, 512);
54
+ const height = integer(item.height, "glyph height", 0, 512);
55
+ const advance16 = integer(item.advance16, "advance16", 0, 32767);
56
+ const bearingX = integer(item.bearingX, "bearingX", -1024, 1024);
57
+ const bearingY = integer(item.bearingY, "bearingY", -1024, 1024);
58
+ if (
59
+ typeof item.coverage !== "string" ||
60
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
61
+ item.coverage,
62
+ )
63
+ ) {
64
+ throw new Error("Invalid bitmap glyph coverage encoding");
65
+ }
66
+
67
+ const coverage = Buffer.from(item.coverage, "base64");
68
+
69
+ if (coverage.length !== width * height)
70
+ throw new Error("Invalid bitmap glyph coverage length");
71
+ if (
72
+ requested &&
73
+ !requested.has(cp) &&
74
+ cp !== 63 &&
75
+ cp !== fallbackCodepoint
76
+ )
77
+ continue;
78
+ glyphs.push({
79
+ codepoint: cp,
80
+ sourceX: 0,
81
+ sourceY: 0,
82
+ width,
83
+ height,
84
+ advance: (advance16 + 8) >> 4,
85
+ bearingX,
86
+ bearingY,
87
+ advance16,
88
+ });
89
+ bitmaps.push(coverage);
90
+ }
91
+
92
+ if (fallbackCodepoint >= 0 && !available.has(fallbackCodepoint))
93
+ throw new Error("Bitmap fallback glyph is absent");
94
+ if (glyphs.length === 0)
95
+ throw new Error("Bitmap font charset selects no available glyphs");
96
+ const records = glyphs
97
+ .map((glyph, index) => ({ glyph, bitmap: bitmaps[index] }))
98
+ .sort((a, b) => a.glyph.codepoint - b.glyph.codepoint);
99
+ const sortedGlyphs = records.map((record) => record.glyph);
100
+ const { atlasWidth, atlasHeight } = packGlyphAtlas(sortedGlyphs);
101
+ const width = Math.max(1, atlasWidth);
102
+ const height = Math.max(1, atlasHeight);
103
+ const atlasData = new Uint8Array(width * height);
104
+
105
+ for (const { glyph, bitmap } of records) {
106
+ for (let row = 0; row < glyph.height; row++) {
107
+ atlasData.set(
108
+ bitmap.subarray(row * glyph.width, (row + 1) * glyph.width),
109
+ (glyph.sourceY + row) * width + glyph.sourceX,
110
+ );
111
+ }
112
+ }
113
+
114
+ const selected = new Set(sortedGlyphs.map((glyph) => glyph.codepoint));
115
+ const kerning = [];
116
+ const pairs = new Set();
117
+ for (const pair of face.kerning || []) {
118
+ const left = integer(pair.left, "kerning left", 0, 0x10ffff);
119
+ const right = integer(pair.right, "kerning right", 0, 0x10ffff);
120
+ const adjustment16 = integer(
121
+ pair.adjustment16,
122
+ "kerning adjustment",
123
+ -32768,
124
+ 32767,
125
+ );
126
+ const key = `${left}:${right}`;
127
+
128
+ if (pairs.has(key)) throw new Error("Duplicate bitmap font kerning pair");
129
+ pairs.add(key);
130
+ if (!available.has(left) || !available.has(right))
131
+ throw new Error("Kerning references absent glyph");
132
+ if (selected.has(left) && selected.has(right) && adjustment16 !== 0)
133
+ kerning.push({ left, right, adjustment16 });
134
+ }
135
+
136
+ kerning.sort((a, b) => a.left - b.left || a.right - b.right);
137
+
138
+ return {
139
+ id: tuple.id,
140
+ family: tuple.family,
141
+ familyId: tuple.familyId,
142
+ sizePx: tuple.sizePx,
143
+ lineHeight,
144
+ ascender,
145
+ descender,
146
+ glyphs: sortedGlyphs,
147
+ atlasWidth: width,
148
+ atlasHeight: height,
149
+ atlasBits: 8,
150
+ atlasData,
151
+ kerning,
152
+ importedBitmap: true,
153
+ fallbackCodepoint,
154
+ };
155
+ }
@@ -253,7 +253,18 @@ function copyCompatSourceTree(srcDir, dstDir, envDefines = {}) {
253
253
  for (const module of native.modules) {
254
254
  nativeModules.set(`${module.kind}:${module.url}`, { ...module, stagedEntry: path.resolve(path.dirname(dst), path.relative(path.dirname(src), module.entry)) })
255
255
  }
256
- fs.writeFileSync(dst, inlineProcessEnv(transformGeaEmbeddedCompatSource(inlineJsonImports(native.code, src), src), envDefines))
256
+ let debugMap
257
+ const preparedSource = inlineJsonImports(native.code, src)
258
+ const transformed = transformGeaEmbeddedCompatSource(preparedSource, src,
259
+ process.env.GEA_NATIVE_DEBUGGER === '1' ? map => { debugMap = map } : undefined)
260
+ fs.writeFileSync(dst, inlineProcessEnv(transformed, envDefines))
261
+ if (process.env.GEA_NATIVE_DEBUGGER === '1') {
262
+ fs.writeFileSync(dst + '.gea-source.json', JSON.stringify({
263
+ source: src, original: code, staged: fs.readFileSync(dst, 'utf8'),
264
+ map: debugMap || null, prepared: preparedSource,
265
+ unmappedTransforms: preparedSource !== code || transformed !== inlineProcessEnv(transformed, envDefines)
266
+ }))
267
+ }
257
268
  } else {
258
269
  fs.copyFileSync(src, dst)
259
270
  if (path.extname(entry.name) === '.css') copyCssUrlAssets(src, dst)
@@ -451,7 +462,72 @@ function resolveAppleNativeAliasModules(appDir) {
451
462
  return { threeSrcDir, threeUtilsModule, threeWebGLAnimationModule, threeWebXRManagerModule, troikaThreeTextModule }
452
463
  }
453
464
 
454
- function writeCompatViteConfig({ configPath, compatSrcDir, appDir, entry, viteOutDir, geaIrPath, appleNative, moduleGraphOutDir, envDefines }) {
465
+ // `gea.moduleAliases` in the application's package.json: exact import
466
+ // specifiers redirected to files inside the application, for a module whose
467
+ // host exists on one target only (an app's macOS audio host, say) and that
468
+ // another target replaces with its own implementation.
469
+ // Each replacement names the app's STAGED copy when there is one: the compat
470
+ // build compiles the staged tree, so an alias into the app directory itself
471
+ // pulled a second copy of every module the replacement imports (skytail's
472
+ // mosaico kit index brought its own kit-types.ts, and the two KitCategory
473
+ // records refused to convert).
474
+ function resolveAppModuleAliases(appDir, stagedAppDir = appDir) {
475
+ let manifest = {}
476
+ try {
477
+ manifest = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).gea ?? {}
478
+ } catch {
479
+ return []
480
+ }
481
+ const raw = manifest.moduleAliases
482
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
483
+ const aliases = []
484
+ for (const [specifier, target] of Object.entries(raw)) {
485
+ if (typeof target !== 'string' || !target) fail(`gea.moduleAliases["${specifier}"] must name a file inside the application`)
486
+ if (!fs.existsSync(path.resolve(appDir, target))) fail(`gea.moduleAliases["${specifier}"] does not exist: ${target}`)
487
+ aliases.push({ specifier, replacement: path.resolve(stagedAppDir, target) })
488
+ }
489
+ return aliases
490
+ }
491
+
492
+ // The gea-threejs port, when the application depends on it: its src/ directory,
493
+ // and the native troika text module when the application uses troika.
494
+ // Opt-in: an application that sets `"gea": { "geaThreejs": true }` in its
495
+ // package.json. Depending on the port is not enough on its own, so an app that
496
+ // installs it for some other reason keeps compiling three.js itself.
497
+ function resolveGeaThreeAliasModules(appDir) {
498
+ const appRequire = createRequire(path.join(appDir, 'package.json'))
499
+ let geaThreeSrcDir = ''
500
+ let troikaThreeTextModule = ''
501
+ let optedIn = false
502
+ try {
503
+ optedIn = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).gea?.geaThreejs === true
504
+ } catch {
505
+ // An unreadable manifest opts into nothing; the build reports it elsewhere.
506
+ }
507
+ if (!optedIn) return { geaThreeSrcDir, troikaThreeTextModule }
508
+ try {
509
+ geaThreeSrcDir = path.join(path.dirname(fs.realpathSync(appRequire.resolve('@geastack/gea-threejs/package.json'))), 'src')
510
+ } catch {
511
+ return { geaThreeSrcDir, troikaThreeTextModule }
512
+ }
513
+ try {
514
+ appRequire.resolve('troika-three-text')
515
+ } catch {
516
+ return { geaThreeSrcDir, troikaThreeTextModule }
517
+ }
518
+ try {
519
+ troikaThreeTextModule = fs.realpathSync(appRequire.resolve('@geastack/native-webgl-angle/troika-three-text'))
520
+ } catch {
521
+ fail(
522
+ `This build resolved "troika-three-text" and "@geastack/gea-threejs" from ${appDir}, but could not resolve ` +
523
+ '"@geastack/native-webgl-angle/troika-three-text", the native text module that replaces it. ' +
524
+ 'Install @geastack/native-webgl-angle in the application.'
525
+ )
526
+ }
527
+ return { geaThreeSrcDir, troikaThreeTextModule }
528
+ }
529
+
530
+ function writeCompatViteConfig({ configPath, compatSrcDir, appDir, stagedAppDir, entry, viteOutDir, geaIrPath, appleNative, moduleGraphOutDir, envDefines }) {
455
531
  const engineComponentsPath = path.join(engineRoot, 'components/index.ts')
456
532
  const engineComponentSubpath = path.join(engineRoot, 'components/$1')
457
533
  const elementsPath = path.join(elementsRoot, 'components/index.ts')
@@ -467,7 +543,13 @@ function writeCompatViteConfig({ configPath, compatSrcDir, appDir, entry, viteOu
467
543
  const { threeSrcDir, threeUtilsModule, threeWebGLAnimationModule, threeWebXRManagerModule, troikaThreeTextModule } = appleNative
468
544
  ? resolveAppleNativeAliasModules(appDir)
469
545
  : { threeSrcDir: '', threeUtilsModule: '', threeWebGLAnimationModule: '', threeWebXRManagerModule: '', troikaThreeTextModule: '' }
546
+ // Apple-native keeps three.js itself (it renders through ANGLE).
547
+ const geaThree = appleNative ? { geaThreeSrcDir: '', troikaThreeTextModule: '' } : resolveGeaThreeAliasModules(appDir)
470
548
  const q = (value) => JSON.stringify(value)
549
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
550
+ const appModuleAliases = resolveAppModuleAliases(appDir, stagedAppDir)
551
+ .map(({ specifier, replacement }) => ` { find: new RegExp(${q('^' + escapeRegExp(specifier) + '$')}), replacement: ${q(replacement)} },\n`)
552
+ .join('')
471
553
  // Compile the @geajs/core reactive runtime (Component/Store/compiler-runtime)
472
554
  // from its TYPED SOURCE, not the minified dist. The minified build reuses short
473
555
  // identifiers across nested scopes, which the C++ symbol collector cannot
@@ -496,7 +578,7 @@ function writeCompatViteConfig({ configPath, compatSrcDir, appDir, entry, viteOu
496
578
  const config = `import { resolve } from 'node:path'
497
579
  import { geaPlugin } from ${q(geaPluginResolved)}
498
580
  import { geaNativeStylePlugin } from ${q(path.join(coreRoot, 'scripts/gea-native-style-plugin.mjs'))}
499
- import { geaAppleNativeModuleAliases, geaModuleGraphPlugins } from ${q(moduleGraphPluginPath)}
581
+ import { geaAppleNativeModuleAliases, geaModuleGraphPlugins, geaThreeModuleAliases } from ${q(moduleGraphPluginPath)}
500
582
 
501
583
  const root = ${q(compatSrcDir)}
502
584
  const geaModuleGraph = geaModuleGraphPlugins({ outDir: ${q(moduleGraphOutDir || '')}, entryReachableOnly: true })
@@ -507,6 +589,10 @@ const geaAppleNativeAliases = geaAppleNativeModuleAliases({
507
589
  threeWebXRManagerModule: ${q(threeWebXRManagerModule)},
508
590
  troikaThreeTextModule: ${q(troikaThreeTextModule)},
509
591
  })
592
+ const geaThreeAliases = geaThreeModuleAliases({
593
+ geaThreeSrcDir: ${q(geaThree.geaThreeSrcDir)},
594
+ troikaThreeTextModule: ${q(geaThree.troikaThreeTextModule)},
595
+ })
510
596
 
511
597
  export default {
512
598
  root,
@@ -516,6 +602,7 @@ ${defineEntries}
516
602
  plugins: [geaNativeStylePlugin({ runtimeEntry: ${q(compilerRuntimeEntry)} }), geaPlugin({ ir: { enabled: true, outFile: ${q(geaIrPath)} } }), ...geaModuleGraph],
517
603
  resolve: {
518
604
  alias: [
605
+ ${appModuleAliases} ...geaThreeAliases,
519
606
  ${compilerRuntimeAlias}
520
607
  { find: new RegExp('^@geastack/core$'), replacement: ${q(runtimePath)} },
521
608
  { find: new RegExp('^@geastack/engine$'), replacement: ${q(engineComponentsPath)} },
@@ -2011,7 +2098,6 @@ function cssIgnoredProperty(property) {
2011
2098
  'scroll-snap-type',
2012
2099
  'scrollbar-width',
2013
2100
  'text-shadow',
2014
- 'transition',
2015
2101
  'cursor',
2016
2102
  '-webkit-tap-highlight-color',
2017
2103
  ]).has(property)
@@ -4428,7 +4514,7 @@ if (geaEmbeddedCompat) {
4428
4514
  }
4429
4515
  stagedEntryFile = path.join(compatSrcDir, compatEntry)
4430
4516
  fontSourceDir = compatSrcDir
4431
- writeCompatViteConfig({ configPath: viteConfig, compatSrcDir, appDir, entry: compatEntry, viteOutDir, geaIrPath, appleNative, moduleGraphOutDir, envDefines })
4517
+ writeCompatViteConfig({ configPath: viteConfig, compatSrcDir, appDir, stagedAppDir: path.join(compatSrcDir, path.relative(stagingRoot, appDir)), entry: compatEntry, viteOutDir, geaIrPath, appleNative, moduleGraphOutDir, envDefines })
4432
4518
  } else if (appleNative) {
4433
4519
  const baseViteConfig = viteConfig
4434
4520
  viteConfig = path.join(outDir, 'gea-apple-native.vite.config.mjs')
@@ -4559,6 +4645,10 @@ const geatscArgs = [
4559
4645
  `gea.ir=${geaIrPath}`,
4560
4646
  '--plugin-option',
4561
4647
  `gea.microtasks-namespace=${microtasksNamespaceForEntry(entrySymbol)}`,
4648
+ '--plugin-option',
4649
+ `gea.native-signature-core-root=${coreRoot}`,
4650
+ '--plugin-option',
4651
+ `gea.native-signature-host-root=${process.env.GEA_HOST_DIR || path.resolve(coreRoot, '..', 'host')}`,
4562
4652
  ]
4563
4653
  if (!moduleGraphCompile) geatscArgs.push('--target', 'cpp')
4564
4654
  if (moduleGraphCompile) {
@@ -4578,6 +4668,7 @@ if (cxxStandard) geatscArgs.push('--cxx-standard', cxxStandard)
4578
4668
  if (hasFlag('--allow-any') || compilerSettings.allowAny) geatscArgs.push('--allow-any')
4579
4669
  if (entrySymbol) geatscArgs.push('--entry-symbol', entrySymbol)
4580
4670
  if (hasFlag('--isolate-symbols')) geatscArgs.push('--isolate-symbols')
4671
+ if (process.env.GEA_NATIVE_DEBUGGER === '1') geatscArgs.push('--debug-source')
4581
4672
  // One C++ unit per source module plus a shared header, instead of one unit for
4582
4673
  // the whole program. Every target already reads `geatsc-sources.txt` as a LIST,
4583
4674
  // so nothing downstream changes; see the compiler's docs/TRANSLATION-UNITS.md
@@ -4606,7 +4697,7 @@ if (nativeModules.size) {
4606
4697
  if (!fs.existsSync(module.stagedEntry)) fail(`missing ${module.kind} module: ${module.entry}`)
4607
4698
  if (module.kind === 'worklet') {
4608
4699
  const implementation = path.join(coreRoot, 'runtime/audio-worklet.ts')
4609
- const bindings = `import { AudioWorkletProcessor, registerProcessor } from ${JSON.stringify(implementation)}\n`
4700
+ const bindings = `import { AudioWorkletProcessor, registerProcessor } from ${JSON.stringify(implementation.slice(0, -3))}\n`
4610
4701
  const source = fs.readFileSync(module.stagedEntry, 'utf8')
4611
4702
  fs.writeFileSync(module.stagedEntry, bindings + source)
4612
4703
  const compilerRequire = createRequire(pathToFileURL(geatscBin))
@@ -0,0 +1,38 @@
1
+ import path from "node:path";
2
+
3
+ export function compileTimeOnlyAssetPaths(manifest) {
4
+ const configured = manifest?.gea?.embeddedAssets?.compileTimeOnly;
5
+
6
+ if (configured === undefined) {
7
+ return new Set();
8
+ }
9
+
10
+ if (!Array.isArray(configured)) {
11
+ throw new Error(
12
+ "gea.embeddedAssets.compileTimeOnly must be an array of app-relative file paths",
13
+ );
14
+ }
15
+
16
+ const result = new Set();
17
+
18
+ for (const entry of configured) {
19
+ if (
20
+ typeof entry !== "string" ||
21
+ entry.length === 0 ||
22
+ entry.includes("\\") ||
23
+ path.posix.isAbsolute(entry) ||
24
+ path.posix.normalize(entry) !== entry ||
25
+ entry === "." ||
26
+ entry === ".." ||
27
+ entry.startsWith("../")
28
+ ) {
29
+ throw new Error(
30
+ "Compile-time asset paths must be normalized relative paths inside the app",
31
+ );
32
+ }
33
+
34
+ result.add(entry);
35
+ }
36
+
37
+ return result;
38
+ }
@@ -212,7 +212,7 @@ function transformTopLevelStatement(stmt) {
212
212
  return { changed: false, nodes: [stmt] }
213
213
  }
214
214
 
215
- export function transformGeaEmbeddedCompatSource(code, filename) {
215
+ export function transformGeaEmbeddedCompatSource(code, filename, onSourceMap) {
216
216
  if ((!code.includes('<') || !code.includes('>')) && !code.includes('@geastack/core') && !code.includes('@geajs/core')) return code
217
217
 
218
218
  const ast = parser.parse(code, {
@@ -246,7 +246,9 @@ export function transformGeaEmbeddedCompatSource(code, filename) {
246
246
  ensureComponentImport(ast, statesProps)
247
247
  }
248
248
 
249
- return generate(ast, { retainLines: true }, code).code
249
+ const result = generate(ast, { retainLines: true, sourceMaps: !!onSourceMap, sourceFileName: filename }, code)
250
+ if (onSourceMap) onSourceMap(result.map)
251
+ return result.code
250
252
  }
251
253
 
252
254
  // ─── JSON-import constant folding ────────────────────────────────────────────
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/i
7
7
  const STATIC_ASSET_RE = /\.(?:apng|bmp|png|jpe?g|jfif|pjpeg|pjp|gif|svg|ico|webp|avif|cur|jxl|mp4|webm|ogg|mp3|wav|flac|aac|opus|mov|m4a|vtt|woff2?|eot|ttf|otf|webmanifest|pdf|txt|glb|gltf|hdr)$/i
8
8
  const STATIC_ASSET_MODULE_SUFFIX = '.geaassetmodule.js'
9
+ const RAW_MODULE_SUFFIX = '.gearawmodule.js'
9
10
  const STATIC_IMPORT_RE = /\bimport\s+(?:[^'"()]*?\s+from\s*)?["']([^"']+)["']/g
10
11
  const EXPORT_FROM_RE = /\bexport\s+(?:[^'"]*?\s+from\s*)["']([^"']+)["']/g
11
12
  const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g
@@ -65,6 +66,30 @@ export function geaAppleNativeModuleAliases(options = {}) {
65
66
  return aliases
66
67
  }
67
68
 
69
+ // An app that opts into @geastack/gea-threejs (three.js ported to strictly
70
+ // typed TypeScript, with a native software renderer) by setting
71
+ // `"gea": { "geaThreejs": true }` gets three's import surface redirected to
72
+ // the port on non-apple native targets: bare `three`,
73
+ // every `three/src/*.js` path (to its `.ts` source), and `troika-three-text`
74
+ // (to the native atlas text, whose own `three/src` imports land on the port
75
+ // through the same aliases).
76
+ export function geaThreeModuleAliases(options = {}) {
77
+ const aliases = []
78
+ if (!options.geaThreeSrcDir) return aliases
79
+ if (!path.isAbsolute(options.geaThreeSrcDir)) {
80
+ throw new TypeError('geaThreeSrcDir must be an absolute directory resolved from the application package boundary')
81
+ }
82
+ aliases.push({ find: /^three$/, replacement: path.join(options.geaThreeSrcDir, 'Three.ts') })
83
+ aliases.push({ find: /^three\/src\/(.+)\.js$/, replacement: path.join(options.geaThreeSrcDir, '$1.ts') })
84
+ if (options.troikaThreeTextModule) {
85
+ if (!path.isAbsolute(options.troikaThreeTextModule)) {
86
+ throw new TypeError('troikaThreeTextModule must be an absolute module resolved from the application package boundary')
87
+ }
88
+ aliases.push({ find: /^troika-three-text$/, replacement: options.troikaThreeTextModule })
89
+ }
90
+ return aliases
91
+ }
92
+
68
93
  export function geaModuleGraphPlugins(options = {}) {
69
94
  const outDir = options.outDir || process.env.GEA_VITE_MODULE_GRAPH_OUT || ''
70
95
  if (!outDir) return []
@@ -76,6 +101,7 @@ export function geaModuleGraphPlugins(options = {}) {
76
101
  transformed: new Map(),
77
102
  imports: new Map(),
78
103
  assets: new Map(),
104
+ rawModules: new Map(),
79
105
  // GEA_MODULE_GRAPH_ENTRY_REACHABLE_ONLY=0 keeps the unpruned graph, so a
80
106
  // build can be A/B'd against the reachability filter without editing the
81
107
  // generated vite config.
@@ -95,32 +121,44 @@ export function geaModuleGraphPlugins(options = {}) {
95
121
  // Vite normally turns a plain static-asset import into a JS module that
96
122
  // exports the emitted browser URL. The native compiler consumes source
97
123
  // snapshots instead of the browser bundle, so expose the same binding
98
- // through a compiler-visible synthetic JS module. Query imports retain
99
- // Vite's distinct ?raw/?inline/etc. semantics.
124
+ // through a compiler-visible synthetic JS module. Raw imports export
125
+ // file contents instead; other query imports retain Vite semantics.
100
126
  const normalizedImporter = normalizeModuleId(importer)
127
+ const raw = source.endsWith('?raw')
128
+ const assetSpecifier = raw ? source.slice(0, -4) : source
101
129
  if (
102
130
  !importer ||
103
131
  !shouldTrackModule(normalizedImporter) ||
104
- source.includes('?') ||
105
- source.includes('#') ||
106
- !STATIC_ASSET_RE.test(source)
132
+ assetSpecifier.includes('?') ||
133
+ assetSpecifier.includes('#') ||
134
+ (!raw && !STATIC_ASSET_RE.test(assetSpecifier))
107
135
  ) return null
108
136
 
109
137
  let resolvedId = ''
110
- const resolved = await this.resolve(source, importer, { skipSelf: true })
138
+ const resolved = await this.resolve(assetSpecifier, importer, { skipSelf: true })
111
139
  if (resolved && !resolved.external) resolvedId = normalizeModuleId(resolved.id)
112
- if (!resolvedId && source.startsWith('.')) {
113
- resolvedId = path.resolve(path.dirname(normalizedImporter), source)
140
+ if (!resolvedId && assetSpecifier.startsWith('.')) {
141
+ resolvedId = path.resolve(path.dirname(normalizedImporter), assetSpecifier)
114
142
  }
115
143
  if (!path.isAbsolute(resolvedId) || !fs.existsSync(resolvedId) || !fs.statSync(resolvedId).isFile()) return null
116
144
 
117
145
  const assetSource = fs.realpathSync(resolvedId)
146
+ if (raw) {
147
+ const moduleId = `${assetSource}${RAW_MODULE_SUFFIX}`
148
+ state.rawModules.set(moduleId, fs.readFileSync(assetSource, 'utf8'))
149
+ return moduleId
150
+ }
151
+
118
152
  const moduleId = `${assetSource}${STATIC_ASSET_MODULE_SUFFIX}`
119
153
  if (!state.assets.has(moduleId)) state.assets.set(moduleId, describeStaticAsset(assetSource))
120
154
  return moduleId
121
155
  },
122
156
  load(id) {
123
- const asset = state.assets.get(normalizeModuleId(id))
157
+ const normalizedId = normalizeModuleId(id)
158
+ if (state.rawModules.has(normalizedId)) {
159
+ return `export default ${JSON.stringify(state.rawModules.get(normalizedId))}\n`
160
+ }
161
+ const asset = state.assets.get(normalizedId)
124
162
  if (!asset) return null
125
163
  return `export default ${JSON.stringify(asset.url)}\n`
126
164
  },
@@ -183,10 +221,16 @@ function writeModuleGraph(pluginContext, state) {
183
221
  const id = normalizeModuleId(rawId)
184
222
  if (shouldTrackModule(id) && !rawIdsByNormalizedId.has(id)) rawIdsByNormalizedId.set(id, rawId)
185
223
  }
224
+ // A framework transform can consume an imported asset while leaving the
225
+ // original TS snapshot's binding intact. Rollup then never loads its module;
226
+ // the native graph still needs the resolved literal module for that binding.
227
+ for (const id of [...state.assets.keys(), ...state.rawModules.keys()]) {
228
+ if (!rawIdsByNormalizedId.has(id)) rawIdsByNormalizedId.set(id, id)
229
+ }
186
230
  const modules = []
187
231
  const allIds = Array.from(rawIdsByNormalizedId.keys()).sort()
188
232
  const reachableIds = state.entryReachableOnly
189
- ? entryReachableModuleIds(pluginContext, rawIdsByNormalizedId, allIds)
233
+ ? entryReachableModuleIds(pluginContext, state, rawIdsByNormalizedId, allIds)
190
234
  : allIds
191
235
  const live = liveModuleIds(state, reachableIds)
192
236
  const ids = reachableIds.filter((id) => live.keep.has(id))
@@ -194,11 +238,13 @@ function writeModuleGraph(pluginContext, state) {
194
238
  for (const id of ids) {
195
239
  const info = pluginContext.getModuleInfo(rawIdsByNormalizedId.get(id))
196
240
  const prune = (code) => pruneDeadReExports(code, id, state, live)
197
- const originalCode = prune(normalizeSnapshotSourceForGeatsc(state.original.get(id) ?? readFileSource(id)))
198
- const transformedCode = prune(normalizeSnapshotSourceForGeatsc(state.transformed.get(id)))
241
+ const asset = state.assets.get(id)
242
+ const literal = asset?.url ?? state.rawModules.get(id)
243
+ const syntheticSource = literal === undefined ? undefined : `export default ${JSON.stringify(literal)}\n`
244
+ const originalCode = prune(normalizeSnapshotSourceForGeatsc(state.original.get(id) ?? syntheticSource ?? readFileSource(id)))
245
+ const transformedCode = prune(normalizeSnapshotSourceForGeatsc(state.transformed.get(id) ?? syntheticSource))
199
246
  const originalPath = writeSnapshot(state, id, 'original', originalCode)
200
247
  const transformedPath = writeSnapshot(state, id, 'transformed', transformedCode)
201
- const asset = state.assets.get(id)
202
248
  modules.push({
203
249
  id,
204
250
  file: path.isAbsolute(id) ? id : null,
@@ -397,7 +443,7 @@ function pruneDeadReExports(code, id, state, live) {
397
443
  })
398
444
  }
399
445
 
400
- function entryReachableModuleIds(pluginContext, rawIdsByNormalizedId, allIds) {
446
+ function entryReachableModuleIds(pluginContext, state, rawIdsByNormalizedId, allIds) {
401
447
  const tracked = new Set(allIds)
402
448
  const reachable = new Set()
403
449
  const pending = []
@@ -416,7 +462,10 @@ function entryReachableModuleIds(pluginContext, rawIdsByNormalizedId, allIds) {
416
462
  if (!id || reachable.has(id)) continue
417
463
  reachable.add(id)
418
464
  const info = pluginContext.getModuleInfo(rawIdsByNormalizedId.get(id))
419
- for (const dependency of [...(info?.importedIds ?? []), ...(info?.dynamicallyImportedIds ?? [])]) {
465
+ const snapshotAssets = (state.imports.get(id) ?? [])
466
+ .map((item) => item.resolvedId)
467
+ .filter((target) => state.assets.has(target) || state.rawModules.has(target))
468
+ for (const dependency of [...(info?.importedIds ?? []), ...(info?.dynamicallyImportedIds ?? []), ...snapshotAssets]) {
420
469
  const normalized = normalizeModuleId(dependency)
421
470
  if (tracked.has(normalized) && !reachable.has(normalized)) pending.push(normalized)
422
471
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Scans an app directory for binary assets (images/fonts) and emits a C++ translation
2
+ // Scans an app directory for binary assets (images/fonts/audio) and emits a C++ translation
3
3
  // unit that embeds each one as a named, externally-visible `.rodata` symbol.
4
4
  //
5
5
  // This is the build half of "store images as files, read them as files": the
@@ -23,6 +23,7 @@
23
23
  // an asset filename; the linker keeps one copy.
24
24
 
25
25
  import { describeStaticAsset } from './gea-static-asset.mjs'
26
+ import { compileTimeOnlyAssetPaths } from './gea-embedded-asset-policy.mjs'
26
27
  import fs from 'node:fs'
27
28
  import path from 'node:path'
28
29
  import process from 'node:process'
@@ -44,8 +45,19 @@ function fail(message) {
44
45
 
45
46
  const appDir = path.resolve(readOption('--app-dir') ?? fail('missing --app-dir <dir>'))
46
47
  const outCpp = path.resolve(readOption('--out-cpp') ?? fail('missing --out-cpp <file>'))
48
+ const manifestPath = path.join(appDir, 'package.json')
49
+ const compileTimeOnly = compileTimeOnlyAssetPaths(
50
+ fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {},
51
+ )
47
52
 
48
- const ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ttf', '.otf'])
53
+ for (const relative of compileTimeOnly) {
54
+ const file = path.join(appDir, relative)
55
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
56
+ fail(`compile-time-only asset is not a file: ${relative}`)
57
+ }
58
+ }
59
+
60
+ const ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ttf', '.otf', '.wav'])
49
61
  // Launcher/package icons are metadata, not runtime `<Image>` assets. If an app
50
62
  // wants to render one, keep a copy under `public/` so the reference is explicit.
51
63
  const PRUNE_DIRS = new Set(['node_modules', 'dist', 'build'])
@@ -83,7 +95,9 @@ function collectAssets(dir) {
83
95
  const rel = path.relative(appDir, full).split(path.sep).join('/')
84
96
  if (!PRUNE_DIRS.has(entry.name) && !entry.name.startsWith('.') && rel !== 'icons') walk(full)
85
97
  } else if (entry.isFile() && ASSET_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
86
- found.push(full)
98
+ if (!compileTimeOnly.has(relPath(full))) {
99
+ found.push(full)
100
+ }
87
101
  }
88
102
  }
89
103
  }