scenic-prism-standalone 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +133 -0
- package/README.md +202 -0
- package/dist/component.d.ts +53 -0
- package/dist/component.js +313 -0
- package/dist/download.d.ts +24 -0
- package/dist/download.js +46 -0
- package/dist/embed.d.ts +22 -0
- package/dist/embed.js +40 -0
- package/dist/highlight.d.ts +100 -0
- package/dist/highlight.js +463 -0
- package/dist/html.d.ts +85 -0
- package/dist/html.js +154 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +50 -0
- package/dist/minify.d.ts +40 -0
- package/dist/minify.js +128 -0
- package/dist/options.d.ts +54 -0
- package/dist/options.js +56 -0
- package/dist/runtime.d.ts +44 -0
- package/dist/runtime.js +158 -0
- package/dist/tsx.d.ts +114 -0
- package/dist/tsx.js +74 -0
- package/package.json +37 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { shaderConstant } from "./embed.js";
|
|
2
|
+
import { minify } from "./minify.js";
|
|
3
|
+
import { configureCanvas } from "./options.js";
|
|
4
|
+
/** The file's own header: what it is, and what it is not. */
|
|
5
|
+
function header(config) {
|
|
6
|
+
return `/**
|
|
7
|
+
* <${config.name} /> — one scenic-prism scene, compiled, with nothing to install.
|
|
8
|
+
*
|
|
9
|
+
* Generated by scenic-prism-standalone. The scene is already WGSL by the time
|
|
10
|
+
* this file exists: every shape, material and light of it is baked into the
|
|
11
|
+
* TRACE source below as numeric literals, so there is no library to add to
|
|
12
|
+
* the project this is pasted into and nothing is fetched at runtime. The only
|
|
13
|
+
* import is React; the only requirement is a browser with WebGPU, and a
|
|
14
|
+
* TypeScript whose DOM library declares it (6.0 and later do).
|
|
15
|
+
*
|
|
16
|
+
* <${config.name} size={720} className="rounded-lg" />
|
|
17
|
+
*
|
|
18
|
+
* The component renders a bare <canvas> and nothing else — no wrapper, no
|
|
19
|
+
* styling, no fallback element — so its size on the page, and what it sits in,
|
|
20
|
+
* stay yours. Everything a <canvas> takes is passed straight through.
|
|
21
|
+
*
|
|
22
|
+
* Nothing regenerates this file. Edit it as your own.
|
|
23
|
+
*/`;
|
|
24
|
+
}
|
|
25
|
+
/** The scene, the framing and the budget, as the constants the component reads. */
|
|
26
|
+
function constants(config) {
|
|
27
|
+
return [
|
|
28
|
+
'/** Backing resolution the component falls back to, in pixels. */',
|
|
29
|
+
`const WIDTH = ${config.width};`,
|
|
30
|
+
`const HEIGHT = ${config.height};`,
|
|
31
|
+
'',
|
|
32
|
+
'/** Samples per pixel accumulated before the loop stops. */',
|
|
33
|
+
`const MAX_FRAMES = ${config.maxFrames};`,
|
|
34
|
+
'',
|
|
35
|
+
'/** A fixed base seed, or null for a fresh sample sequence on every mount. */',
|
|
36
|
+
`const SEED: number | null = ${config.seed === null ? 'null' : config.seed};`,
|
|
37
|
+
'',
|
|
38
|
+
'/** The WebGPU flags this file uses, by value. */',
|
|
39
|
+
'const BUFFER_STORAGE = 0x80;',
|
|
40
|
+
'const BUFFER_UNIFORM = 0x40;',
|
|
41
|
+
'const BUFFER_COPY_DST = 0x08;',
|
|
42
|
+
'',
|
|
43
|
+
'// The scene itself: every shape, material and light of it, compiled to a',
|
|
44
|
+
'// WGSL compute shader with all of its parameters baked in as literals. One',
|
|
45
|
+
"// invocation per pixel adds one sample to that pixel's running sum.",
|
|
46
|
+
shaderConstant('TRACE', config.trace),
|
|
47
|
+
'',
|
|
48
|
+
'// Tonemap and gamma, from the accumulation buffer to the canvas.',
|
|
49
|
+
shaderConstant('DISPLAY', config.display),
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
52
|
+
/** The props, which are a canvas's plus the render's own. */
|
|
53
|
+
function props(name) {
|
|
54
|
+
return `/**
|
|
55
|
+
* Everything a <canvas> takes, minus the five this component owns. \`width\` and
|
|
56
|
+
* \`height\` are the backing store rather than the CSS box, and \`onProgress\` and
|
|
57
|
+
* \`onError\` are DOM media events on every element — here they mean the render's
|
|
58
|
+
* progress and the render's failure.
|
|
59
|
+
*/
|
|
60
|
+
type CanvasProps = Omit<
|
|
61
|
+
ComponentPropsWithoutRef<'canvas'>,
|
|
62
|
+
'width' | 'height' | 'onProgress' | 'onError' | 'children'
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
export interface ${name}Props extends CanvasProps {
|
|
66
|
+
/** Square backing resolution in pixels; \`width\`/\`height\` override it. */
|
|
67
|
+
size?: number;
|
|
68
|
+
/** Backing-store width in pixels. Not a CSS width. */
|
|
69
|
+
width?: number;
|
|
70
|
+
/** Backing-store height in pixels. Not a CSS height. */
|
|
71
|
+
height?: number;
|
|
72
|
+
/** Stop accumulating after this many samples per pixel. */
|
|
73
|
+
maxFrames?: number;
|
|
74
|
+
/** Fix the sample sequence, for a reproducible accumulation. */
|
|
75
|
+
seed?: number | null;
|
|
76
|
+
/** Called after every accumulated frame, with the running sample count. */
|
|
77
|
+
onProgress?: (frames: number, total: number) => void;
|
|
78
|
+
/** Called once the accumulation finishes, with the count. */
|
|
79
|
+
onDone?: (frames: number) => void;
|
|
80
|
+
/** Called when the scene cannot be traced at all — no WebGPU, say. */
|
|
81
|
+
onError?: (error: Error) => void;
|
|
82
|
+
}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The fixed half of the file: the same progressive path tracer the library
|
|
86
|
+
* runs, inside the effect that owns it.
|
|
87
|
+
*/
|
|
88
|
+
function body(config) {
|
|
89
|
+
const { name, workgroup } = config;
|
|
90
|
+
return `export function ${name}({
|
|
91
|
+
size,
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
maxFrames = MAX_FRAMES,
|
|
95
|
+
seed = SEED,
|
|
96
|
+
onProgress,
|
|
97
|
+
onDone,
|
|
98
|
+
onError,
|
|
99
|
+
...canvasProps
|
|
100
|
+
}: ${name}Props) {
|
|
101
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The callbacks are read through a ref, so passing them inline — which is how
|
|
105
|
+
* anyone writes them — does not count as a change of scene. A fresh
|
|
106
|
+
* onProgress on every parent render would otherwise tear the render down and
|
|
107
|
+
* start the accumulation again from noise.
|
|
108
|
+
*/
|
|
109
|
+
const callbacks = useRef({ onProgress, onDone, onError });
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
callbacks.current = { onProgress, onDone, onError };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// The backing store, which is what the trace is sized for. How large the
|
|
115
|
+
// canvas is drawn is CSS, and so the caller's.
|
|
116
|
+
const w = Math.max(1, Math.round(width ?? size ?? WIDTH));
|
|
117
|
+
const h = Math.max(1, Math.round(height ?? size ?? HEIGHT));
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const canvas = canvasRef.current;
|
|
121
|
+
if (!canvas) return;
|
|
122
|
+
|
|
123
|
+
let running = true;
|
|
124
|
+
let raf = 0;
|
|
125
|
+
let device: GPUDevice | undefined;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A callback is the caller's code, so a throw inside one must not take the
|
|
129
|
+
* loop down with it. Report it the way the browser reports a throwing event
|
|
130
|
+
* listener: asynchronously, leaving everything here untouched.
|
|
131
|
+
*/
|
|
132
|
+
const notify = (report: () => void) => {
|
|
133
|
+
try {
|
|
134
|
+
report();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
throw error;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const start = async () => {
|
|
143
|
+
const gpu = navigator.gpu as GPU | undefined;
|
|
144
|
+
const adapter = await gpu?.requestAdapter();
|
|
145
|
+
if (!gpu || !adapter) throw new Error('This scene needs a browser with WebGPU.');
|
|
146
|
+
// The accumulation buffer is 16 bytes a pixel, so the storage limits are
|
|
147
|
+
// raised to whatever the adapter allows: a large canvas outgrows them.
|
|
148
|
+
device = await adapter.requestDevice({
|
|
149
|
+
requiredLimits: {
|
|
150
|
+
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
|
|
151
|
+
maxBufferSize: adapter.limits.maxBufferSize,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
// Unmounted while the device was on its way: give it straight back.
|
|
155
|
+
if (!running) {
|
|
156
|
+
device.destroy();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const context = canvas.getContext('webgpu') as GPUCanvasContext | null;
|
|
161
|
+
if (!context) throw new Error('This canvas cannot provide a WebGPU context.');
|
|
162
|
+
const format = gpu.getPreferredCanvasFormat();
|
|
163
|
+
${configureCanvas(config.colorSpace, config.alpha, ' ')}
|
|
164
|
+
// A shader that will not build is reported through onError, in the
|
|
165
|
+
// compiler's own words, rather than printed to a console.
|
|
166
|
+
device.pushErrorScope('validation');
|
|
167
|
+
const traceModule = device.createShaderModule({ code: TRACE });
|
|
168
|
+
const displayModule = device.createShaderModule({ code: DISPLAY });
|
|
169
|
+
const invalid = await device.popErrorScope();
|
|
170
|
+
if (invalid) throw new Error(invalid.message);
|
|
171
|
+
|
|
172
|
+
const trace = await device.createComputePipelineAsync({
|
|
173
|
+
layout: 'auto',
|
|
174
|
+
compute: { module: traceModule, entryPoint: 'main' },
|
|
175
|
+
});
|
|
176
|
+
const display = await device.createRenderPipelineAsync({
|
|
177
|
+
layout: 'auto',
|
|
178
|
+
vertex: { module: displayModule, entryPoint: 'vertexMain' },
|
|
179
|
+
fragment: { module: displayModule, entryPoint: 'fragmentMain', targets: [{ format }] },
|
|
180
|
+
});
|
|
181
|
+
if (!running) return;
|
|
182
|
+
|
|
183
|
+
// One RGBA32F running sum per pixel, zeroed at creation: read and written
|
|
184
|
+
// in place by the trace, read by the display.
|
|
185
|
+
const accum = device.createBuffer({ size: w * h * 16, usage: BUFFER_STORAGE });
|
|
186
|
+
// Per sample: the resolution, which sample this is, and its seed.
|
|
187
|
+
const frameBuffer = device.createBuffer({
|
|
188
|
+
size: 16,
|
|
189
|
+
usage: BUFFER_UNIFORM | BUFFER_COPY_DST,
|
|
190
|
+
});
|
|
191
|
+
// For the display: the resolution, and how many samples have gone in.
|
|
192
|
+
const displayBuffer = device.createBuffer({
|
|
193
|
+
size: 16,
|
|
194
|
+
usage: BUFFER_UNIFORM | BUFFER_COPY_DST,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const frameData = new ArrayBuffer(16);
|
|
198
|
+
new Float32Array(frameData, 0, 2).set([w, h]);
|
|
199
|
+
const frameWords = new Uint32Array(frameData, 8, 2);
|
|
200
|
+
const displayData = new Float32Array([w, h, 0, 0]);
|
|
201
|
+
|
|
202
|
+
const gpuDevice = device;
|
|
203
|
+
const bind = (pipeline: GPUComputePipeline | GPURenderPipeline, uniforms: GPUBuffer) =>
|
|
204
|
+
gpuDevice.createBindGroup({
|
|
205
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
206
|
+
entries: [
|
|
207
|
+
{ binding: 0, resource: { buffer: uniforms } },
|
|
208
|
+
{ binding: 1, resource: { buffer: accum } },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
const traceGroup = bind(trace, frameBuffer);
|
|
212
|
+
const displayGroup = bind(display, displayBuffer);
|
|
213
|
+
|
|
214
|
+
let frame = 0;
|
|
215
|
+
|
|
216
|
+
const draw = () => {
|
|
217
|
+
if (!running) return;
|
|
218
|
+
frameWords[0] = frame;
|
|
219
|
+
frameWords[1] =
|
|
220
|
+
seed === null
|
|
221
|
+
? (Math.random() * 4294967296) >>> 0
|
|
222
|
+
: (Math.imul(seed + frame, 2654435761) ^ frame) >>> 0;
|
|
223
|
+
gpuDevice.queue.writeBuffer(frameBuffer, 0, frameData);
|
|
224
|
+
displayData[2] = frame + 1;
|
|
225
|
+
gpuDevice.queue.writeBuffer(displayBuffer, 0, displayData);
|
|
226
|
+
|
|
227
|
+
const encoder = gpuDevice.createCommandEncoder();
|
|
228
|
+
|
|
229
|
+
// Trace: one invocation per pixel adds one new sample to its running sum.
|
|
230
|
+
const pass = encoder.beginComputePass();
|
|
231
|
+
pass.setPipeline(trace);
|
|
232
|
+
pass.setBindGroup(0, traceGroup);
|
|
233
|
+
pass.dispatchWorkgroups(Math.ceil(w / ${workgroup[0]}), Math.ceil(h / ${workgroup[1]}));
|
|
234
|
+
pass.end();
|
|
235
|
+
|
|
236
|
+
// Display: tonemap what has accumulated so far onto the canvas.
|
|
237
|
+
const out = encoder.beginRenderPass({
|
|
238
|
+
colorAttachments: [
|
|
239
|
+
{
|
|
240
|
+
view: context.getCurrentTexture().createView(),
|
|
241
|
+
loadOp: 'clear',
|
|
242
|
+
storeOp: 'store',
|
|
243
|
+
clearValue: [0, 0, 0, 0],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
out.setPipeline(display);
|
|
248
|
+
out.setBindGroup(0, displayGroup);
|
|
249
|
+
out.draw(3);
|
|
250
|
+
out.end();
|
|
251
|
+
|
|
252
|
+
gpuDevice.queue.submit([encoder.finish()]);
|
|
253
|
+
frame++;
|
|
254
|
+
notify(() => callbacks.current.onProgress?.(frame, maxFrames));
|
|
255
|
+
|
|
256
|
+
if (frame >= maxFrames) {
|
|
257
|
+
notify(() => callbacks.current.onDone?.(frame));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// The next sample waits for this one, so a scene slower than the
|
|
261
|
+
// screen never queues work faster than the GPU can drain it.
|
|
262
|
+
void gpuDevice.queue.onSubmittedWorkDone().then(() => {
|
|
263
|
+
if (running) raf = requestAnimationFrame(draw);
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
draw();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
start().catch((cause: unknown) => {
|
|
271
|
+
// A scene that cannot be traced is the caller's to present: there is no
|
|
272
|
+
// fallback element here, because anything drawn in place of the canvas
|
|
273
|
+
// would be this component's styling rather than theirs.
|
|
274
|
+
if (!running) return;
|
|
275
|
+
notify(() =>
|
|
276
|
+
callbacks.current.onError?.(cause instanceof Error ? cause : new Error(String(cause))),
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Unmounting, or a change of size, gives the GPU back: the device goes, and
|
|
281
|
+
// every buffer and pipeline on it with it.
|
|
282
|
+
return () => {
|
|
283
|
+
running = false;
|
|
284
|
+
cancelAnimationFrame(raf);
|
|
285
|
+
device?.destroy();
|
|
286
|
+
};
|
|
287
|
+
}, [h, maxFrames, seed, w]);
|
|
288
|
+
|
|
289
|
+
return <canvas ref={canvasRef} width={w} height={h} {...canvasProps} />;
|
|
290
|
+
}`;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The whole `.tsx` file for one scene: the baked constants, the props, then the
|
|
294
|
+
* component that reads them. `min` squeezes the two shaders — which are most of
|
|
295
|
+
* the file — and leaves the component alone, since that half is meant to be
|
|
296
|
+
* read, and whatever bundles it will minify it anyway.
|
|
297
|
+
*/
|
|
298
|
+
export function componentSource(config, min = false) {
|
|
299
|
+
const shaders = min
|
|
300
|
+
? { ...config, trace: minify(config.trace), display: minify(config.display) }
|
|
301
|
+
: config;
|
|
302
|
+
const imports = [
|
|
303
|
+
"import { useEffect, useRef } from 'react';",
|
|
304
|
+
"import type { ComponentPropsWithoutRef } from 'react';",
|
|
305
|
+
].join('\n');
|
|
306
|
+
const parts = [header(config), imports, constants(shaders), props(config.name), body(config)];
|
|
307
|
+
// A directive has to be the very first thing in the file, above the header.
|
|
308
|
+
if (config.useClient)
|
|
309
|
+
parts.unshift("'use client';");
|
|
310
|
+
if (config.exportDefault)
|
|
311
|
+
parts.push(`export default ${config.name};`);
|
|
312
|
+
return `${parts.join('\n\n')}\n`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StandaloneOptions } from './html.js';
|
|
2
|
+
import type { FluentSceneSpec } from 'scenic-prism-fluent';
|
|
3
|
+
/** What a downloaded file is called when the caller does not name it. */
|
|
4
|
+
export declare const DEFAULT_FILENAME = "scene.html";
|
|
5
|
+
export interface DownloadOptions extends StandaloneOptions {
|
|
6
|
+
/** The name to save under. `.html` is appended if it is missing. */
|
|
7
|
+
filename?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the page for `spec` and save it, as though the visitor had clicked a
|
|
11
|
+
* link to it. Returns the same HTML {@link buildStandaloneHtml} would, so a
|
|
12
|
+
* caller that also wants to show or measure what it just saved need not build
|
|
13
|
+
* it twice.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* <button onClick={() => downloadStandaloneHtml(spec, { size: 900, min: true })}>
|
|
17
|
+
* Download
|
|
18
|
+
* </button>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Browser only, and deliberately not guarded: called where there is no
|
|
22
|
+
* `document` it throws, rather than quietly doing nothing on a server render.
|
|
23
|
+
*/
|
|
24
|
+
export declare function downloadStandaloneHtml(spec: FluentSceneSpec, options?: DownloadOptions): string;
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing the file to the person looking at the page.
|
|
3
|
+
*
|
|
4
|
+
* The rest of this package is a pure function of a scene and runs anywhere —
|
|
5
|
+
* Node, a build script, a worker. This one is the browser half: it is what
|
|
6
|
+
* turns "generate a page" into "download a page", which is the thing an app
|
|
7
|
+
* with an export button actually wants and the only part that needs a DOM.
|
|
8
|
+
*/
|
|
9
|
+
import { buildStandaloneHtml } from "./html.js";
|
|
10
|
+
/** What a downloaded file is called when the caller does not name it. */
|
|
11
|
+
export const DEFAULT_FILENAME = 'scene.html';
|
|
12
|
+
/** `name.html` for anything that does not already end in `.html` or `.htm`. */
|
|
13
|
+
function withExtension(filename) {
|
|
14
|
+
return /\.html?$/i.test(filename) ? filename : `${filename}.html`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the page for `spec` and save it, as though the visitor had clicked a
|
|
18
|
+
* link to it. Returns the same HTML {@link buildStandaloneHtml} would, so a
|
|
19
|
+
* caller that also wants to show or measure what it just saved need not build
|
|
20
|
+
* it twice.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* <button onClick={() => downloadStandaloneHtml(spec, { size: 900, min: true })}>
|
|
24
|
+
* Download
|
|
25
|
+
* </button>
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Browser only, and deliberately not guarded: called where there is no
|
|
29
|
+
* `document` it throws, rather than quietly doing nothing on a server render.
|
|
30
|
+
*/
|
|
31
|
+
export function downloadStandaloneHtml(spec, options = {}) {
|
|
32
|
+
const html = buildStandaloneHtml(spec, options);
|
|
33
|
+
const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }));
|
|
34
|
+
const link = document.createElement('a');
|
|
35
|
+
link.href = url;
|
|
36
|
+
link.download = withExtension(options.filename ?? DEFAULT_FILENAME);
|
|
37
|
+
// Firefox only follows a click on an element that is in the document.
|
|
38
|
+
link.style.display = 'none';
|
|
39
|
+
document.body.append(link);
|
|
40
|
+
link.click();
|
|
41
|
+
link.remove();
|
|
42
|
+
// The download reads the blob asynchronously, so the URL has to outlive the
|
|
43
|
+
// click — a tick is enough, and holding it longer would leak the whole file.
|
|
44
|
+
setTimeout(() => URL.revokeObjectURL(url));
|
|
45
|
+
return html;
|
|
46
|
+
}
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one check every generated file shares.
|
|
3
|
+
*
|
|
4
|
+
* Both of the files this package writes — a page and a component — carry the
|
|
5
|
+
* two shaders as template literals, so both need the same promise kept: that
|
|
6
|
+
* nothing in a shader ends the literal, or the element around it, early.
|
|
7
|
+
*
|
|
8
|
+
* It is a guard on a promise rather than a case to handle. `compile.ts` emits
|
|
9
|
+
* WGSL out of numbers and fixed text, and the one part of a shader it does not
|
|
10
|
+
* write — a `custom` node's own source — is checked for exactly this list by
|
|
11
|
+
* the builder that takes it. So this fails loudly rather than writing a broken
|
|
12
|
+
* file.
|
|
13
|
+
*/
|
|
14
|
+
/** Throw unless `source` can be embedded verbatim as a template literal. */
|
|
15
|
+
export declare function checkEmbeddable(name: string, source: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* `const NAME = ` + the shader, as one line and its contents. The opening
|
|
18
|
+
* backtick is followed by the shader and nothing else, which WGSL does not
|
|
19
|
+
* insist on the way GLSL's first-line `#version` did, but which keeps a
|
|
20
|
+
* shader's line numbers in an error message the line numbers of the string.
|
|
21
|
+
*/
|
|
22
|
+
export declare function shaderConstant(name: string, source: string): string;
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one check every generated file shares.
|
|
3
|
+
*
|
|
4
|
+
* Both of the files this package writes — a page and a component — carry the
|
|
5
|
+
* two shaders as template literals, so both need the same promise kept: that
|
|
6
|
+
* nothing in a shader ends the literal, or the element around it, early.
|
|
7
|
+
*
|
|
8
|
+
* It is a guard on a promise rather than a case to handle. `compile.ts` emits
|
|
9
|
+
* WGSL out of numbers and fixed text, and the one part of a shader it does not
|
|
10
|
+
* write — a `custom` node's own source — is checked for exactly this list by
|
|
11
|
+
* the builder that takes it. So this fails loudly rather than writing a broken
|
|
12
|
+
* file.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* What a shader may never contain: something a template literal reads as an
|
|
16
|
+
* escape or as an interpolation, or that closes the `<script>` a page puts one
|
|
17
|
+
* in. The last two matter to the HTML file alone, but a shader that could not
|
|
18
|
+
* go in a page is not one worth putting in a component either — the two files
|
|
19
|
+
* are the same scene, and an author should not find out which is which.
|
|
20
|
+
*/
|
|
21
|
+
const FORBIDDEN = ['`', '${', '\\', '</script', '<!--'];
|
|
22
|
+
/** Throw unless `source` can be embedded verbatim as a template literal. */
|
|
23
|
+
export function checkEmbeddable(name, source) {
|
|
24
|
+
for (const forbidden of FORBIDDEN) {
|
|
25
|
+
if (source.includes(forbidden)) {
|
|
26
|
+
throw new Error(`scenic-prism-standalone: the ${name} shader contains '${forbidden}', ` +
|
|
27
|
+
'which cannot be embedded in a generated file as a template literal.');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `const NAME = ` + the shader, as one line and its contents. The opening
|
|
33
|
+
* backtick is followed by the shader and nothing else, which WGSL does not
|
|
34
|
+
* insist on the way GLSL's first-line `#version` did, but which keeps a
|
|
35
|
+
* shader's line numbers in an error message the line numbers of the string.
|
|
36
|
+
*/
|
|
37
|
+
export function shaderConstant(name, source) {
|
|
38
|
+
checkEmbeddable(name, source);
|
|
39
|
+
return `const ${name} = \`${source}\`;`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's source, coloured — for showing or handing out the markup
|
|
3
|
+
* {@link buildStandaloneHtml} wrote rather than the scene it draws.
|
|
4
|
+
*
|
|
5
|
+
* This package may not depend on the site (see `CLAUDE.md`), so it cannot
|
|
6
|
+
* reach for the site's highlighters. What follows is its own tokenizing: a
|
|
7
|
+
* small WGSL grammar for the shaders baked into the script, and a tokenizer over
|
|
8
|
+
* that script, the stylesheet and the markup around them. Like its site
|
|
9
|
+
* counterparts it is narrow on purpose — it assumes the input is a page this
|
|
10
|
+
* package generated (every template literal in the script is a shader), not
|
|
11
|
+
* arbitrary hand-written HTML.
|
|
12
|
+
*/
|
|
13
|
+
export interface FragmentOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Bake in the site's dark palette instead of its light one. Default false —
|
|
16
|
+
* the colours are fixed values rather than custom properties, so this is
|
|
17
|
+
* the one choice about them the caller gets.
|
|
18
|
+
*/
|
|
19
|
+
dark?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Put this in front of every class name, both in the markup and in the
|
|
22
|
+
* stylesheet. Default `''`, which is the single letters
|
|
23
|
+
* {@link highlightStandaloneHtml} uses; give a prefix (`'sd-'`, say) where
|
|
24
|
+
* the host page might already have styled `.b` or `.l` itself.
|
|
25
|
+
*/
|
|
26
|
+
prefix?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface HighlightOptions extends FragmentOptions {
|
|
29
|
+
/** The document's title. Default `scenic-prism source`. */
|
|
30
|
+
title?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Let the long lines wrap instead of scrolling sideways. Default false: the
|
|
33
|
+
* source sits in a `<pre>` and a line as long as a minified page's is
|
|
34
|
+
* reached by scrolling. True drops the `<pre>` for a `<div>` set to
|
|
35
|
+
* `white-space: pre-wrap`, so the same source folds to the window's width —
|
|
36
|
+
* the indentation is still the file's, but a line break on screen may not
|
|
37
|
+
* be one in the file.
|
|
38
|
+
*/
|
|
39
|
+
wrap?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** The two halves of a highlighted page, for a caller assembling their own. */
|
|
42
|
+
export interface HighlightedFragment {
|
|
43
|
+
/**
|
|
44
|
+
* The coloured source as markup — nothing but text and `<span>`s, with no
|
|
45
|
+
* `<pre>`, `<code>` or wrapper of any kind around it. It carries the
|
|
46
|
+
* original's own newlines and indentation, so whatever encloses it has to
|
|
47
|
+
* preserve them (a `<pre>`, or `white-space: pre` — `pre-wrap` where the
|
|
48
|
+
* long lines should fold rather than scroll).
|
|
49
|
+
*/
|
|
50
|
+
code: string;
|
|
51
|
+
/**
|
|
52
|
+
* The rules those spans need, as stylesheet text: one rule per token class
|
|
53
|
+
* and nothing else — no `:root`, no `body`, no rule for the container.
|
|
54
|
+
*/
|
|
55
|
+
css: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The same colouring as {@link highlightStandaloneHtml}, handed back in two
|
|
59
|
+
* pieces instead of as a document: the marked-up source, and the stylesheet
|
|
60
|
+
* that gives it its colours. Neither carries a `<pre>`, a `<code>`, a `<style>`
|
|
61
|
+
* or anything else around it, so both can be dropped into a page of the
|
|
62
|
+
* caller's own — a docs template, a React component, an email — beside
|
|
63
|
+
* whatever else it shows.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { buildStandaloneHtml, highlightStandaloneHtmlFragment } from 'scenic-prism-standalone';
|
|
67
|
+
*
|
|
68
|
+
* const html = buildStandaloneHtml(spec);
|
|
69
|
+
* const { code, css } = highlightStandaloneHtmlFragment(html, { prefix: 'sd-' });
|
|
70
|
+
*
|
|
71
|
+
* page = `<style>${css}</style><pre class="listing"><code>${code}</code></pre>`;
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* The markup is the original's text with the characters that would not survive
|
|
75
|
+
* it escaped, so the container must preserve whitespace: `code` holds the
|
|
76
|
+
* newlines and the indentation, and the stylesheet says nothing about them.
|
|
77
|
+
*/
|
|
78
|
+
export declare function highlightStandaloneHtmlFragment(html: string, options?: FragmentOptions): HighlightedFragment;
|
|
79
|
+
/**
|
|
80
|
+
* Render a page {@link buildStandaloneHtml} wrote as coloured source, rather
|
|
81
|
+
* than as the scene it draws — a self-contained document with no dependency
|
|
82
|
+
* of its own, ready to be written to disk, pasted somewhere that keeps
|
|
83
|
+
* formatting, or shown alongside the render it came from.
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* import { buildStandaloneHtml, highlightStandaloneHtml } from 'scenic-prism-standalone';
|
|
87
|
+
*
|
|
88
|
+
* const html = buildStandaloneHtml(spec);
|
|
89
|
+
* const source = highlightStandaloneHtml(html, { dark: true });
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* `wrap` is the one choice about the shape of it: off, the source sits in a
|
|
93
|
+
* `<pre>` and long lines scroll; on, there is no `<pre>` at all and they fold
|
|
94
|
+
* to the window instead.
|
|
95
|
+
*
|
|
96
|
+
* For the same colouring inside a page of your own, see
|
|
97
|
+
* {@link highlightStandaloneHtmlFragment}, which returns the markup and the
|
|
98
|
+
* stylesheet separately and wraps neither.
|
|
99
|
+
*/
|
|
100
|
+
export declare function highlightStandaloneHtml(html: string, options?: HighlightOptions): string;
|