wesl-plugin 0.6.69 → 0.6.71

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/WeslPlugin.ts CHANGED
@@ -19,23 +19,17 @@ import type { WeslPluginOptions } from "./WeslPluginOptions.ts";
19
19
 
20
20
  export type { WeslToml, WeslTomlInfo };
21
21
 
22
- /** internal cache used by the plugin to avoid reloading files
23
- * The assumption is that the plugin is used for a single wesl.toml and set of shaders
24
- * (a plugin instance supports only one shader project)
25
- */
22
+ /** Cache for a single plugin instance (one wesl.toml / shader project). */
26
23
  interface PluginCache {
27
24
  registry?: RecordResolver;
28
25
  weslToml?: WeslTomlInfo;
29
26
  }
30
27
 
31
- /** some types from unplugin */
32
28
  type Resolver = (
33
29
  this: UnpluginBuildContext & UnpluginContext,
34
30
  id: string,
35
31
  importer: string | undefined,
36
- options: {
37
- isEntry: boolean;
38
- },
32
+ options: { isEntry: boolean },
39
33
  ) => Thenable<string | ExternalIdResult | null | undefined>;
40
34
 
41
35
  type Loader = (
@@ -43,7 +37,7 @@ type Loader = (
43
37
  id: string,
44
38
  ) => Thenable<TransformResult>;
45
39
 
46
- /** convenient state for local functions */
40
+ /** Shared state threaded through plugin functions. */
47
41
  export interface PluginContext {
48
42
  cache: PluginCache;
49
43
  options: WeslPluginOptions;
@@ -54,18 +48,9 @@ export interface PluginContext {
54
48
 
55
49
  type DebugLog = (msg: string, data?: Record<string, unknown>) => void;
56
50
 
57
- /**
58
- * A bundler plugin for processing WESL files.
59
- *
60
- * The plugin works by reading the wesl.toml file and possibly package.json
61
- *
62
- * The plugin is triggered by imports to special virtual module urls
63
- * two urls suffixes are supported:
64
- * 1. `import "./shaders/bar.wesl?reflect"` - produces a javascript file for binding struct reflection
65
- * 2. `import "./shaders/bar.wesl?link"` - produces a javascript file for preconstructed link functions
66
- */
67
51
  const builtinExtensions = [staticBuildExtension, linkBuildExtension];
68
52
 
53
+ /** Bundler plugin for WESL files, triggered by ?link or ?static import suffixes. */
69
54
  export function weslPlugin(
70
55
  options: WeslPluginOptions | undefined,
71
56
  meta: UnpluginContextMeta,
@@ -85,13 +70,9 @@ export function weslPlugin(
85
70
  load: buildLoader(context, log),
86
71
  watchChange(id, _change) {
87
72
  log("watchChange", { id });
88
- if (id.endsWith("wesl.toml")) {
89
- // The cache is shared for multiple imports
90
- cache.weslToml = undefined;
91
- cache.registry = undefined;
92
- } else {
93
- cache.registry = undefined;
94
- }
73
+ // The cache is shared for multiple imports
74
+ if (id.endsWith("wesl.toml")) cache.weslToml = undefined;
75
+ cache.registry = undefined;
95
76
  },
96
77
  };
97
78
  }
@@ -107,26 +88,59 @@ function pluginsByName(
107
88
  return Object.fromEntries(entries);
108
89
  }
109
90
 
110
- /** wesl plugins match import statements of the form:
111
- *
112
- * foo/bar.wesl?link
113
- * or
114
- * foo/bar.wesl COND=false ?static
115
- *
116
- * Bundlers may add extra query params (e.g. Vite adds ?import for dynamic imports,
117
- * ?t=123 for cache busting), so we capture the full query and search within it.
118
- *
119
- * someday it'd be nice to support import attributes like:
120
- * import "foo.bar.wesl?static" with { COND: false};
121
- * (but that doesn't seem supported to be supported in the the bundler plugins yet)
122
- */
123
- const pluginMatch =
124
- /(^^)?(?<baseId>.*\.w[eg]sl)(?<cond>(\s*\w+(=\w+)?\s*)*)\?(?<query>.+)$/;
91
+ /** Match .wesl/.wgsl imports with query params. Bundlers may append extra params. */
92
+ const pluginMatch = /(^^)?(?<baseId>.*\.w[eg]sl)\?(?<query>.+)$/;
125
93
 
126
94
  const resolvedPrefix = "^^";
127
95
 
128
- /** build plugin entry for 'resolverId'
129
- * to validate our javascript virtual module imports (with e.g. ?static or ?link suffixes) */
96
+ /** Reserved query param names (not treated as conditions). */
97
+ const reservedParams = new Set(["include"]);
98
+
99
+ interface ParsedQuery {
100
+ pluginName: string;
101
+ conditions?: Conditions;
102
+ options?: Record<string, string>;
103
+ }
104
+
105
+ /** Parse query string into plugin name, conditions, and options. */
106
+ function parsePluginQuery(
107
+ query: string,
108
+ suffixes: string[],
109
+ ): ParsedQuery | null {
110
+ const segments = query.split("&");
111
+ const pluginName = suffixes.find(s => segments.includes(s));
112
+ if (!pluginName) return null;
113
+
114
+ const isBundlerParam = (s: string) => s === "import" || /^t=\d+/.test(s);
115
+ const userSegments = segments.filter(
116
+ s => s !== pluginName && !isBundlerParam(s),
117
+ );
118
+
119
+ const conditions: Record<string, boolean> = {};
120
+ const options: Record<string, string> = {};
121
+
122
+ for (const seg of userSegments) {
123
+ const eqIdx = seg.indexOf("=");
124
+ if (eqIdx === -1) {
125
+ conditions[seg] = true; // bare name like "FUN" ==> condition true
126
+ } else {
127
+ const key = seg.slice(0, eqIdx);
128
+ const val = seg.slice(eqIdx + 1);
129
+ if (reservedParams.has(key)) options[key] = val;
130
+ else conditions[key] = val !== "false";
131
+ }
132
+ }
133
+
134
+ const hasConds = Object.keys(conditions).length > 0;
135
+ const hasOpts = Object.keys(options).length > 0;
136
+ return {
137
+ pluginName,
138
+ conditions: hasConds ? conditions : undefined,
139
+ options: hasOpts ? options : undefined,
140
+ };
141
+ }
142
+
143
+ /** Build the resolveId hook for virtual module imports (?static, ?link, etc). */
130
144
  function buildResolver(
131
145
  options: WeslPluginOptions,
132
146
  context: PluginContext,
@@ -135,71 +149,34 @@ function buildResolver(
135
149
  const suffixes = pluginNames(options);
136
150
  return resolver;
137
151
 
138
- // vite calls resolver only for odd import paths.
139
- // this doesn't call resolver: import wgsl from "../shaders/foo/app.wesl?static";
140
- // but this does call resolver: import wgsl from "../shaders/foo/app.wesl MOBILE=true FUN SAFE=false ?static";
141
-
142
- /**
143
- * For imports with conditions, vite won't resolve the module-path part of the js import
144
- * so we do it here.
145
- *
146
- * To avoid recirculating on resolve(), we rewrite the resolution id to start with ^^
147
- * The loader will drop the prefix.
148
- */
152
+ // With pure query-param syntax, Vite resolves paths natively.
153
+ // The resolver is still needed for non-Vite bundlers that may not handle query params.
149
154
  function resolver(
150
155
  this: UnpluginBuildContext & UnpluginContext,
151
156
  id: string,
152
157
  importer: string | undefined,
153
158
  ): string | null {
154
- if (id.startsWith(resolvedPrefix)) {
155
- return id;
156
- }
157
- if (id === context.weslToml) {
158
- return id;
159
- }
160
- const matched = pluginSuffixMatch(id, suffixes);
161
- log("resolveId", { id, matched: !!matched, suffixes });
162
- if (matched) {
163
- const { importParams, baseId, pluginName } = matched;
164
-
165
- // resolve the path to the shader file
166
- const importerDir = path.dirname(importer!);
167
- const pathToShader = path.join(importerDir, baseId);
168
- const result =
169
- resolvedPrefix + pathToShader + importParams + "?" + pluginName;
170
- log("resolveId resolved", { result });
171
- return result;
172
- }
173
- return matched ? id : null; // this case doesn't happen AFAIK
159
+ if (id.startsWith(resolvedPrefix)) return id;
160
+ if (id === context.weslToml) return id;
161
+
162
+ const match = id.match(pluginMatch);
163
+ const query = match?.groups?.query;
164
+ if (!query) return null;
165
+
166
+ const parsed = parsePluginQuery(query, suffixes);
167
+ log("resolveId", { id, matched: !!parsed, suffixes });
168
+ if (!parsed) return null;
169
+
170
+ const baseId = match.groups!.baseId;
171
+ const importerDir = path.dirname(importer!);
172
+ const pathToShader = path.join(importerDir, baseId);
173
+ const result = resolvedPrefix + pathToShader + "?" + query;
174
+ log("resolveId resolved", { result });
175
+ return result;
174
176
  }
175
177
  }
176
178
 
177
- interface PluginMatch {
178
- baseId: string;
179
- importParams?: string;
180
- pluginName: string;
181
- }
182
-
183
- /** Find matching plugin suffix in query string (handles ?import&static, ?t=123&static, etc.) */
184
- function pluginSuffixMatch(id: string, suffixes: string[]): PluginMatch | null {
185
- const match = id.match(pluginMatch);
186
- const query = match?.groups?.query;
187
- if (!query) return null;
188
-
189
- // Query params are &-separated; find one that matches a configured suffix
190
- const segments = query.split("&");
191
- const pluginName = suffixes.find(s => segments.includes(s));
192
- if (!pluginName) return null;
193
-
194
- return {
195
- pluginName,
196
- baseId: match.groups!.baseId,
197
- importParams: match.groups?.cond,
198
- };
199
- }
200
-
201
- /** build plugin function for serving a javascript module in response to
202
- * an import of of our virtual import modules. */
179
+ /** Build the load hook that emits JS for virtual module imports. */
203
180
  function buildLoader(context: PluginContext, log: DebugLog): Loader {
204
181
  const { options } = context;
205
182
  const suffixes = pluginNames(options);
@@ -210,50 +187,27 @@ function buildLoader(context: PluginContext, log: DebugLog): Loader {
210
187
  this: UnpluginBuildContext & UnpluginContext,
211
188
  id: string,
212
189
  ) {
213
- const matched = pluginSuffixMatch(id, suffixes);
214
- log("load", { id, matched: matched?.pluginName ?? null });
215
- if (matched) {
216
- const buildPluginApi = buildApi(context, this);
217
- const plugin = pluginsMap[matched.pluginName];
218
- const { baseId, importParams } = matched;
219
- const conditions = importParamsToConditions(importParams);
220
- const shaderPath = baseId.startsWith(resolvedPrefix)
221
- ? baseId.slice(resolvedPrefix.length)
222
- : baseId;
223
-
224
- log("load emitting", { shaderPath, conditions });
225
- return await plugin.emitFn(shaderPath, buildPluginApi, conditions);
226
- }
227
-
228
- return null;
190
+ const match = id.match(pluginMatch);
191
+ const query = match?.groups?.query;
192
+ if (!query) return null;
193
+
194
+ const parsed = parsePluginQuery(query, suffixes);
195
+ log("load", { id, matched: parsed?.pluginName ?? null });
196
+ if (!parsed) return null;
197
+
198
+ const buildPluginApi = buildApi(context, this);
199
+ const plugin = pluginsMap[parsed.pluginName];
200
+ const rawPath = match.groups!.baseId;
201
+ const shaderPath = rawPath.startsWith(resolvedPrefix)
202
+ ? rawPath.slice(resolvedPrefix.length)
203
+ : rawPath;
204
+
205
+ const { conditions, options: opts } = parsed;
206
+ log("load emitting", { shaderPath, conditions, options: opts });
207
+ return await plugin.emitFn(shaderPath, buildPluginApi, conditions, opts);
229
208
  }
230
209
  }
231
210
 
232
- /**
233
- * Convert an import parameters string to a Conditions record.
234
- *
235
- * Import parameters are key=value pairs separated by spaces.
236
- * Values may be "true" or "false" or missing (default to true)
237
- * e.g. ' MOBILE=true FUN SAFE=false '
238
- */
239
- function importParamsToConditions(
240
- importParams: string | undefined,
241
- ): Conditions | undefined {
242
- if (!importParams) return undefined;
243
- const params = importParams.trim().split(/\s+/);
244
- const condEntries = params.map(p => {
245
- const text = p.trim();
246
- const [cond, value] = text.split("=");
247
- if (value === undefined || value === "true") {
248
- return [cond, true] as const;
249
- } else {
250
- return [cond, false] as const;
251
- }
252
- });
253
- const conditions = Object.fromEntries(condEntries);
254
- return conditions;
255
- }
256
-
257
211
  function fmtDebugData(data?: Record<string, unknown>): string {
258
212
  return data ? " " + JSON.stringify(data) : "";
259
213
  }
@@ -264,9 +218,5 @@ function debugLog(msg: string, data?: Record<string, unknown>): void {
264
218
 
265
219
  function noopLog(): void {}
266
220
 
267
- export const unplugin = createUnplugin(
268
- (options: WeslPluginOptions, meta: UnpluginContextMeta) => {
269
- return weslPlugin(options, meta);
270
- },
271
- );
221
+ export const unplugin = createUnplugin(weslPlugin);
272
222
  export default unplugin;
@@ -10,6 +10,12 @@ declare module "*?static" {
10
10
  export default wgsl;
11
11
  }
12
12
 
13
+ /** @hidden */
14
+ declare module "*?simple_reflect" {
15
+ import type { WeslStruct } from "wesl-reflect";
16
+ export const structs: WeslStruct[];
17
+ }
18
+
13
19
  /** @hidden */ // LATER move to separate package
14
20
  declare module "*?bindingLayout" {
15
21
  export const layouts: Record<string, GPUBindGroupLayoutEntry[]>;
@@ -14,18 +14,18 @@ export const linkBuildExtension: PluginExtension = {
14
14
  async function emitLinkJs(
15
15
  baseId: string,
16
16
  api: PluginExtensionApi,
17
+ _conditions?: Record<string, boolean>,
18
+ options?: Record<string, string>,
17
19
  ): Promise<string> {
18
- const { resolvedRoot, tomlDir } = await api.weslToml();
19
-
20
- const weslSrc = await api.weslSrc();
21
-
22
20
  const rootModule = await api.weslMain(baseId);
23
21
  const rootModuleName = noSuffix(rootModule);
24
22
 
25
- const tomlRelative = path.relative(tomlDir, resolvedRoot);
26
- const debugWeslRoot = tomlRelative.replaceAll(path.sep, "/");
23
+ const [{ weslSrc, dependencies: autoDeps }, debugWeslRoot] =
24
+ await Promise.all([
25
+ api.fetchProject(rootModuleName, options),
26
+ api.debugWeslRoot(),
27
+ ]);
27
28
 
28
- const autoDeps = await api.weslDependencies();
29
29
  const sanitizedDeps = autoDeps.map(dep => dep.replaceAll("/", "_"));
30
30
 
31
31
  const bundleImports = autoDeps
@@ -35,25 +35,18 @@ async function emitLinkJs(
35
35
  const rootName = path.basename(rootModuleName).replace(/\W/g, "_");
36
36
  const paramsName = `link${rootName}Config`;
37
37
 
38
- const linkParams: LinkParams = {
39
- rootModuleName,
40
- weslSrc,
41
- debugWeslRoot,
42
- };
43
-
38
+ const linkParams: LinkParams = { rootModuleName, weslSrc, debugWeslRoot };
44
39
  const libsStr = `libs: [${sanitizedDeps.join(", ")}]`;
45
40
  const linkParamsStr = `{
46
41
  ${serializeFields(linkParams)},
47
42
  ${libsStr},
48
43
  }`;
49
44
 
50
- const src = `
45
+ return `
51
46
  ${bundleImports}
52
47
  export const ${paramsName} = ${linkParamsStr};
53
48
  export default ${paramsName};
54
49
  `;
55
-
56
- return src;
57
50
  }
58
51
 
59
52
  function serializeFields(record: Record<string, any>) {
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs/promises";
2
+ import type { StructElem } from "wesl";
3
+ import { originalTypeName, weslStructs, wgslTypeToTs } from "wesl-reflect";
4
+ import type {
5
+ PluginExtension,
6
+ PluginExtensionApi,
7
+ } from "../PluginExtension.ts";
8
+
9
+ export interface SimpleReflectOptions {
10
+ /** directory to contain the .d.ts files or undefined to not write .d.ts files */
11
+ typesDir?: string;
12
+ }
13
+
14
+ /** wesl-js build extension to reflect wgsl structs into js and .d.ts files. */
15
+ export function simpleReflect(
16
+ options: SimpleReflectOptions = {},
17
+ ): PluginExtension {
18
+ const { typesDir = "./src/types" } = options;
19
+ return {
20
+ extensionName: "simple_reflect",
21
+ emitFn: async (_baseId: string, api: PluginExtensionApi) => {
22
+ const registry = await api.weslRegistry();
23
+ const astStructs = [...registry.allModules()].flatMap(([, module]) =>
24
+ module.moduleElem.contents.filter(
25
+ (e): e is StructElem => e.kind === "struct",
26
+ ),
27
+ );
28
+
29
+ const jsStructs = weslStructs(astStructs);
30
+ if (typesDir) await writeTypes(astStructs, typesDir);
31
+
32
+ const structArray = JSON.stringify(jsStructs, null, 2);
33
+ return `export const structs = ${structArray};`;
34
+ },
35
+ };
36
+ }
37
+
38
+ /** Write .d.ts file with TypeScript interfaces for reflected structs. */
39
+ async function writeTypes(
40
+ structs: StructElem[],
41
+ typesDir: string,
42
+ ): Promise<void> {
43
+ const tsdInterfaces = structs.map(s => {
44
+ const name = s.name.ident.originalName;
45
+ const entries = s.members.map(m => {
46
+ const tsType = wgslTypeToTs(originalTypeName(m.typeRef));
47
+ return ` ${m.name.name}: ${tsType};`;
48
+ });
49
+ return `interface ${name} {\n${entries.join("\n")}\n}`;
50
+ });
51
+ await fs.mkdir(typesDir, { recursive: true });
52
+ await fs.writeFile(`${typesDir}/reflectTypes.d.ts`, tsdInterfaces.join("\n"));
53
+ }
@@ -7,64 +7,50 @@ import type {
7
7
  PluginExtensionApi,
8
8
  } from "../PluginExtension.ts";
9
9
 
10
- /**
11
- * a wesl-js ?static build extension that statically links from the root file
12
- * and emits a JavaScript file containing the linked wgsl string.
13
- *
14
- * use it like this:
15
- * import wgsl from "./shaders/app.wesl?static";
16
- *
17
- * or with conditions, like this:
18
- * import wgsl from "../shaders/foo/app.wesl MOBILE=true FUN SAFE=false ?static";
19
- */
10
+ /** Build extension for ?static imports: links WESL at build time, emits WGSL string. */
20
11
  export const staticBuildExtension: PluginExtension = {
21
12
  extensionName: "static",
22
13
  emitFn: emitStaticJs,
23
14
  };
24
15
 
25
- /** Emit a JavaScript file containing the wgsl string */
16
+ /** Emit a JS module exporting the statically linked WGSL string. */
26
17
  async function emitStaticJs(
27
18
  baseId: string,
28
19
  api: PluginExtensionApi,
29
20
  conditions?: Conditions,
21
+ _options?: Record<string, string>,
30
22
  ): Promise<string> {
31
23
  const { resolvedRoot, tomlDir } = await api.weslToml();
32
24
 
33
- // resolve import module relative to the root of the shader project
34
- const parentModule = url
35
- .pathToFileURL(path.join(tomlDir, "wesl.toml"))
36
- .toString();
25
+ const tomlUrl = url.pathToFileURL(path.join(tomlDir, "wesl.toml"));
26
+ const parentModule = tomlUrl.toString();
27
+
28
+ const rootModule = await api.weslMain(baseId);
29
+ const rootModuleName = noSuffix(rootModule);
30
+
31
+ const [weslSrc, dependencies] = await Promise.all([
32
+ api.weslSrc(),
33
+ api.weslDependencies(),
34
+ ]);
37
35
 
38
- const dependencies = await api.weslDependencies();
39
36
  const libFileUrls = dependencies.map(d => resolve(d, parentModule));
40
37
 
41
- // load the lib modules
42
- const futureLibs = libFileUrls.map(f => import(f));
43
- const libModules = await Promise.all(futureLibs);
38
+ const libModules = await Promise.all(libFileUrls.map(f => import(f)));
44
39
  const libs = libModules.map(m => m.default);
45
40
 
46
- // find weslSrc and rootModule
47
- const weslSrc = await api.weslSrc();
48
- const rootModule = await api.weslMain(baseId);
49
- const rootModuleName = noSuffix(rootModule);
50
-
51
- // find weslRoot
52
41
  const tomlRelative = path.relative(tomlDir, resolvedRoot);
53
42
  const debugWeslRoot = tomlRelative.replaceAll(path.sep, "/");
54
43
 
55
- const result = await link({
44
+ const { dest: wgsl } = await link({
56
45
  weslSrc,
57
46
  rootModuleName,
58
47
  debugWeslRoot,
59
48
  libs,
60
49
  conditions,
61
50
  });
62
- const wgsl = result.dest;
63
51
 
64
- const src = `
52
+ return `
65
53
  export const wgsl = \`${wgsl}\`;
66
54
  export default wgsl;
67
55
  `;
68
-
69
- return src;
70
56
  }
@@ -1,3 +1,4 @@
1
1
  export * from "./extensions/LinkExtension.ts";
2
+ export * from "./extensions/ReflectExtension.ts";
2
3
  export * from "./extensions/StaticExtension.ts";
3
4
  export * from "./PluginExtension.ts";