wgsl-edit 0.0.2

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 @@
1
+ body{margin:0;padding:20px;font-family:system-ui,sans-serif;transition:background .2s,color .2s}body.dark{background:#1e1e1e;color:#ccc}body.light{background:#fff;color:#333}.titles{display:flex;gap:32px}.titles h1:first-child{flex:1;text-align:center}.titles h1:last-child{width:min(60vh,50%);text-align:center}h1,h2{margin-top:0;font-family:ui-monospace,Cascadia Code,Fira Code,Menlo,monospace;font-weight:600}#theme-toggle{position:fixed;top:16px;right:16px;background:none;border:none;cursor:pointer;padding:6px;border-radius:8px;color:inherit;opacity:.7;transition:opacity .2s}#theme-toggle:hover{opacity:1}#theme-toggle svg{display:block}#theme-toggle .sun,#theme-toggle .moon{display:none}body.light #theme-toggle .moon,body.dark #theme-toggle .sun{display:block}.editor-player{display:flex;gap:32px;height:60vh}wgsl-edit{flex:1}wgsl-play{aspect-ratio:1;width:min(60vh,50%);align-self:start}.usage{margin-top:48px}.usage pre{padding:16px 20px;border-radius:8px;overflow-x:auto;font-family:ui-monospace,Cascadia Code,Fira Code,Menlo,monospace;font-size:14px;line-height:1.5}body.light .usage pre{background:#f5f5f5;color:#333}body.dark .usage pre{background:#2a2a2a;color:#ccc}.docs-links{margin-top:24px;display:flex;align-items:baseline;font-size:18px}.docs-links h2{margin:0}.docs-links nav{display:flex;gap:40px;margin-left:100px}body.dark .docs-links a{color:#6cb6ff}
@@ -0,0 +1,68 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>wgsl-edit test</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism.min.css" id="prism-light">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css" id="prism-dark" disabled>
9
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js" defer></script>
10
+ <script type="module" crossorigin src="/assets/index-1yVlrenS.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-oyrhrEUu.css">
12
+ </head>
13
+ <body>
14
+ <button id="theme-toggle" aria-label="Toggle theme">
15
+ <svg class="sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
16
+ <circle cx="12" cy="12" r="5"/>
17
+ <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
18
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
19
+ <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
20
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
21
+ </svg>
22
+ <svg class="moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
23
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
24
+ </svg>
25
+ </button>
26
+ <div class="titles">
27
+ <h1>wgsl-edit</h1>
28
+ <h1>wgsl-play</h1>
29
+ </div>
30
+ <div class="editor-player">
31
+ <wgsl-edit id="editor" lint-from="player">
32
+ <script type="text/wesl" data-name="main.wesl">
33
+ import package::utils;
34
+
35
+ @group(0) @binding(0) var<uniform> u: test::Uniforms;
36
+
37
+ @fragment
38
+ fn main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
39
+ let uv = pos.xy / u.resolution;
40
+ return vec4f(utils::gradient(uv, u.time), 1.0);
41
+ }
42
+ </script>
43
+ <script type="text/wesl" data-name="utils.wesl">
44
+ fn gradient(uv: vec2f, time: f32) -> vec3f {
45
+ return vec3f(uv, 0.5 + 0.5 * sin(time));
46
+ }
47
+ </script>
48
+ </wgsl-edit>
49
+ <wgsl-play id="player" source="editor"></wgsl-play>
50
+ </div>
51
+ <section class="usage">
52
+ <h2>Usage</h2>
53
+ <pre><code class="language-html">&lt;script type="module" src="https://esm.sh/wgsl-edit"&gt;&lt;/script&gt;
54
+ &lt;script type="module" src="https://esm.sh/wgsl-play"&gt;&lt;/script&gt;
55
+
56
+ &lt;wgsl-edit id="editor" lint-from="player"&gt;&lt;/wgsl-edit&gt;
57
+ &lt;wgsl-play id="player" source="editor"&gt;&lt;/wgsl-play&gt;</code></pre>
58
+
59
+ <div class="docs-links">
60
+ <h2>Docs</h2>
61
+ <nav>
62
+ <a href="https://github.com/wgsl-tooling-wg/wesl-js/tree/main/tools/packages/wgsl-edit">wgsl-edit</a>
63
+ <a href="https://github.com/wgsl-tooling-wg/wesl-js/tree/main/tools/packages/wgsl-play">wgsl-play</a>
64
+ </nav>
65
+ </div>
66
+ </section>
67
+ </body>
68
+ </html>
package/src/Cli.ts ADDED
@@ -0,0 +1,115 @@
1
+ /** CLI to quickly open a local WESL/WGSL file in a browser-based CodeMirror editor. */
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import open from "open";
7
+ import { createServer, type ViteDevServer } from "vite";
8
+ import yargs from "yargs";
9
+ import { hideBin } from "yargs/helpers";
10
+
11
+ interface CliArgs {
12
+ file: string;
13
+ port: number;
14
+ open: boolean;
15
+ }
16
+
17
+ export async function cli(rawArgs: string[]): Promise<void> {
18
+ const argv = await parseArgs(rawArgs);
19
+ await startDevServer(argv);
20
+ }
21
+
22
+ async function parseArgs(args: string[]): Promise<CliArgs> {
23
+ const argv = await yargs(args)
24
+ .command("$0 <file>", "Edit a WESL/WGSL file in the browser", yargs => {
25
+ yargs.positional("file", {
26
+ type: "string",
27
+ describe: "Path to WESL/WGSL file to edit",
28
+ demandOption: true,
29
+ });
30
+ })
31
+ .option("port", {
32
+ alias: "p",
33
+ type: "number",
34
+ default: 5173,
35
+ describe: "Dev server port",
36
+ })
37
+ .option("open", {
38
+ type: "boolean",
39
+ default: true,
40
+ describe: "Open browser automatically",
41
+ })
42
+ .help()
43
+ .parse();
44
+ return argv as unknown as CliArgs;
45
+ }
46
+
47
+ async function startDevServer(argv: CliArgs): Promise<void> {
48
+ const filePath = path.resolve(argv.file);
49
+ if (!fs.existsSync(filePath)) {
50
+ console.error(`File not found: ${filePath}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const packageDir = path.dirname(fileURLToPath(import.meta.url));
55
+ const html = generateHtml(filePath);
56
+
57
+ const server = await createServer({
58
+ root: packageDir,
59
+ server: { port: argv.port },
60
+ plugins: [
61
+ {
62
+ name: "wgsl-edit-serve",
63
+ configureServer(server: ViteDevServer) {
64
+ server.middlewares.use((req, res, next) => {
65
+ if (req.url === "/" || req.url === "/index.html") {
66
+ res.setHeader("Content-Type", "text/html");
67
+ res.end(html);
68
+ return;
69
+ }
70
+ if (req.url === "/__file__") {
71
+ res.setHeader("Content-Type", "text/plain");
72
+ res.end(fs.readFileSync(filePath, "utf-8"));
73
+ return;
74
+ }
75
+ next();
76
+ });
77
+ },
78
+ },
79
+ ],
80
+ });
81
+
82
+ await server.listen();
83
+ const url = `http://localhost:${argv.port}`;
84
+ console.log(`Editing: ${filePath}`);
85
+ console.log(`Server: ${url}`);
86
+
87
+ if (argv.open) await open(url);
88
+ }
89
+
90
+ function generateHtml(filePath: string): string {
91
+ const fileName = path.basename(filePath);
92
+ return `<!DOCTYPE html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="UTF-8">
96
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
97
+ <title>${fileName} - wgsl-edit</title>
98
+ <style>
99
+ * { box-sizing: border-box; }
100
+ body { margin: 0; height: 100vh; display: flex; flex-direction: column; background: #1e1e1e; }
101
+ header { padding: 8px 16px; background: #252526; color: #ccc; font-family: system-ui; border-bottom: 1px solid #444; }
102
+ wgsl-edit { flex: 1; }
103
+ </style>
104
+ </head>
105
+ <body>
106
+ <header>${fileName}</header>
107
+ <wgsl-edit src="/__file__"></wgsl-edit>
108
+ <script type="module">
109
+ import "wgsl-edit";
110
+ </script>
111
+ </body>
112
+ </html>`;
113
+ }
114
+
115
+ cli(hideBin(process.argv));
@@ -0,0 +1,204 @@
1
+ import { LanguageSupport, LRLanguage } from "@codemirror/language";
2
+ import { type Diagnostic, linter } from "@codemirror/lint";
3
+ import { parser, weslHighlighting } from "lezer-wesl";
4
+ import {
5
+ type BindResults,
6
+ BundleResolver,
7
+ bindIdents,
8
+ CompositeResolver,
9
+ type Conditions,
10
+ type ModuleResolver,
11
+ RecordResolver,
12
+ type UnboundRef,
13
+ type WeslAST,
14
+ type WeslBundle,
15
+ WeslParseError,
16
+ } from "wesl";
17
+
18
+ export interface WeslLintConfig {
19
+ /** Get all sources keyed by module path (e.g., "package::main"). */
20
+ getSources: () => Record<string, string>;
21
+
22
+ /** Root module to validate (e.g., "package::main"). */
23
+ rootModule: () => string;
24
+
25
+ /** Runtime conditions for @if conditional compilation. */
26
+ conditions?: () => Conditions;
27
+
28
+ /** Package name alias (enables `import mypkg::foo` alongside `import package::foo`). */
29
+ packageName?: () => string | undefined;
30
+
31
+ /** External diagnostics (e.g., GPU compilation errors from wgsl-play). */
32
+ getExternalDiagnostics?: () => Diagnostic[];
33
+
34
+ /** Get pre-loaded library bundles. */
35
+ getLibs?: () => WeslBundle[];
36
+
37
+ /** Fetch libraries on-demand for unresolved external packages. */
38
+ fetchLibs?: (packageNames: string[]) => Promise<WeslBundle[]>;
39
+
40
+ /** Package names to ignore when checking unbound externals (e.g. virtual modules). */
41
+ ignorePackages?: () => string[];
42
+ }
43
+
44
+ export const weslLanguage = LRLanguage.define({
45
+ name: "wesl",
46
+ parser: parser.configure({ props: [weslHighlighting] }),
47
+ languageData: {
48
+ commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
49
+ },
50
+ });
51
+
52
+ export function wesl(): LanguageSupport {
53
+ return new LanguageSupport(weslLanguage);
54
+ }
55
+
56
+ /** Create a linter that validates WESL using the canonical parser. */
57
+ export function createWeslLinter(config: WeslLintConfig) {
58
+ return linter(async () => lintAndFetch(config), { delay: 300 });
59
+ }
60
+
61
+ /** Lint once, fetch missing externals if needed, re-lint with new libs. */
62
+ async function lintAndFetch(config: WeslLintConfig): Promise<Diagnostic[]> {
63
+ const libs = config.getLibs?.() ?? [];
64
+ const ignored = new Set(config.ignorePackages?.() ?? []);
65
+ const { diagnostics, externals } = lintPass(config, libs, ignored);
66
+ if (!config.fetchLibs || !externals.length) return diagnostics;
67
+
68
+ const newLibs = await config.fetchLibs(externals);
69
+ if (!newLibs.length) return diagnostics;
70
+ return lintPass(config, [...libs, ...newLibs], ignored).diagnostics;
71
+ }
72
+
73
+ /** Parse, bind, collect diagnostics and discover missing externals. */
74
+ function lintPass(
75
+ config: WeslLintConfig,
76
+ libs: WeslBundle[],
77
+ ignored: Set<string>,
78
+ ): { diagnostics: Diagnostic[]; externals: string[] } {
79
+ const sources = config.getSources();
80
+ const rootModule = config.rootModule();
81
+ const diagnostics: Diagnostic[] = [];
82
+ let externals: string[] = [];
83
+
84
+ try {
85
+ const resolver = buildResolver(sources, libs, config.packageName?.());
86
+ const rootAst = resolver.resolveModule(rootModule);
87
+ if (rootAst) {
88
+ const result = runBind(config, resolver, rootAst);
89
+ diagnostics.push(...unboundDiagnostics(result, rootModule, ignored));
90
+ // LATER integrate external fetching into the bind (when we go async), then this becomes unnecessary
91
+ externals = findMissingPackages(rootAst, result, resolver, ignored, libs);
92
+ }
93
+ } catch (e: unknown) {
94
+ const diag = errorToDiagnostic(e);
95
+ if (diag) diagnostics.push(diag);
96
+ }
97
+
98
+ diagnostics.push(...(config.getExternalDiagnostics?.() ?? []));
99
+ return { diagnostics, externals };
100
+ }
101
+
102
+ /** Build a resolver from sources and optional libs. */
103
+ function buildResolver(
104
+ sources: Record<string, string>,
105
+ libs: WeslBundle[],
106
+ packageName?: string,
107
+ ): ModuleResolver {
108
+ const record = new RecordResolver(sources, { packageName });
109
+ if (libs.length === 0) return record;
110
+ const resolvers = [record, ...libs.map(b => new BundleResolver(b))];
111
+ return new CompositeResolver(resolvers);
112
+ }
113
+
114
+ /** Parse and bind identifiers starting from a root module. */
115
+ function runBind(
116
+ config: WeslLintConfig,
117
+ resolver: ModuleResolver,
118
+ rootAst: WeslAST,
119
+ ): BindResults {
120
+ return bindIdents({
121
+ resolver,
122
+ rootAst,
123
+ conditions: config.conditions?.(),
124
+ accumulateUnbound: true,
125
+ });
126
+ }
127
+
128
+ /** Convert unbound refs to diagnostics, skipping ignored packages. */
129
+ function unboundDiagnostics(
130
+ result: BindResults,
131
+ rootModule: string,
132
+ ignored: Set<string>,
133
+ ): Diagnostic[] {
134
+ const diagnostics: Diagnostic[] = [];
135
+ for (const ref of result.unbound ?? []) {
136
+ if (ref.srcModule.modulePath !== rootModule) continue;
137
+ if (ref.path.length > 1 && ignored.has(ref.path[0])) continue;
138
+ diagnostics.push(unboundToDiagnostic(ref));
139
+ }
140
+ return diagnostics;
141
+ }
142
+
143
+ /** Convert a WESL error to a CodeMirror diagnostic. */
144
+ function errorToDiagnostic(e: unknown): Diagnostic | undefined {
145
+ if (e instanceof WeslParseError) {
146
+ const [from, to] = e.span;
147
+ return {
148
+ from,
149
+ to,
150
+ severity: "error",
151
+ message: (e.cause as Error)?.message ?? e.message,
152
+ };
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ /** Find external package names not yet loaded, from unresolved imports and unbound refs. */
158
+ function findMissingPackages(
159
+ rootAst: WeslAST,
160
+ result: BindResults,
161
+ resolver: ModuleResolver,
162
+ ignored: Set<string>,
163
+ libs: WeslBundle[],
164
+ ): string[] {
165
+ const loaded = new Set(libs.map(b => b.name));
166
+ const pkgs: string[] = [];
167
+
168
+ // imports that don't resolve to a known module
169
+ for (const imp of rootAst.imports) {
170
+ const root = imp.segments[0]?.name;
171
+ if (!root || !isExternalRoot(root) || ignored.has(root)) continue;
172
+ const modPath = imp.segments.map(s => s.name).join("::");
173
+ if (!resolver.resolveModule(modPath)) pkgs.push(root);
174
+ }
175
+
176
+ // inline path references (e.g. foo::bar) that didn't bind
177
+ for (const ref of result.unbound ?? []) {
178
+ const root = ref.path[0];
179
+ if (
180
+ ref.path.length > 1 &&
181
+ isExternalRoot(root) &&
182
+ !ignored.has(root) &&
183
+ !loaded.has(root)
184
+ )
185
+ pkgs.push(root);
186
+ }
187
+
188
+ return [...new Set(pkgs)];
189
+ }
190
+
191
+ /** @return true if root is an external package name (not package/super). */
192
+ function isExternalRoot(root: string): boolean {
193
+ return root !== "package" && root !== "super";
194
+ }
195
+
196
+ /** Convert an unbound reference to a CodeMirror diagnostic. */
197
+ function unboundToDiagnostic(ref: UnboundRef): Diagnostic {
198
+ return {
199
+ from: ref.start,
200
+ to: ref.end,
201
+ severity: "error",
202
+ message: `unresolved identifier '${ref.path.join("::")}'`,
203
+ };
204
+ }
@@ -0,0 +1,168 @@
1
+ :host {
2
+ --tab-bar-bg: transparent;
3
+ --tab-border: #ccc;
4
+ --tab-color: #999;
5
+ --tab-active-bg: #fff;
6
+ --tab-active-color: #222;
7
+ --tab-accent: #5f61d8;
8
+
9
+ display: flex;
10
+ flex-direction: column;
11
+ position: relative;
12
+ min-height: 100px;
13
+ }
14
+
15
+ :host(.dark) {
16
+ --tab-bar-bg: transparent;
17
+ --tab-border: #555;
18
+ --tab-color: #999;
19
+ --tab-active-bg: #1e1e1e;
20
+ --tab-active-color: #fff;
21
+ }
22
+
23
+ .tab-bar {
24
+ --bar-v: 6px;
25
+ --bar-h: 8px;
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 14px;
29
+ padding: var(--bar-v) var(--bar-h);
30
+ background: var(--tab-bar-bg);
31
+ flex-shrink: 0;
32
+ position: relative;
33
+ }
34
+
35
+ .tab-bar::after {
36
+ content: "";
37
+ position: absolute;
38
+ bottom: 0;
39
+ left: 0;
40
+ right: 0;
41
+ height: 1px;
42
+ background: var(--tab-border);
43
+ }
44
+
45
+ .tab {
46
+ --tab-v: 5px;
47
+ --tab-h: 12px;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 16px;
51
+ padding: var(--tab-v) var(--tab-h);
52
+ background: transparent;
53
+ border: none;
54
+ border-radius: 0;
55
+ color: var(--tab-color);
56
+ cursor: pointer;
57
+ position: relative;
58
+ font-size: 15px;
59
+ font-family: system-ui, sans-serif;
60
+ }
61
+
62
+ .tab.active {
63
+ color: var(--tab-accent);
64
+ font-weight: 600;
65
+ padding-bottom: calc(var(--tab-v) + var(--bar-v) + 0.5px);
66
+ margin-bottom: calc(-1 * (var(--bar-v) + 0.5px));
67
+ position: relative;
68
+ z-index: 1;
69
+ border-bottom: 2px solid var(--tab-accent);
70
+ }
71
+
72
+ .tab.active:first-child {
73
+ margin-left: calc(-1 * var(--bar-h));
74
+ padding-left: calc(var(--tab-h) + var(--bar-h));
75
+ }
76
+
77
+ .tab-close {
78
+ position: absolute;
79
+ right: -8px;
80
+ /* top: 2px; */
81
+ width: 16px;
82
+ height: 16px;
83
+ padding: 0;
84
+ border: none;
85
+ background: transparent;
86
+ color: inherit;
87
+ cursor: pointer;
88
+ opacity: 0;
89
+ font-size: 18px;
90
+ line-height: 1;
91
+ }
92
+
93
+ .tab:hover .tab-close {
94
+ opacity: 0.6;
95
+ }
96
+
97
+ .tab-close:hover {
98
+ opacity: 1;
99
+ }
100
+
101
+ .tab-rename {
102
+ background: var(--tab-active-bg);
103
+ border: 1px solid var(--tab-border);
104
+ border-radius: 4px;
105
+ color: var(--tab-active-color);
106
+ font-size: 15px;
107
+ font-family: system-ui, sans-serif;
108
+ padding: 0 4px;
109
+ outline: none;
110
+ }
111
+
112
+ .tab-add {
113
+ padding: 0 10px;
114
+ background: transparent;
115
+ border: none;
116
+ color: var(--tab-color);
117
+ cursor: pointer;
118
+ font-size: 25px;
119
+ line-height: 1;
120
+ }
121
+
122
+ .tab-add:hover {
123
+ color: var(--tab-active-color);
124
+ }
125
+
126
+ .editor-container {
127
+ flex: 1;
128
+ min-height: 0;
129
+ width: 100%;
130
+ padding: var(--editor-padding, 16px 0 0);
131
+ }
132
+
133
+ .cm-editor {
134
+ height: 100%;
135
+ font-size: var(--editor-font-size, 14px);
136
+ }
137
+
138
+ .cm-scroller {
139
+ overflow: auto;
140
+ }
141
+
142
+ .snackbar {
143
+ position: absolute;
144
+ bottom: 16px;
145
+ left: 16px;
146
+ background: #e8e8e8;
147
+ color: #333;
148
+ padding: 10px 20px;
149
+ border-radius: 8px;
150
+ font-size: 14px;
151
+ font-family: system-ui, sans-serif;
152
+ line-height: 1.4;
153
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
154
+ opacity: 0;
155
+ pointer-events: none;
156
+ transition: opacity 0.2s;
157
+ z-index: 10;
158
+ }
159
+
160
+ :host(.dark) .snackbar {
161
+ background: #3a3a3a;
162
+ color: #e0e0e0;
163
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
164
+ }
165
+
166
+ .snackbar.visible {
167
+ opacity: 1;
168
+ }