@ringozz/godot 4.7.2-614 → 4.7.2-616

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/src/preload.ts CHANGED
@@ -1,253 +1,253 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { dirname, resolve } from 'node:path';
7
- import { plugin } from 'bun';
8
- import type { BunPlugin } from 'bun';
9
-
10
- // Registers the godot-assets plugin: imports of Godot source assets become
11
- // virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
12
- // and export the resulting `materialize` (own + deps' files) and load promise.
13
- // `loadAsset` checks the engine's `ResourceCache` once; when the asset is
14
- // already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
15
- // the cached instance and `materialize` short-circuits, skipping file staging
16
- // and the threaded load. The load promise persists on the module's
17
- // `import.meta.hot.data` (per-module state carried across hot replacements), so
18
- // HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
19
- // keeps its tracked status and never re-suspends (no Suspense fallback flash); a
20
- // rejected load is evicted from `data` so the next evaluation retries; on
21
- // desktop `data` is `{}` and modules evaluate once. Dependencies are generated
22
- // modules too, so a dep's load starts at module evaluation while the parent only
23
- // waits for dep *files*. The default export is the single plugin — registered
24
- // globally via bunfig preload and loaded by `[serve.static] plugins` for
25
- // `Bun.serve` HTML routes.
26
-
27
- const ROOT = process.cwd();
28
- const NS = 'godot';
29
-
30
- /** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
31
- const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
32
- gltf: { cls: 'PackedScene', scan: true },
33
- tscn: { cls: 'PackedScene', scan: true, native: true },
34
- obj: { cls: 'PackedScene', scan: true },
35
- gd: { cls: 'GDScript', scan: true, native: true },
36
- jpg: { cls: 'CompressedTexture2D' },
37
- jpeg: { cls: 'CompressedTexture2D' },
38
- png: { cls: 'CompressedTexture2D' },
39
- webp: { cls: 'CompressedTexture2D' },
40
- svg: { cls: 'CompressedTexture2D' },
41
- exr: { cls: 'TextureLayered' },
42
- hdr: { cls: 'TextureLayered' },
43
- wav: { cls: 'AudioStreamWAV' },
44
- ogg: { cls: 'AudioStreamOggVorbis' },
45
- mp3: { cls: 'AudioStreamMP3' },
46
- tres: { cls: 'Resource', scan: true, native: true },
47
- po: { cls: 'Translation', native: true },
48
- ttf: { cls: 'FontFile' },
49
- otf: { cls: 'FontFile' },
50
- mtl: { scan: true },
51
- };
52
-
53
- const ASSET_RE = new RegExp(
54
- `\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
55
- 'i',
56
- );
57
- const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
58
- Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
59
- );
60
- const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
61
- const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
62
-
63
- const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
64
- const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
65
- const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
66
- const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
67
- const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
68
-
69
- /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
70
- function parseSidecarDest(text: string): string | null {
71
- const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
72
- if (deps) {
73
- const first = deps[1].match(/"([^"]+)"/);
74
- if (first) {
75
- return first[1];
76
- }
77
- }
78
- const remap = text.match(/^path="([^"]+)"/m);
79
- return remap ? remap[1] : null;
80
- }
81
-
82
- // Reference extraction for text-based source assets: absolute `res://` paths
83
- // (Godot scenes/resources) and relative path tokens ending in a known asset
84
- // extension (glTF uris, OBJ/MTL textures). The literal dot before the extension
85
- // avoids matching MIME types inside `data:` URIs. Regexes are built fresh per
86
- // call: a `/g` instance is stateful, and reusing one module-level regex across
87
- // many files misbehaves under the dev server's bundler (a stale lastIndex
88
- // shifted a scan's results). The `//` fragment right after `res:` is a mere
89
- // path remainder of the `res://` match, not a real relative ref, so it's
90
- // skipped (otherwise it mis-resolves as a Windows UNC `\\…` path).
91
- //
92
- // The extension must TERMINATE a token: `(?![\w.])` rejects matches where the
93
- // extension is only the head of a longer identifier or a dotted sub-field, e.g.
94
- // `X.po` in `X.position`, `foo.gd` in `foo.gd.position`, or `scene.tscn2`. A
95
- // real relative ref (`foo.po`, `x.gd`, `ui/scene.tscn`) always ends its token.
96
- // `String.raw` keeps the `\w` escapes intact — a plain template literal would
97
- // drop the backslashes, turning `\w` into `w` (a wrong guard `(?![w.])`).
98
- const REL_TERM = String.raw`(?![\w.])`;
99
- const REL_PATTERN = String.raw`([\w./\\-]+\.(?:` + Object.keys(FORMATS).join('|') + `))` + REL_TERM;
100
- function extractRefs(text: string): string[] {
101
- const refs: string[] = [];
102
- for (const m of text.matchAll(/res:\/\/[\w./\\-]+/g)) refs.push(m[0]);
103
- const rel = new RegExp(REL_PATTERN, 'g');
104
- for (const m of text.matchAll(rel)) if (!m[1].startsWith('/')) refs.push(m[1]);
105
- return refs;
106
- }
107
-
108
- /**
109
- * Adds a source asset's own files to `files`: imported assets contribute their
110
- * `.import` sidecar (text) + imported product (file); native text formats
111
- * (tscn/tres/po/gd) contribute the file itself (text).
112
- */
113
- function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
114
- const impAbs = srcAbs + '.import';
115
- if (existsSync(impAbs)) {
116
- files.set(resOf(srcAbs) + '.import', 'text');
117
- const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
118
- if (dest) {
119
- files.set(dest, 'file');
120
- }
121
- } else if (isNativeText(srcAbs)) {
122
- stageNativeText(srcAbs, files);
123
- }
124
- }
125
-
126
- /** Stages a native text source (`res://` path → `text`) so web staging + inlining picks it up. */
127
- function stageNativeText(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
128
- files.set(resOf(srcAbs), 'text');
129
- }
130
-
131
- /**
132
- * Collects the referenced source assets (deps) of a text source: every referenced
133
- * source asset is staged (native text → `files`, products → dep modules), and
134
- * loadable source assets also become dep modules so their own files + transitive
135
- * deps are handled. Raw text formats without a sidecar (`.mtl`) are recursed into.
136
- * Buffers (`.bin`) are skipped.
137
- */
138
- function collectDeps(abs: string, visited: Set<string>, deps: string[], files: Map<string, 'text' | 'file'>): void {
139
- if (visited.has(abs)) {
140
- return;
141
- }
142
- visited.add(abs);
143
- if (!isScannableText(abs)) {
144
- return;
145
- }
146
- for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
147
- const refAbs = ref.startsWith('res://')
148
- ? resolve(ROOT, ref.slice('res://'.length))
149
- : resolve(dirname(abs), ref);
150
- if (visited.has(refAbs)) {
151
- continue;
152
- }
153
- // Stage native text (e.g. `.gd`, nested `.tscn`) so the engine loads it
154
- // by `res://` on web (idempotent); loadable assets also become dep
155
- // modules for their own files + transitive deps.
156
- if (isSourceAsset(refAbs)) {
157
- if (isNativeText(refAbs)) {
158
- visited.add(refAbs);
159
- stageNativeText(refAbs, files);
160
- }
161
- if (CLASS_BY_EXT[extOf(refAbs)]) {
162
- deps.push(refAbs);
163
- }
164
- }
165
- // Any scannable ref (a source's own text, or a raw leaf like `.mtl`) is
166
- // recursed into so its internal `res://` refs are traced/staged too.
167
- if (isScannableText(refAbs)) {
168
- collectDeps(refAbs, visited, deps, files);
169
- }
170
- }
171
- }
172
-
173
- function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
174
- const files = new Map<string, 'text' | 'file'>();
175
- const deps: string[] = [];
176
- addSource(abs, files);
177
- collectDeps(abs, new Set(), deps, files);
178
- return { files, deps };
179
- }
180
-
181
- function ensureModule(abs: string): string {
182
- const res = resOf(abs);
183
- const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
184
- const { files, deps } = analyze(abs);
185
-
186
- const imports = [
187
- `import { loadAsset } from '@ringozz/godot/load';`,
188
- `import { ${cls} } from '@ringozz/godot/${cls}';`,
189
- ];
190
- const map: string[] = [];
191
- let fi = 0;
192
- // Text files (own `.tscn`/`.tres`/`.po`, `.import` sidecars, staged `.gd`
193
- // leaves) are inlined as string literals — Bun's `with { type: 'text' }`
194
- // import resolves to a null/default under the dev server's HMR wrapper for
195
- // a module's own path; products (`.scn`/`.ctex`) are fetched by path.
196
- for (const [resPath, kind] of files) {
197
- const absP = resolve(ROOT, resPath.slice('res://'.length));
198
- if (kind === 'text') {
199
- map.push(` ${JSON.stringify(resPath)}: { content: ${JSON.stringify(readFileSync(absP, 'utf8'))} },`);
200
- continue;
201
- }
202
- const v = `f${fi++}`;
203
- imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
204
- map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
205
- }
206
- deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
207
-
208
- const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
209
- const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
210
- return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
211
- }
212
-
213
- const assetPlugin: BunPlugin = {
214
- name: 'godot-assets',
215
- setup(build) {
216
- build.onResolve({ filter: ASSET_RE }, (args) => {
217
- // Only JS module imports of Godot source assets become `godot`
218
- // modules. `Bun.build` reports JS imports as `import-statement`
219
- // (and `dynamic-import`/`require-*`) but HTML asset references
220
- // (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
221
- // those fall through so Bun copies/hashes the file normally. The
222
- // runtime preload (bun run / bun test) reports JS imports with no
223
- // kind (`undefined`), so treat that as a JS import too.
224
- if (
225
- args.kind !== undefined &&
226
- args.kind !== 'import-statement' &&
227
- args.kind !== 'dynamic-import' &&
228
- args.kind !== 'require-call' &&
229
- args.kind !== 'require-resolve'
230
- ) {
231
- return;
232
- }
233
- const importerAbs = args.importer?.startsWith(`${NS}:`)
234
- ? args.importer.slice(NS.length + 1)
235
- : args.importer;
236
- const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
237
- const abs = resolve(base, args.path);
238
- // A module's own native-text source (e.g. `.tscn`) self-imports the
239
- // same path — let it load as a real file.
240
- if (abs === importerAbs) {
241
- return;
242
- }
243
- return { path: abs, namespace: NS };
244
- });
245
- build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
246
- contents: ensureModule(args.path),
247
- loader: 'js',
248
- }));
249
- },
250
- };
251
-
252
- export default assetPlugin;
253
- plugin(assetPlugin);
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { plugin } from 'bun';
8
+ import type { BunPlugin } from 'bun';
9
+
10
+ // Registers the godot-assets plugin: imports of Godot source assets become
11
+ // virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
12
+ // and export the resulting `materialize` (own + deps' files) and load promise.
13
+ // `loadAsset` checks the engine's `ResourceCache` once; when the asset is
14
+ // already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
15
+ // the cached instance and `materialize` short-circuits, skipping file staging
16
+ // and the threaded load. The load promise persists on the module's
17
+ // `import.meta.hot.data` (per-module state carried across hot replacements), so
18
+ // HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
19
+ // keeps its tracked status and never re-suspends (no Suspense fallback flash); a
20
+ // rejected load is evicted from `data` so the next evaluation retries; on
21
+ // desktop `data` is `{}` and modules evaluate once. Dependencies are generated
22
+ // modules too, so a dep's load starts at module evaluation while the parent only
23
+ // waits for dep *files*. The default export is the single plugin — registered
24
+ // globally via bunfig preload and loaded by `[serve.static] plugins` for
25
+ // `Bun.serve` HTML routes.
26
+
27
+ const ROOT = process.cwd();
28
+ const NS = 'godot';
29
+
30
+ /** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
31
+ const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
32
+ gltf: { cls: 'PackedScene', scan: true },
33
+ tscn: { cls: 'PackedScene', scan: true, native: true },
34
+ obj: { cls: 'PackedScene', scan: true },
35
+ gd: { cls: 'GDScript', scan: true, native: true },
36
+ jpg: { cls: 'CompressedTexture2D' },
37
+ jpeg: { cls: 'CompressedTexture2D' },
38
+ png: { cls: 'CompressedTexture2D' },
39
+ webp: { cls: 'CompressedTexture2D' },
40
+ svg: { cls: 'CompressedTexture2D' },
41
+ exr: { cls: 'TextureLayered' },
42
+ hdr: { cls: 'TextureLayered' },
43
+ wav: { cls: 'AudioStreamWAV' },
44
+ ogg: { cls: 'AudioStreamOggVorbis' },
45
+ mp3: { cls: 'AudioStreamMP3' },
46
+ tres: { cls: 'Resource', scan: true, native: true },
47
+ po: { cls: 'Translation', native: true },
48
+ ttf: { cls: 'FontFile' },
49
+ otf: { cls: 'FontFile' },
50
+ mtl: { scan: true },
51
+ };
52
+
53
+ const ASSET_RE = new RegExp(
54
+ `\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
55
+ 'i',
56
+ );
57
+ const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
58
+ Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
59
+ );
60
+ const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
61
+ const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
62
+
63
+ const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
64
+ const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
65
+ const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
66
+ const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
67
+ const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
68
+
69
+ /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
70
+ function parseSidecarDest(text: string): string | null {
71
+ const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
72
+ if (deps) {
73
+ const first = deps[1].match(/"([^"]+)"/);
74
+ if (first) {
75
+ return first[1];
76
+ }
77
+ }
78
+ const remap = text.match(/^path="([^"]+)"/m);
79
+ return remap ? remap[1] : null;
80
+ }
81
+
82
+ // Reference extraction for text-based source assets: absolute `res://` paths
83
+ // (Godot scenes/resources) and relative path tokens ending in a known asset
84
+ // extension (glTF uris, OBJ/MTL textures). The literal dot before the extension
85
+ // avoids matching MIME types inside `data:` URIs. Regexes are built fresh per
86
+ // call: a `/g` instance is stateful, and reusing one module-level regex across
87
+ // many files misbehaves under the dev server's bundler (a stale lastIndex
88
+ // shifted a scan's results). The `//` fragment right after `res:` is a mere
89
+ // path remainder of the `res://` match, not a real relative ref, so it's
90
+ // skipped (otherwise it mis-resolves as a Windows UNC `\\…` path).
91
+ //
92
+ // The extension must TERMINATE a token: `(?![\w.])` rejects matches where the
93
+ // extension is only the head of a longer identifier or a dotted sub-field, e.g.
94
+ // `X.po` in `X.position`, `foo.gd` in `foo.gd.position`, or `scene.tscn2`. A
95
+ // real relative ref (`foo.po`, `x.gd`, `ui/scene.tscn`) always ends its token.
96
+ // `String.raw` keeps the `\w` escapes intact — a plain template literal would
97
+ // drop the backslashes, turning `\w` into `w` (a wrong guard `(?![w.])`).
98
+ const REL_TERM = String.raw`(?![\w.])`;
99
+ const REL_PATTERN = String.raw`([\w./\\-]+\.(?:` + Object.keys(FORMATS).join('|') + `))` + REL_TERM;
100
+ function extractRefs(text: string): string[] {
101
+ const refs: string[] = [];
102
+ for (const m of text.matchAll(/res:\/\/[\w./\\-]+/g)) refs.push(m[0]);
103
+ const rel = new RegExp(REL_PATTERN, 'g');
104
+ for (const m of text.matchAll(rel)) if (!m[1].startsWith('/')) refs.push(m[1]);
105
+ return refs;
106
+ }
107
+
108
+ /**
109
+ * Adds a source asset's own files to `files`: imported assets contribute their
110
+ * `.import` sidecar (text) + imported product (file); native text formats
111
+ * (tscn/tres/po/gd) contribute the file itself (text).
112
+ */
113
+ function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
114
+ const impAbs = srcAbs + '.import';
115
+ if (existsSync(impAbs)) {
116
+ files.set(resOf(srcAbs) + '.import', 'text');
117
+ const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
118
+ if (dest) {
119
+ files.set(dest, 'file');
120
+ }
121
+ } else if (isNativeText(srcAbs)) {
122
+ stageNativeText(srcAbs, files);
123
+ }
124
+ }
125
+
126
+ /** Stages a native text source (`res://` path → `text`) so web staging + inlining picks it up. */
127
+ function stageNativeText(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
128
+ files.set(resOf(srcAbs), 'text');
129
+ }
130
+
131
+ /**
132
+ * Collects the referenced source assets (deps) of a text source: every referenced
133
+ * source asset is staged (native text → `files`, products → dep modules), and
134
+ * loadable source assets also become dep modules so their own files + transitive
135
+ * deps are handled. Raw text formats without a sidecar (`.mtl`) are recursed into.
136
+ * Buffers (`.bin`) are skipped.
137
+ */
138
+ function collectDeps(abs: string, visited: Set<string>, deps: string[], files: Map<string, 'text' | 'file'>): void {
139
+ if (visited.has(abs)) {
140
+ return;
141
+ }
142
+ visited.add(abs);
143
+ if (!isScannableText(abs)) {
144
+ return;
145
+ }
146
+ for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
147
+ const refAbs = ref.startsWith('res://')
148
+ ? resolve(ROOT, ref.slice('res://'.length))
149
+ : resolve(dirname(abs), ref);
150
+ if (visited.has(refAbs)) {
151
+ continue;
152
+ }
153
+ // Stage native text (e.g. `.gd`, nested `.tscn`) so the engine loads it
154
+ // by `res://` on web (idempotent); loadable assets also become dep
155
+ // modules for their own files + transitive deps.
156
+ if (isSourceAsset(refAbs)) {
157
+ if (isNativeText(refAbs)) {
158
+ visited.add(refAbs);
159
+ stageNativeText(refAbs, files);
160
+ }
161
+ if (CLASS_BY_EXT[extOf(refAbs)]) {
162
+ deps.push(refAbs);
163
+ }
164
+ }
165
+ // Any scannable ref (a source's own text, or a raw leaf like `.mtl`) is
166
+ // recursed into so its internal `res://` refs are traced/staged too.
167
+ if (isScannableText(refAbs)) {
168
+ collectDeps(refAbs, visited, deps, files);
169
+ }
170
+ }
171
+ }
172
+
173
+ function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
174
+ const files = new Map<string, 'text' | 'file'>();
175
+ const deps: string[] = [];
176
+ addSource(abs, files);
177
+ collectDeps(abs, new Set(), deps, files);
178
+ return { files, deps };
179
+ }
180
+
181
+ function ensureModule(abs: string): string {
182
+ const res = resOf(abs);
183
+ const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
184
+ const { files, deps } = analyze(abs);
185
+
186
+ const imports = [
187
+ `import { loadAsset } from '@ringozz/godot/load';`,
188
+ `import { ${cls} } from '@ringozz/godot/${cls}';`,
189
+ ];
190
+ const map: string[] = [];
191
+ let fi = 0;
192
+ // Text files (own `.tscn`/`.tres`/`.po`, `.import` sidecars, staged `.gd`
193
+ // leaves) are inlined as string literals — Bun's `with { type: 'text' }`
194
+ // import resolves to a null/default under the dev server's HMR wrapper for
195
+ // a module's own path; products (`.scn`/`.ctex`) are fetched by path.
196
+ for (const [resPath, kind] of files) {
197
+ const absP = resolve(ROOT, resPath.slice('res://'.length));
198
+ if (kind === 'text') {
199
+ map.push(` ${JSON.stringify(resPath)}: { content: ${JSON.stringify(readFileSync(absP, 'utf8'))} },`);
200
+ continue;
201
+ }
202
+ const v = `f${fi++}`;
203
+ imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
204
+ map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
205
+ }
206
+ deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
207
+
208
+ const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
209
+ const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
210
+ return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
211
+ }
212
+
213
+ const assetPlugin: BunPlugin = {
214
+ name: 'godot-assets',
215
+ setup(build) {
216
+ build.onResolve({ filter: ASSET_RE }, (args) => {
217
+ // Only JS module imports of Godot source assets become `godot`
218
+ // modules. `Bun.build` reports JS imports as `import-statement`
219
+ // (and `dynamic-import`/`require-*`) but HTML asset references
220
+ // (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
221
+ // those fall through so Bun copies/hashes the file normally. The
222
+ // runtime preload (bun run / bun test) reports JS imports with no
223
+ // kind (`undefined`), so treat that as a JS import too.
224
+ if (
225
+ args.kind !== undefined &&
226
+ args.kind !== 'import-statement' &&
227
+ args.kind !== 'dynamic-import' &&
228
+ args.kind !== 'require-call' &&
229
+ args.kind !== 'require-resolve'
230
+ ) {
231
+ return;
232
+ }
233
+ const importerAbs = args.importer?.startsWith(`${NS}:`)
234
+ ? args.importer.slice(NS.length + 1)
235
+ : args.importer;
236
+ const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
237
+ const abs = resolve(base, args.path);
238
+ // A module's own native-text source (e.g. `.tscn`) self-imports the
239
+ // same path — let it load as a real file.
240
+ if (abs === importerAbs) {
241
+ return;
242
+ }
243
+ return { path: abs, namespace: NS };
244
+ });
245
+ build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
246
+ contents: ensureModule(args.path),
247
+ loader: 'js',
248
+ }));
249
+ },
250
+ };
251
+
252
+ export default assetPlugin;
253
+ plugin(assetPlugin);