From 0dd3209241ea5aa74d4897187898e41386556855 Mon Sep 17 00:00:00 2001 From: NetWin Date: Fri, 23 Oct 2026 22:55:48 +0200 Subject: [PATCH 1/5] ci(release): don't release commits scoped to the playground --- release.config.js | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/release.config.js b/release.config.js index c14d85a..155552d 100644 --- a/release.config.js +++ b/release.config.js @@ -17,6 +17,26 @@ const publish = [ '@semantic-release/github' ]; +// Every commit type the release notes list, in the order of their sections +const types = [ + { type: 'feat', section: 'πŸš€ Features' }, + { type: 'fix', section: '🩹 Bug Fixes' }, + { type: 'perf', section: '⚑ Performance Improvements' }, + { type: 'revert', section: '↩️ Reverts' }, + { type: 'docs', section: 'πŸ“– Documentation' }, + { type: 'style', section: '🎨 Code Style' }, + { type: 'chore', section: 'πŸ”§ Miscellaneous Chores' }, + { type: 'refactor', section: 'πŸ› οΈ Code Refactoring' }, + { type: 'test', section: 'πŸ§ͺ Tests' }, + { type: 'build', scope: 'deps', section: 'πŸ— Dependency Updates' }, + { type: 'build', section: 'πŸ“¦ Build System' }, + { type: 'ci', section: 'πŸ”„οΈ Continuous Integration' } +]; + +// The playground isn't published in the library, so its commits neither release nor appear in the release notes. +// The preset uses the first entry that matches a commit, so these go before `types`. +const playground = types.map(({ type }) => ({ type, scope: 'playground', hidden: true })); + export default { branches: ['master'], // Plain versions (1.2.3), without the default "v" prefix @@ -27,6 +47,7 @@ export default { { preset: 'conventionalcommits', releaseRules: [ + { scope: 'playground', release: false }, // While Maple is on 0.x, a breaking change bumps the minor version { breaking: true, release: 'minor' }, // Dependency updates and refactorings change what ships, so they release a patch @@ -40,20 +61,7 @@ export default { { preset: 'conventionalcommits', presetConfig: { - types: [ - { type: 'feat', section: 'πŸš€ Features' }, - { type: 'fix', section: '🩹 Bug Fixes' }, - { type: 'perf', section: '⚑ Performance Improvements' }, - { type: 'revert', section: '↩️ Reverts' }, - { type: 'docs', section: 'πŸ“– Documentation' }, - { type: 'style', section: '🎨 Code Style' }, - { type: 'chore', section: 'πŸ”§ Miscellaneous Chores' }, - { type: 'refactor', section: 'πŸ› οΈ Code Refactoring' }, - { type: 'test', section: 'πŸ§ͺ Tests' }, - { type: 'build', scope: 'deps', section: 'πŸ— Dependency Updates' }, - { type: 'build', section: 'πŸ“¦ Build System' }, - { type: 'ci', section: 'πŸ”„οΈ Continuous Integration' } - ] + types: [...playground, ...types] } } ], From 4fa2106d81440628178dd8046b33cc0601f5442e Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 08:12:13 +0200 Subject: [PATCH 2/5] feat(playground): run scripts against a host context module The Context pane becomes the Host context: a JavaScript module the worker imports from a blob URL. INSTANCE_DATA becomes the instance's globals and RUN_DATA the data of each run, so scripts can call host functions. --- apps/maple-playground/README.md | 34 +++++++-- apps/maple-playground/angular.json | 2 + apps/maple-playground/src/app/app.html | 17 +++-- apps/maple-playground/src/app/app.ts | 36 +++++++--- .../src/app/data-access/benchmark-protocol.ts | 4 +- .../src/app/data-access/benchmark.ts | 19 ++++- .../src/app/data-access/benchmark.worker.ts | 34 +++++---- .../src/app/data-access/host-module.ts | 70 +++++++++++++++++++ .../app/data-access/js-interpreter-engine.ts | 2 +- .../app/data-access/maple-worker-protocol.ts | 19 ++--- .../src/app/data-access/maple.worker.ts | 40 +++++++++-- .../src/app/data-access/playground.ts | 49 ++++++++++--- .../src/app/data-access/runner.ts | 31 ++++++-- .../src/app/ui/benchmark-panel.html | 2 +- 14 files changed, 284 insertions(+), 75 deletions(-) create mode 100644 apps/maple-playground/src/app/data-access/host-module.ts diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index 2d901c6..024f415 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -1,7 +1,7 @@ # 🍁 Maple Playground -A private Angular app for trying out Maple. It has editors for code and a JSON context and shows the result, the AST -and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is +A private Angular app for trying out Maple. It has editors for a host context and a script and shows the result, the +AST and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is stopped after 1 s. ```sh @@ -11,26 +11,46 @@ pnpm dev # from the repository root, serves the playground on :4200 `@netwin/maple` resolves to the library's sources (`packages/maple/src/index.ts`), so library edits show up without a build. +## Host context and script + +The two editors stand for the two sides of a Maple integration: + +- **Host context** is trusted code, a JavaScript module the playground imports natively in the worker, with the + worker's full access, as your application would run. Its two optional named exports are what the script gets: + + ```js + export const INSTANCE_DATA = { upperCase: (text) => text.toUpperCase() }; // new Maple({ globals }) + export const RUN_DATA = { name: 'Steve' }; // maple.run(script, data) + ``` + + `INSTANCE_DATA` holds read-only bindings shared by every run, host functions included; `RUN_DATA` is the data of one + run, which the script gets a copy of and may change. A missing export stands for `{}`. Any other export, a value that + is not an object or a module that fails to load shows up as **Host module failed** in the output, apart from Maple's + own errors. + +- **Script** is the untrusted code Maple runs. It reaches the two exports' keys and nothing else; a key of `RUN_DATA` + shadows the same key of `INSTANCE_DATA`. + ## Settings The **Settings** popover in the header sets the run limits, `maxOps` (default `1000000`) and `maxDepth` (default -`100`). Like code and context they persist in local storage, and **Reset** restores them. +`100`). Like the host context and the script they persist in local storage, and **Reset** restores them. ## Comparing with js-interpreter [`@netwin/js-interpreter`](https://github.com/netwin/JS-Interpreter) is installed as a dev dependency so Maple can be compared with it. Both comparisons call js-interpreter the same way (`src/app/data-access/js-interpreter-engine.ts`): the script is wrapped in a function so that `return` works, and each -top-level key of the context becomes a global. +key of `INSTANCE_DATA` and `RUN_DATA` becomes a global, run data shadowing instance data as in Maple. js-interpreter understands ES5 only, so a script using `let`, `const` or arrow functions shows its parse error in the js-interpreter row while Maple still reports its figures. ### Speed: the Benchmark tab -The **Benchmark** tab in the output pane runs the current code and context in both engines: 3 unmeasured warm-up runs, +The **Benchmark** tab in the output pane runs the current script and host context in both engines: 3 unmeasured warm-up runs, then 20 measured runs each. It shows the median, the min–max range, how much slower js-interpreter is, and a warning -when the two engines return different results. Each run is timed end to end, including parsing, setting up the -context and converting the result. +when the two engines return different results. Each run is timed end to end, including setting up Maple, parsing and +converting the result; the host module is imported once, before the runs. The benchmark has its own worker and a 60 s timeout. js-interpreter has no operation limit, so for an endless loop that timeout is the only stop. diff --git a/apps/maple-playground/angular.json b/apps/maple-playground/angular.json index db67ee7..9bd3be4 100644 --- a/apps/maple-playground/angular.json +++ b/apps/maple-playground/angular.json @@ -73,6 +73,8 @@ }, "defaultConfiguration": "development", "options": { + "host": "0.0.0.0", + "port": 4200, "prebundle": { "exclude": ["monaco-editor"] } diff --git a/apps/maple-playground/src/app/app.html b/apps/maple-playground/src/app/app.html index 93edaca..079a245 100644 --- a/apps/maple-playground/src/app/app.html +++ b/apps/maple-playground/src/app/app.html @@ -39,18 +39,23 @@

Maple

storageKey="maple-playground-panes"> - JSON + title="Host context"> + JS + language="javascript" + [(value)]="hostContext"> - JS + title="Script"> +
+ Sandboxed + JS +
diff --git a/apps/maple-playground/src/app/app.ts b/apps/maple-playground/src/app/app.ts index 5a8fdc6..ec806ce 100644 --- a/apps/maple-playground/src/app/app.ts +++ b/apps/maple-playground/src/app/app.ts @@ -85,7 +85,7 @@ export class App { protected readonly astDocument = computed(() => this.#runner.ast() ?? ''); - protected readonly contextString = this.#playground.contextString; + protected readonly hostContext = this.#playground.hostContext; protected readonly codeString = this.#playground.codeString; @@ -96,12 +96,34 @@ export class App { protected readonly debouncedInput = debounced( () => ({ code: this.codeString(), - context: this.contextString(), + hostContext: this.hostContext(), limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } }), RUN_DELAY_MS ); + /** + * What the next live run gets. The debounced resource also notifies when only its status changes, + * so equal input is dropped here: a rerun would advance state the host module keeps between runs. + */ + protected readonly runInput = computed( + () => + this.debouncedInput.hasValue() + ? this.debouncedInput.value() + : { + code: '', + hostContext: '', + limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } + }, + { + equal: (previous, next) => + previous.code === next.code && + previous.hostContext === next.hostContext && + previous.limits.maxOps === next.limits.maxOps && + previous.limits.maxDepth === next.limits.maxDepth + } + ); + protected readonly outputTabs: ReadonlyArray> = [ { value: 'result', label: 'Result' }, { value: 'ast', label: 'AST' }, @@ -111,10 +133,8 @@ export class App { constructor() { // Re-runs shortly after the last edit to either editor or to the limits. effect(() => { - const { code, context, limits } = this.debouncedInput.hasValue() - ? this.debouncedInput.value() - : { code: '', context: '', limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() } }; - this.#runner.run(code, context, limits); + const { code, hostContext, limits } = this.runInput(); + this.#runner.run(code, hostContext, limits); }); } @@ -122,13 +142,13 @@ export class App { protected runBenchmark(): void { this.#benchmark.run({ code: this.codeString(), - context: this.contextString(), + hostSource: this.hostContext(), limits: { maxOps: this.maxOps(), maxDepth: this.maxDepth() }, runs: BENCHMARK_RUNS }); } - /** Restores the default context, code and pane sizes. */ + /** Restores the default host context, code and pane sizes. */ protected reset(): void { this.#playground.reset(); this.split().reset(); diff --git a/apps/maple-playground/src/app/data-access/benchmark-protocol.ts b/apps/maple-playground/src/app/data-access/benchmark-protocol.ts index 68601ac..7d73500 100644 --- a/apps/maple-playground/src/app/data-access/benchmark-protocol.ts +++ b/apps/maple-playground/src/app/data-access/benchmark-protocol.ts @@ -5,10 +5,10 @@ export const ENGINES = ['maple', 'js-interpreter'] as const; export type Engine = (typeof ENGINES)[number]; -/** Asks the worker to time `code` against the JSON `context` in both engines. */ +/** Asks the worker to time `code` against the host module at `hostModuleUrl` in both engines. */ export interface BenchmarkRequest { code: string; - context: string; + hostModuleUrl: string; limits: RunLimits; /** Measured runs per engine, after a few unmeasured warm-up runs. */ runs: number; diff --git a/apps/maple-playground/src/app/data-access/benchmark.ts b/apps/maple-playground/src/app/data-access/benchmark.ts index f8fd07b..a3de7cb 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.ts @@ -1,5 +1,6 @@ import { Service, signal } from '@angular/core'; import type { BenchmarkRequest, BenchmarkResponse } from './benchmark-protocol'; +import { createModuleUrl } from './host-module'; /** Js-interpreter has no operation limit, so an endless loop is only stopped by this. */ export const BENCHMARK_TIMEOUT_MS = 60_000; @@ -16,6 +17,9 @@ export class Benchmark { #timeout: ReturnType | undefined; + /** The URL of the host module the running benchmark imports, revoked when it stops. */ + #hostModuleUrl: string | undefined; + readonly #status = signal('idle'); readonly #response = signal(undefined); @@ -25,8 +29,14 @@ export class Benchmark { /** The latest finished benchmark; it stays visible while the next one runs. */ public readonly response = this.#response.asReadonly(); - public run(request: BenchmarkRequest): void { + /** Imports the host module written in `hostSource` from a URL of its own, as the live runs do. */ + public run({ + hostSource, + ...request + }: Omit & { hostSource: string }): void { this.stop(); + const hostModuleUrl = createModuleUrl(hostSource); + this.#hostModuleUrl = hostModuleUrl; this.#status.set('running'); const worker = new Worker(new URL('./benchmark.worker', import.meta.url), { type: 'module' }); worker.addEventListener('message', (event: MessageEvent) => { @@ -44,14 +54,19 @@ export class Benchmark { this.stop(); }, BENCHMARK_TIMEOUT_MS); this.#worker = worker; + const message: BenchmarkRequest = { ...request, hostModuleUrl }; // Worker.postMessage has no targetOrigin; the rule only applies to window.postMessage. // oxlint-disable-next-line unicorn/require-post-message-target-origin - worker.postMessage(request); + worker.postMessage(message); } private stop(): void { clearTimeout(this.#timeout); this.#worker?.terminate(); this.#worker = undefined; + if (this.#hostModuleUrl) { + URL.revokeObjectURL(this.#hostModuleUrl); + this.#hostModuleUrl = undefined; + } } } diff --git a/apps/maple-playground/src/app/data-access/benchmark.worker.ts b/apps/maple-playground/src/app/data-access/benchmark.worker.ts index 5b33de8..768f590 100644 --- a/apps/maple-playground/src/app/data-access/benchmark.worker.ts +++ b/apps/maple-playground/src/app/data-access/benchmark.worker.ts @@ -1,46 +1,50 @@ /// import { Maple } from '@netwin/maple'; import type { BenchmarkRequest, BenchmarkResponse, EngineResult } from './benchmark-protocol'; +import { type HostContext, importHostContext } from './host-module'; import { runJsInterpreter } from './js-interpreter-engine'; -import { parseContext } from './maple-worker-protocol'; /** Unmeasured runs first, so neither engine is timed while the JIT is still cold. */ const WARM_UP_RUNS = 3; -addEventListener('message', (event: MessageEvent) => { - const { code, context, limits, runs } = event.data; +addEventListener('message', async (event: MessageEvent) => { + const { code, hostModuleUrl, limits, runs } = event.data; postMessage({ runs, - results: benchmark(code, context, limits, runs) + results: await benchmark(code, hostModuleUrl, limits, runs) } satisfies BenchmarkResponse); }); /** - * Parses the context once, outside the timed runs, since a host passes data it already holds; - * context that is not JSON fails both engines alike. + * Imports the host module once, outside the timed runs, since a host holds its data already; a + * module that fails to load fails both engines alike. */ -function benchmark( +async function benchmark( code: string, - context: string, + hostModuleUrl: string, limits: BenchmarkRequest['limits'], runs: number -): BenchmarkResponse['results'] { - let data: object; +): Promise { + let host: HostContext; try { - data = parseContext(context); + host = await importHostContext(hostModuleUrl); } catch (error) { const failed: EngineResult = { ok: false, error: describe(error) }; return { maple: failed, 'js-interpreter': failed }; } + // js-interpreter has no instances, so both exports become its globals, run data shadowing + // instance data as it does in Maple. + const globals = { ...host.instanceData, ...host.runData }; return { - maple: measure(runs, () => runMaple(code, data, limits)), - 'js-interpreter': measure(runs, () => runJsInterpreter(code, data)) + maple: measure(runs, () => runMaple(code, host, limits)), + 'js-interpreter': measure(runs, () => runJsInterpreter(code, globals)) }; } /** One complete execution as the host would do it: setup, parse, run and result conversion. */ -function runMaple(code: string, data: object, limits: BenchmarkRequest['limits']): string { - return JSON.stringify(new Maple(limits).run(code, data).value) ?? 'undefined'; +function runMaple(code: string, host: HostContext, limits: BenchmarkRequest['limits']): string { + const maple = new Maple({ globals: host.instanceData, ...limits }); + return JSON.stringify(maple.run(code, host.runData).value) ?? 'undefined'; } function measure(runs: number, execute: () => string): EngineResult { diff --git a/apps/maple-playground/src/app/data-access/host-module.ts b/apps/maple-playground/src/app/data-access/host-module.ts new file mode 100644 index 0000000..0e3081a --- /dev/null +++ b/apps/maple-playground/src/app/data-access/host-module.ts @@ -0,0 +1,70 @@ +/** + * The host context is a JavaScript module the playground imports natively, as trusted host code. + * Its two optional named exports become what Maple hands to the script: + * + * ```js + * export const INSTANCE_DATA = { upperCase: (text) => text.toUpperCase() }; // new Maple({ globals }) + * export const RUN_DATA = { name: 'Steve' }; // maple.run(code, data) + * ``` + */ +export interface HostContext { + /** Read-only bindings every run of the instance sees, host functions included. */ + instanceData: Record; + /** The data of one run; the script gets a copy it may change. */ + runData: Record; +} + +const EXPORTS: ReadonlyArray = ['INSTANCE_DATA', 'RUN_DATA']; + +/** The host module failed to load or exports something else than an object for each name. */ +export class HostModuleError extends Error { + public override readonly name = 'HostModuleError'; +} + +/** Puts `source` behind a URL a worker can `import()`; revoke it once no worker needs it. */ +export function createModuleUrl(source: string): string { + return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); +} + +/** Imports the module at `url` and checks its exports. */ +export async function importHostContext(url: string): Promise { + let namespace: object; + try { + namespace = await import(/* @vite-ignore */ url); + } catch (error) { + throw new HostModuleError(describe(error), { cause: error }); + } + + const exports = new Map(Object.entries(namespace)); + const unknown = [...exports.keys()].filter((name) => !EXPORTS.includes(name)); + if (unknown.length > 0) { + const names = unknown.map((name) => `'${name}'`).join(', '); + const noun = unknown.length === 1 ? 'export' : 'exports'; + throw new HostModuleError( + `Unexpected ${noun} ${names}; export INSTANCE_DATA and RUN_DATA only` + ); + } + return { + instanceData: readObject(exports, 'INSTANCE_DATA'), + runData: readObject(exports, 'RUN_DATA') + }; +} + +/** + * Copies the export's own enumerable keys, the ones Maple reads, into a record. A missing export + * stands for an empty object. + */ +function readObject(exports: Map, name: string): Record { + const value = exports.get(name); + if (value === undefined) { + return {}; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new HostModuleError(`${name} must be an object`); + } + return Object.fromEntries(Object.entries(value)); +} + +function describe(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} diff --git a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts index 368a905..17abc36 100644 --- a/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts +++ b/apps/maple-playground/src/app/data-access/js-interpreter-engine.ts @@ -7,7 +7,7 @@ interface InterpreterInternals { } /** - * Runs `code` against the parsed `data` in js-interpreter and returns the result as JSON text. + * Runs `code` against `data` in js-interpreter and returns the result as JSON text. * The script is wrapped in a function to allow `return`, and each data key becomes a global; * js-interpreter understands ES5 only and has no operation limit. The result is read and * serialized on the host, as Maple's is, so the data cannot shadow anything this relies on. diff --git a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts index 59d9269..b02f75f 100644 --- a/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts +++ b/apps/maple-playground/src/app/data-access/maple-worker-protocol.ts @@ -4,26 +4,17 @@ export interface RunLimits { maxDepth: number; } -/** Asks the worker to parse and run `code` against the JSON `context` within `limits`. */ +/** + * Asks the worker to parse `code` and run it within `limits` against the host module at + * `hostModuleUrl`. + */ export interface RunRequest { id: number; code: string; - context: string; + hostModuleUrl: string; limits: RunLimits; } -/** - * Turns the JSON `context` of a request back into the data a run takes. An array is refused here, - * as Maple would, so that both engines of the Benchmark tab see the same input error. - */ -export function parseContext(context: string): object { - const data: unknown = JSON.parse(context); - if (typeof data !== 'object' || data === null || Array.isArray(data)) { - throw new TypeError('context must be a JSON object'); - } - return data; -} - /** An error, reduced to what survives `postMessage`. */ export interface RunError { message: string; diff --git a/apps/maple-playground/src/app/data-access/maple.worker.ts b/apps/maple-playground/src/app/data-access/maple.worker.ts index 7d395ff..2f1a319 100644 --- a/apps/maple-playground/src/app/data-access/maple.worker.ts +++ b/apps/maple-playground/src/app/data-access/maple.worker.ts @@ -1,7 +1,7 @@ /// import { Maple, MapleError, parse } from '@netwin/maple'; +import { type HostContext, HostModuleError, importHostContext } from './host-module'; import { - parseContext, type RunError, type RunLimits, type RunRequest, @@ -9,10 +9,16 @@ import { type StepOutcome } from './maple-worker-protocol'; +/** The host module of the latest run, imported once per edit, so its state lasts between runs. */ +let host: { url: string; context: Promise } | undefined; + +/** One Maple per host module and limits, reused by every run as a host application would. */ +let instance: { url: string; limits: RunLimits; maple: Maple } | undefined; + // Runs off the main thread, so an endless loop in the interpreter can be stopped by terminating // the worker instead of freezing the page. -addEventListener('message', (event: MessageEvent) => { - const { id, code, context, limits } = event.data; +addEventListener('message', async (event: MessageEvent) => { + const { id, code, hostModuleUrl, limits } = event.data; postMessage({ id, @@ -23,20 +29,39 @@ addEventListener('message', (event: MessageEvent) => { postMessage({ id, step: 'result', - outcome: runStep(code, context, limits) + outcome: await runStep(code, hostModuleUrl, limits) } satisfies RunResponse); }); /** Runs the code within the limits and reports the value as JSON text next to the run's stats. */ -function runStep(code: string, context: string, limits: RunLimits): StepOutcome { +async function runStep( + code: string, + hostModuleUrl: string, + limits: RunLimits +): Promise { try { - const { value, stats } = new Maple(limits).run(code, parseContext(context)); + if (host?.url !== hostModuleUrl) { + host = { url: hostModuleUrl, context: importHostContext(hostModuleUrl) }; + } + const { instanceData, runData } = await host.context; + const { value, stats } = getMaple(hostModuleUrl, instanceData, limits).run(code, runData); return { ok: true, value: JSON.stringify(value, null, 2) ?? String(value), stats }; } catch (error) { return { ok: false, error: describeError(error) }; } } +function getMaple(url: string, globals: Record, limits: RunLimits): Maple { + if ( + instance?.url !== url || + instance.limits.maxOps !== limits.maxOps || + instance.limits.maxDepth !== limits.maxDepth + ) { + instance = { url, limits, maple: new Maple({ globals, ...limits }) }; + } + return instance.maple; +} + /** Runs `produce` and serializes its value as JSON text, or captures the error it throws. */ function attempt(produce: () => unknown): StepOutcome { try { @@ -57,6 +82,9 @@ function describeError(error: unknown): RunError { stack: error.stack }; } + if (error instanceof HostModuleError) { + return { message: `Host module failed: ${error.message}`, stack: error.stack }; + } if (error instanceof Error) { return { message: `${error.name}: ${error.message}`, stack: error.stack }; } diff --git a/apps/maple-playground/src/app/data-access/playground.ts b/apps/maple-playground/src/app/data-access/playground.ts index e08edd7..110d65c 100644 --- a/apps/maple-playground/src/app/data-access/playground.ts +++ b/apps/maple-playground/src/app/data-access/playground.ts @@ -1,16 +1,42 @@ import { effect, Service, signal, type WritableSignal } from '@angular/core'; -/** Default JSON context for the Maple code execution. */ -const DEFAULT_CONTEXT = JSON.stringify({ name: 'Maple' }, null, 2); +/** Default host context: the trusted module whose exports the script sees. */ +const DEFAULT_HOST_CONTEXT = `// Host context: trusted code that runs natively in the worker, as your application would. +// Maple hands INSTANCE_DATA to every run of the instance (read-only), RUN_DATA to one run. +export const INSTANCE_DATA = { + TAX_RATE: 0.19, + formatPrice: (amount) => amount.toFixed(2) + ' €' +}; + +export const RUN_DATA = { + order: { + coupon: 'SPRING10', + items: [ + { name: 'Maple syrup', price: 12.5, quantity: 2 }, + { name: 'Pancake mix', price: 4.2, quantity: 1 } + ] + } +}; +`; -/** Default code to be loaded into the editor. */ -const DEFAULT_CODE = ['const greeting = "Hello, " + name;', 'return greeting;'].join('\n'); +/** Default script: untrusted code that only reaches the host context's exports. */ +const DEFAULT_CODE = `// Script: untrusted code. It sees TAX_RATE, formatPrice and order, and nothing else. +const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0); +const discount = order.coupon === 'SPRING10' ? subtotal * 0.1 : 0; +const total = (subtotal - discount) * (1 + TAX_RATE); + +return { + subtotal: formatPrice(subtotal), + discount: formatPrice(discount), + total: formatPrice(total) +}; +`; /** Maple's own defaults, repeated here so the settings form can show and restore them. */ export const DEFAULT_MAX_OPS = 1_000_000; export const DEFAULT_MAX_DEPTH = 100; -const CONTEXT_STORAGE_KEY = 'maple-playground-context'; +const HOST_CONTEXT_STORAGE_KEY = 'maple-playground-host-context'; const CODE_STORAGE_KEY = 'maple-playground-code'; const MAX_OPS_STORAGE_KEY = 'maple-playground-max-ops'; const MAX_DEPTH_STORAGE_KEY = 'maple-playground-max-depth'; @@ -35,12 +61,17 @@ const POSITIVE_INTEGER: Codec = { }; /** - * The playground's context, code and run limits, persisted across reloads. A value equal to its - * default isn't stored, so a later change to the defaults reaches everyone who never edited it. + * The playground's host context, code and run limits, persisted across reloads. A value equal to + * its default isn't stored, so a later change to the defaults reaches everyone who never edited + * it. */ @Service() export class Playground { - public readonly contextString = persistedSignal(CONTEXT_STORAGE_KEY, DEFAULT_CONTEXT, TEXT); + public readonly hostContext = persistedSignal( + HOST_CONTEXT_STORAGE_KEY, + DEFAULT_HOST_CONTEXT, + TEXT + ); public readonly codeString = persistedSignal(CODE_STORAGE_KEY, DEFAULT_CODE, TEXT); @@ -54,7 +85,7 @@ export class Playground { /** Restores every default, which also clears the values from storage. */ public reset(): void { - this.contextString.set(DEFAULT_CONTEXT); + this.hostContext.set(DEFAULT_HOST_CONTEXT); this.codeString.set(DEFAULT_CODE); this.maxOps.set(DEFAULT_MAX_OPS); this.maxDepth.set(DEFAULT_MAX_DEPTH); diff --git a/apps/maple-playground/src/app/data-access/runner.ts b/apps/maple-playground/src/app/data-access/runner.ts index e55c03c..d450b24 100644 --- a/apps/maple-playground/src/app/data-access/runner.ts +++ b/apps/maple-playground/src/app/data-access/runner.ts @@ -1,4 +1,5 @@ import { Service, signal } from '@angular/core'; +import { createModuleUrl } from './host-module'; import type { RunError, RunLimits, @@ -32,6 +33,9 @@ export class Runner { #timeout: ReturnType | undefined; + /** The host module of the latest run; an edit replaces its URL and revokes the old one. */ + #hostModule: { source: string; url: string } | undefined; + readonly #status = signal('running'); readonly #ast = signal(undefined); @@ -57,10 +61,10 @@ export class Runner { public readonly stats = this.#stats.asReadonly(); /** - * Runs `code` against the JSON `context` within `limits`. The previous output stays visible - * until the new run replaces it, so fast runs don't flicker. + * Runs `code` within `limits` against the host module written in `hostSource`. The previous + * output stays visible until the new run replaces it, so fast runs don't flicker. */ - public run(code: string, context: string, limits: RunLimits): void { + public run(code: string, hostSource: string, limits: RunLimits): void { if (this.#inFlight) { this.stopWorker(); } @@ -71,12 +75,31 @@ export class Runner { this.#status.set('running'); this.#timeout = setTimeout(() => this.onTimeout(id), RUN_TIMEOUT_MS); - const request: RunRequest = { id, code, context, limits }; + const request: RunRequest = { + id, + code, + hostModuleUrl: this.getHostModuleUrl(hostSource), + limits + }; // Worker.postMessage has no targetOrigin; the rule only applies to window.postMessage. // oxlint-disable-next-line unicorn/require-post-message-target-origin this.getWorker().postMessage(request); } + /** + * No worker needs the old URL any more: a run in flight was stopped, an idle worker imports the + * new one. + */ + private getHostModuleUrl(source: string): string { + if (this.#hostModule?.source !== source) { + if (this.#hostModule) { + URL.revokeObjectURL(this.#hostModule.url); + } + this.#hostModule = { source, url: createModuleUrl(source) }; + } + return this.#hostModule.url; + } + private getWorker(): Worker { if (!this.#worker) { const worker = new Worker(new URL('./maple.worker', import.meta.url), { type: 'module' }); diff --git a/apps/maple-playground/src/app/ui/benchmark-panel.html b/apps/maple-playground/src/app/ui/benchmark-panel.html index 6512508..439f459 100644 --- a/apps/maple-playground/src/app/ui/benchmark-panel.html +++ b/apps/maple-playground/src/app/ui/benchmark-panel.html @@ -7,7 +7,7 @@ {{ status() === 'running' ? 'Running…' : 'Run benchmark' }}

- Same code and context in both engines, each timed end to end + Same script and host context in both engines, each timed end to end @if (response(); as last) { Β· median of {{ last.runs }} runs after warm-up } From 08ee39531f0341acb982336265058698e9203459 Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 08:48:59 +0200 Subject: [PATCH 3/5] feat(playground): explain Maple with a welcome dialog and examples A first visit opens a dialog on what Maple is and how to read the playground. An Examples menu loads scripts that show what Maple is for and how it keeps scripts in, and the header links to GitHub and npm and shows the library's version. --- apps/maple-playground/README.md | 8 + apps/maple-playground/src/app/app.html | 49 +++- apps/maple-playground/src/app/app.ts | 38 +++- .../src/app/data-access/examples.ts | 186 +++++++++++++++ .../src/app/data-access/playground.ts | 57 ++--- .../src/app/data-access/project.ts | 11 + .../src/app/ui/examples-menu.html | 47 ++++ .../src/app/ui/examples-menu.ts | 21 ++ .../src/app/ui/settings-menu.html | 2 +- .../src/app/ui/welcome-dialog.html | 214 ++++++++++++++++++ .../src/app/ui/welcome-dialog.ts | 39 ++++ 11 files changed, 627 insertions(+), 45 deletions(-) create mode 100644 apps/maple-playground/src/app/data-access/examples.ts create mode 100644 apps/maple-playground/src/app/data-access/project.ts create mode 100644 apps/maple-playground/src/app/ui/examples-menu.html create mode 100644 apps/maple-playground/src/app/ui/examples-menu.ts create mode 100644 apps/maple-playground/src/app/ui/welcome-dialog.html create mode 100644 apps/maple-playground/src/app/ui/welcome-dialog.ts diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index 024f415..41ac56b 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -31,6 +31,14 @@ The two editors stand for the two sides of a Maple integration: - **Script** is the untrusted code Maple runs. It reaches the two exports' keys and nothing else; a key of `RUN_DATA` shadows the same key of `INSTANCE_DATA`. +## Welcome dialog and examples + +A first visit opens **What is Maple?**, a dialog on what Maple is, how it works and how to read the playground; the +header button reopens it. **Examples** replaces the host context and the script with one of the scripts in +`src/app/data-access/examples.ts`, each explaining itself in a comment. The first of them is what a first visit and +**Reset** show. The header also links to GitHub and npm and shows the library's version, read from +`packages/maple/package.json` at build time. + ## Settings The **Settings** popover in the header sets the run limits, `maxOps` (default `1000000`) and `maxDepth` (default diff --git a/apps/maple-playground/src/app/app.html b/apps/maple-playground/src/app/app.html index 079a245..b8d4548 100644 --- a/apps/maple-playground/src/app/app.html +++ b/apps/maple-playground/src/app/app.html @@ -1,11 +1,25 @@

-
- 🍁 -

Maple

- Playground +
+
+ 🍁 +

Maple

+ + +
+
+ @@ -27,8 +41,31 @@

Maple

- Reset + + +
@@ -117,3 +154,5 @@

Maple

+ + diff --git a/apps/maple-playground/src/app/app.ts b/apps/maple-playground/src/app/app.ts index ec806ce..2825063 100644 --- a/apps/maple-playground/src/app/app.ts +++ b/apps/maple-playground/src/app/app.ts @@ -1,16 +1,29 @@ -import { Component, computed, debounced, effect, inject, signal, viewChild } from '@angular/core'; +import { + afterNextRender, + Component, + computed, + debounced, + effect, + inject, + signal, + viewChild +} from '@angular/core'; import { CodeEditor } from './code-editor/code-editor'; import { Benchmark } from './data-access/benchmark'; +import type { Example } from './data-access/examples'; import { Playground } from './data-access/playground'; +import { MAPLE_VERSION, PROJECT_LINKS } from './data-access/project'; import { RUN_TIMEOUT_MS, Runner } from './data-access/runner'; import { Badge } from './ui/badge'; import { BenchmarkPanel } from './ui/benchmark-panel'; +import { ExamplesMenu } from './ui/examples-menu'; import { Pane } from './ui/pane'; import { SettingsMenu } from './ui/settings-menu'; import { Split } from './ui/split'; import { StatusChip } from './ui/status-chip'; import { type TabOption, Tabs } from './ui/tabs'; import { ThemePicker } from './ui/theme-picker'; +import { WelcomeDialog } from './ui/welcome-dialog'; /** Rerun the code execution 300ms after the last edit. */ const RUN_DELAY_MS = 300; @@ -29,12 +42,14 @@ const BENCHMARK_RUNS = 20; Badge, BenchmarkPanel, CodeEditor, + ExamplesMenu, Pane, SettingsMenu, Split, StatusChip, Tabs, - ThemePicker + ThemePicker, + WelcomeDialog ], templateUrl: './app.html', host: { class: 'flex h-full flex-col' } @@ -46,8 +61,16 @@ export class App { readonly #benchmark = inject(Benchmark); + protected readonly VERSION = MAPLE_VERSION; + + protected readonly LINKS = PROJECT_LINKS; + protected readonly split = viewChild.required(Split); + protected readonly welcome = viewChild.required(WelcomeDialog); + + protected readonly welcomeSeen = this.#playground.welcomeSeen; + protected readonly outputTab = signal('result'); protected readonly status = this.#runner.status; @@ -136,6 +159,17 @@ export class App { const { code, hostContext, limits } = this.runInput(); this.#runner.run(code, hostContext, limits); }); + + // A first visit starts with what Maple is; closing the dialog marks it as seen. + afterNextRender(() => { + if (!this.welcomeSeen()) { + this.welcome().open(); + } + }); + } + + protected load(example: Example): void { + this.#playground.load(example); } /** Times the current editors' content, not the debounced one, so the click is what gets measured. */ diff --git a/apps/maple-playground/src/app/data-access/examples.ts b/apps/maple-playground/src/app/data-access/examples.ts new file mode 100644 index 0000000..e36eb7e --- /dev/null +++ b/apps/maple-playground/src/app/data-access/examples.ts @@ -0,0 +1,186 @@ +/** A host context and a script that show one thing about Maple; the script's comment explains it. */ +export interface Example { + title: string; + /** One line for the Examples menu. */ + summary: string; + hostContext: string; + code: string; +} + +/** For the examples whose script needs nothing from the host. */ +const EMPTY_HOST_CONTEXT = `// Host context: deliberately empty, so the script gets nothing from the host. +`; + +const PRICING_RULE: Example = { + title: 'Pricing rule', + summary: 'A business rule written as a script, with a host function', + hostContext: `// Host context: trusted code that runs natively in the worker, as your application would. +// Maple hands INSTANCE_DATA to every run of the instance (read-only), RUN_DATA to one run. +export const INSTANCE_DATA = { + TAX_RATE: 0.19, + formatPrice: (amount) => amount.toFixed(2) + ' €' +}; + +export const RUN_DATA = { + order: { + coupon: 'SPRING10', + items: [ + { name: 'Maple syrup', price: 12.5, quantity: 2 }, + { name: 'Pancake mix', price: 4.2, quantity: 1 } + ] + } +}; +`, + code: `// Script: untrusted code. It sees TAX_RATE, formatPrice and order, and nothing else. +const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0); +const discount = order.coupon === 'SPRING10' ? subtotal * 0.1 : 0; +const total = (subtotal - discount) * (1 + TAX_RATE); + +return { + subtotal: formatPrice(subtotal), + discount: formatPrice(discount), + total: formatPrice(total) +}; +` +}; + +const TRANSFORM_DATA: Example = { + title: 'Transform data', + summary: 'Modern JavaScript over the data of a run', + hostContext: `// Host context: the orders a report script works on. +export const RUN_DATA = { + orders: [ + { id: 1, customer: 'Ada', total: 42.5, status: 'paid' }, + { id: 2, customer: 'Grace', total: 18, status: 'open' }, + { id: 3, customer: 'Ada', total: 7.25, status: 'paid' }, + { id: 4, customer: 'Linus', total: 99.9, status: 'paid' } + ] +}; +`, + code: `// Transform data: arrow functions, destructuring, spread and ?? work as in JavaScript. +const paid = orders.filter(({ status }) => status === 'paid'); + +const revenueByCustomer = paid.reduce( + (totals, { customer, total }) => ({ ...totals, [customer]: (totals[customer] ?? 0) + total }), + {} +); + +return { + paidOrders: paid.map(({ id }) => id), + revenueByCustomer +}; +` +}; + +const NO_HOST_GLOBALS: Example = { + title: 'No host globals', + summary: 'The page, the worker and Node are out of reach', + hostContext: EMPTY_HOST_CONTEXT, + code: `// No host globals: each of these is a global in a browser, a worker or Node, and Maple hands +// none of them to the script. Standard built-ins such as Math and JSON are Maple's own. +return { + globalThis: typeof globalThis, + window: typeof window, + self: typeof self, + document: typeof document, + fetch: typeof fetch, + localStorage: typeof localStorage, + process: typeof process, + require: typeof require, + Math: typeof Math, + JSON: typeof JSON +}; +` +}; + +const PROTOTYPE_ESCAPE: Example = { + title: 'Prototype escape', + summary: 'The classic way out of a sandbox, blocked', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Prototype escape: the constructor of every function is Function, and +// Function('return process')() would run code in the host, outside the sandbox. +// Maple refuses to read \`constructor\` and \`__proto__\`, so the run fails right there. +const HostFunction = ({}).constructor.constructor; +return HostFunction('return process')(); +` +}; + +const NO_EVAL: Example = { + title: 'No eval', + summary: 'Strings never become code', + hostContext: EMPTY_HOST_CONTEXT, + code: `// No eval: eval and Function would turn a string into code that runs outside the sandbox. +// Maple has neither: calling eval ends the run with an error, and the string is never parsed. +const answer = 6 * 7; +return eval('answer'); +` +}; + +const RUNAWAY_LOOP: Example = { + title: 'Runaway loop', + summary: 'An endless loop stops at the operation limit', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Runaway loop: Maple counts the operations of a run and stops it at maxOps (see Settings, +// default 1,000,000), so an endless loop ends with an error instead of hanging the host. +let count = 0; +while (true) { + count++; +} +` +}; + +const INSTANCE_AND_RUN_DATA: Example = { + title: 'Instance and run data', + summary: 'Run data is a copy, instance data is read-only', + hostContext: `// Host context: INSTANCE_DATA is shared by every run and read-only, +// RUN_DATA is copied into each run. +export const INSTANCE_DATA = { + settings: { currency: '€', maxItems: 3 } +}; + +export const RUN_DATA = { + cart: ['Maple syrup', 'Pancake mix'] +}; +`, + code: `// Instance and run data: the script may change its copy of RUN_DATA ... +cart.push('Waffles'); + +// ... but INSTANCE_DATA is read-only, so this line throws. Delete it to see the cart. +settings.maxItems = 10; + +return cart; +` +}; + +const ERRORS: Example = { + title: 'Errors like JavaScript', + summary: 'Real error objects with the messages Node prints', + hostContext: EMPTY_HOST_CONTEXT, + code: `// Errors like JavaScript: a script's errors are TypeError, ReferenceError and RangeError objects +// with the messages Node prints, and try/catch works as usual. +const attempts = [() => null.name, () => missingFunction(), () => new Array(-1)]; + +return attempts.map((attempt) => { + try { + attempt(); + } catch (error) { + return \`\${error.name}: \${error.message}\`; + } +}); +` +}; + +/** In the order of the Examples menu: what Maple is for, then how it keeps scripts in. */ +export const EXAMPLES: ReadonlyArray = [ + PRICING_RULE, + TRANSFORM_DATA, + NO_HOST_GLOBALS, + PROTOTYPE_ESCAPE, + NO_EVAL, + RUNAWAY_LOOP, + INSTANCE_AND_RUN_DATA, + ERRORS +]; + +/** What a first visit and Reset show. */ +export const DEFAULT_EXAMPLE = PRICING_RULE; diff --git a/apps/maple-playground/src/app/data-access/playground.ts b/apps/maple-playground/src/app/data-access/playground.ts index 110d65c..2ab7e07 100644 --- a/apps/maple-playground/src/app/data-access/playground.ts +++ b/apps/maple-playground/src/app/data-access/playground.ts @@ -1,36 +1,5 @@ import { effect, Service, signal, type WritableSignal } from '@angular/core'; - -/** Default host context: the trusted module whose exports the script sees. */ -const DEFAULT_HOST_CONTEXT = `// Host context: trusted code that runs natively in the worker, as your application would. -// Maple hands INSTANCE_DATA to every run of the instance (read-only), RUN_DATA to one run. -export const INSTANCE_DATA = { - TAX_RATE: 0.19, - formatPrice: (amount) => amount.toFixed(2) + ' €' -}; - -export const RUN_DATA = { - order: { - coupon: 'SPRING10', - items: [ - { name: 'Maple syrup', price: 12.5, quantity: 2 }, - { name: 'Pancake mix', price: 4.2, quantity: 1 } - ] - } -}; -`; - -/** Default script: untrusted code that only reaches the host context's exports. */ -const DEFAULT_CODE = `// Script: untrusted code. It sees TAX_RATE, formatPrice and order, and nothing else. -const subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0); -const discount = order.coupon === 'SPRING10' ? subtotal * 0.1 : 0; -const total = (subtotal - discount) * (1 + TAX_RATE); - -return { - subtotal: formatPrice(subtotal), - discount: formatPrice(discount), - total: formatPrice(total) -}; -`; +import { DEFAULT_EXAMPLE, type Example } from './examples'; /** Maple's own defaults, repeated here so the settings form can show and restore them. */ export const DEFAULT_MAX_OPS = 1_000_000; @@ -40,6 +9,7 @@ const HOST_CONTEXT_STORAGE_KEY = 'maple-playground-host-context'; const CODE_STORAGE_KEY = 'maple-playground-code'; const MAX_OPS_STORAGE_KEY = 'maple-playground-max-ops'; const MAX_DEPTH_STORAGE_KEY = 'maple-playground-max-depth'; +const WELCOME_SEEN_STORAGE_KEY = 'maple-playground-welcome-seen'; /** How a persisted value is written to and read back from storage. */ interface Codec { @@ -52,6 +22,11 @@ const TEXT: Codec = { format: (value) => value }; +const FLAG: Codec = { + parse: (text) => text === 'true', + format: (value) => String(value) +}; + const POSITIVE_INTEGER: Codec = { parse: (text) => { const value = Number(text); @@ -69,11 +44,11 @@ const POSITIVE_INTEGER: Codec = { export class Playground { public readonly hostContext = persistedSignal( HOST_CONTEXT_STORAGE_KEY, - DEFAULT_HOST_CONTEXT, + DEFAULT_EXAMPLE.hostContext, TEXT ); - public readonly codeString = persistedSignal(CODE_STORAGE_KEY, DEFAULT_CODE, TEXT); + public readonly codeString = persistedSignal(CODE_STORAGE_KEY, DEFAULT_EXAMPLE.code, TEXT); public readonly maxOps = persistedSignal(MAX_OPS_STORAGE_KEY, DEFAULT_MAX_OPS, POSITIVE_INTEGER); @@ -83,10 +58,18 @@ export class Playground { POSITIVE_INTEGER ); - /** Restores every default, which also clears the values from storage. */ + /** Whether the visitor has closed the welcome dialog once; Reset leaves it alone. */ + public readonly welcomeSeen = persistedSignal(WELCOME_SEEN_STORAGE_KEY, false, FLAG); + + /** Replaces the host context and the script with an example's. */ + public load(example: Example): void { + this.hostContext.set(example.hostContext); + this.codeString.set(example.code); + } + + /** Restores every default but the welcome flag, which also clears the values from storage. */ public reset(): void { - this.hostContext.set(DEFAULT_HOST_CONTEXT); - this.codeString.set(DEFAULT_CODE); + this.load(DEFAULT_EXAMPLE); this.maxOps.set(DEFAULT_MAX_OPS); this.maxDepth.set(DEFAULT_MAX_DEPTH); } diff --git a/apps/maple-playground/src/app/data-access/project.ts b/apps/maple-playground/src/app/data-access/project.ts new file mode 100644 index 0000000..ede43c9 --- /dev/null +++ b/apps/maple-playground/src/app/data-access/project.ts @@ -0,0 +1,11 @@ +import { version } from '../../../../../packages/maple/package.json'; + +/** The version of the library the playground runs, read from its package.json at build time. */ +export const MAPLE_VERSION = version; + +/** Where Maple lives, for the header and the welcome dialog. */ +export const PROJECT_LINKS = { + github: 'https://github.com/NetWin/maple', + npm: 'https://www.npmjs.com/package/@netwin/maple', + docs: 'https://github.com/NetWin/maple/blob/master/packages/maple/docs/README.md' +} as const; diff --git a/apps/maple-playground/src/app/ui/examples-menu.html b/apps/maple-playground/src/app/ui/examples-menu.html new file mode 100644 index 0000000..cf0d02c --- /dev/null +++ b/apps/maple-playground/src/app/ui/examples-menu.html @@ -0,0 +1,47 @@ + + +
+

+ Examples +

+
    + @for (example of EXAMPLES; track example.title) { +
  • + +
  • + } +
+

+ Replaces the host context and the script. +

+
diff --git a/apps/maple-playground/src/app/ui/examples-menu.ts b/apps/maple-playground/src/app/ui/examples-menu.ts new file mode 100644 index 0000000..7331dfc --- /dev/null +++ b/apps/maple-playground/src/app/ui/examples-menu.ts @@ -0,0 +1,21 @@ +import { Component, output, viewChild, type ElementRef } from '@angular/core'; +import { EXAMPLES, type Example } from '../data-access/examples'; + +/** The examples behind a native popover anchored below its button; picking one closes it. */ +@Component({ + selector: 'maple-examples-menu', + templateUrl: './examples-menu.html', + host: { class: 'contents' } +}) +export class ExamplesMenu { + protected readonly EXAMPLES = EXAMPLES; + + public readonly pick = output(); + + protected readonly popover = viewChild.required>('popover'); + + protected choose(example: Example): void { + this.pick.emit(example); + this.popover().nativeElement.hidePopover(); + } +} diff --git a/apps/maple-playground/src/app/ui/settings-menu.html b/apps/maple-playground/src/app/ui/settings-menu.html index 0efe232..03d8676 100644 --- a/apps/maple-playground/src/app/ui/settings-menu.html +++ b/apps/maple-playground/src/app/ui/settings-menu.html @@ -19,7 +19,7 @@ cy="12" r="3" /> - Settings +
+ +
+
+

+ 🍁 + Maple {{ VERSION }} +

+

+ A secure JavaScript interpreter +

+

+ Maple runs a script itself instead of handing it to eval, so + the script only ever sees the values you pass in. Use it to let users write rules, formulas + or automations that run inside your application without reaching anything else. +

+
+ +
+
+

+ How it works +

+
    +
  1. + 1 +

    + Parse. + acorn turns the script into a syntax tree, which the AST tab shows. +

    +
  2. +
  3. + 2 +

    + Interpret. + Maple walks that tree node by node. Nothing is ever handed to + eval or Function. +

    +
  4. +
  5. + 3 +

    + Isolate. + The only names a script can reach are the ones the host passes in. Everything else, + from Math to Array, is + Maple's own. +

    +
  6. +
+ +

+ What a script can't do +

+
    +
  • + + Reach host globals such as window, + fetch or process +
  • +
  • + + Turn strings into code with eval or + Function +
  • +
  • + + Climb prototypes through constructor or + __proto__ +
  • +
  • + + Touch the DOM, storage, cookies or the network +
  • +
+
+ +
+

+ Reading the playground +

+
+
+
Host context
+
+ Trusted code standing in for your application. It runs natively and exports + INSTANCE_DATA, read-only and shared by every run, and + RUN_DATA, copied into each run. +
+
+
+
Script
+
+ The untrusted code Maple runs. It sees the keys of those two exports and nothing else, + and hands back its result with return. +
+
+
+
Output
+
+ The result or the error, the syntax tree, and a benchmark against another interpreter. + It updates as you type. +
+
+
+ +

+ Use it in your project +

+
{{ INSTALL }}
+

+ + GitHub + + + npm + + + Documentation + +

+
+
+ +
+

+ Pick another script under Examples, or edit either + side. +

+ +
+
+ diff --git a/apps/maple-playground/src/app/ui/welcome-dialog.ts b/apps/maple-playground/src/app/ui/welcome-dialog.ts new file mode 100644 index 0000000..7636d04 --- /dev/null +++ b/apps/maple-playground/src/app/ui/welcome-dialog.ts @@ -0,0 +1,39 @@ +import { Component, output, viewChild, type ElementRef } from '@angular/core'; +import { MAPLE_VERSION, PROJECT_LINKS } from '../data-access/project'; + +/** What Maple is and how to read the playground, in a native modal dialog. */ +@Component({ + selector: 'maple-welcome-dialog', + templateUrl: './welcome-dialog.html', + host: { class: 'contents' } +}) +export class WelcomeDialog { + protected readonly VERSION = MAPLE_VERSION; + + protected readonly LINKS = PROJECT_LINKS; + + protected readonly INSTALL = 'pnpm add @netwin/maple acorn'; + + /** Emitted however the dialog was closed: the button, Escape or a click on the backdrop. */ + public readonly closed = output(); + + protected readonly dialog = viewChild.required>('dialog'); + + protected readonly content = viewChild.required>('content'); + + public open(): void { + this.dialog().nativeElement.showModal(); + this.content().nativeElement.scrollTop = 0; + } + + protected close(): void { + this.dialog().nativeElement.close(); + } + + /** A click on the backdrop lands on the dialog element itself, never on its content. */ + protected closeOnBackdrop(event: MouseEvent): void { + if (event.target === this.dialog().nativeElement) { + this.close(); + } + } +} From ff678d021ce30764292f60c4a1ad507ae3d1e2bb Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 09:09:35 +0200 Subject: [PATCH 4/5] ci(playground): deploy the playground to GitHub Pages The pages job publishes the playground to https://netwin.github.io/maple/ after each release run on master, and the READMEs link to it. --- .github/workflows/push.yml | 29 +++++++++++++++++++++++++++++ README.md | 1 + apps/maple-playground/README.md | 2 +- packages/maple/README.md | 2 ++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 6650588..40e44d9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -78,6 +78,35 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Publishes the playground to https://netwin.github.io/maple/. It runs after the release + pages: + name: Deploy playground + needs: [release] + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write # upload the site + id-token: write # prove the deployment comes from this workflow + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + with: + ref: master + - uses: pnpm/setup@v3 + with: + cache: true + require-lockfile: true + # Pages serves the site below /maple/, so every URL the app builds must start there + - run: pnpm --filter @netwin/maple-playground build --base-href /maple/ + - uses: actions/upload-pages-artifact@v5 + with: + path: apps/maple-playground/dist/maple-playground/browser + - id: deployment + uses: actions/deploy-pages@v5 + # Brings the release commit and tag back to increment, where pull requests branch off, so their # changelog preview starts from the latest release sync: diff --git a/README.md b/README.md index 1396fc2..39a79ee 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Maple is a secure JavaScript interpreter written in TypeScript. It interprets a script itself instead of handing it to `eval`, so the script only ever sees the values you pass in. +Try it in the [playground](https://netwin.github.io/maple/), which runs Maple in your browser. Installation, usage and the API are in the [library's README](packages/maple/README.md), and the full contract is in its [docs](packages/maple/docs/README.md). diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index 41ac56b..dfdb7d2 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -2,7 +2,7 @@ A private Angular app for trying out Maple. It has editors for a host context and a script and shows the result, the AST and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is -stopped after 1 s. +stopped after 1 s. It is live at [netwin.github.io/maple](https://netwin.github.io/maple/). ```sh pnpm dev # from the repository root, serves the playground on :4200 diff --git a/packages/maple/README.md b/packages/maple/README.md index 130b4d9..15973bf 100644 --- a/packages/maple/README.md +++ b/packages/maple/README.md @@ -8,6 +8,8 @@ A secure JavaScript interpreter written in TypeScript. Maple parses a string of JavaScript using [acorn](https://github.com/acornjs/acorn) and interprets it itself instead of handing it to `eval`, so a script only ever sees the values you pass in. +Try it in the [playground](https://netwin.github.io/maple/), which runs Maple in your browser. + Secure means that a script cannot escape: it cannot reach host globals, `require` or the file system on Node, or `localStorage`, cookies or the DOM in a browser. It does not mean that a run's CPU time or memory is capped; run untrusted code in a worker you can terminate if you need that. From e5989492146ffb5050895702522f2cdd0097affc Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 09:30:47 +0200 Subject: [PATCH 5/5] docs(playground): stop calling the published playground private --- README.md | 2 +- apps/maple-playground/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 39a79ee..d43d261 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This is a pnpm workspace: | Path | Package | Description | | ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `packages/maple` | [`@netwin/maple`](packages/maple) | The published library (ESM, Node >= 22, peer dep: `acorn`) | -| `apps/maple-playground` | [`@netwin/maple-playground`](apps/maple-playground) | Private Angular app for trying out the interpreter | +| `apps/maple-playground` | [`@netwin/maple-playground`](apps/maple-playground) | Angular app for trying out the interpreter, [live on GitHub Pages](https://netwin.github.io/maple/) | | `packages/maple-parity` | [`@netwin/maple-parity`](packages/maple-parity) | Private parity tests: Maple adheres to JS standards and (for internal reasons) documents the parity with [JS-Interpreter](https://github.com/NetWin/js-interpreter) | ## Contributing diff --git a/apps/maple-playground/README.md b/apps/maple-playground/README.md index dfdb7d2..b13a7eb 100644 --- a/apps/maple-playground/README.md +++ b/apps/maple-playground/README.md @@ -1,6 +1,6 @@ # 🍁 Maple Playground -A private Angular app for trying out Maple. It has editors for a host context and a script and shows the result, the +An Angular app for trying out Maple. It has editors for a host context and a script and shows the result, the AST and the run's statistics live. Maple runs in a web worker, so an endless loop can't freeze the page; a live run is stopped after 1 s. It is live at [netwin.github.io/maple](https://netwin.github.io/maple/).