@opentf/web 0.4.0 → 0.6.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/README.md +30 -28
- package/components/Link.jsx +43 -0
- package/core/errors.js +48 -0
- package/core/reactive.js +190 -0
- package/core/signals.js +207 -0
- package/index.js +13 -0
- package/package.json +27 -18
- package/runtime/clipboard.js +47 -0
- package/runtime/code-block.js +47 -0
- package/runtime/context.js +99 -0
- package/runtime/dom.js +368 -0
- package/runtime/error-boundary.js +109 -0
- package/runtime/events.js +17 -0
- package/runtime/hydrate.js +121 -0
- package/runtime/index.js +22 -0
- package/runtime/lifecycle.js +9 -0
- package/runtime/mount.js +49 -0
- package/runtime/portal.js +59 -0
- package/runtime/raw-html.js +33 -0
- package/runtime/router.js +392 -0
- package/server/builtins.js +16 -0
- package/server/head.js +191 -0
- package/server/index.js +8 -0
- package/server/render.js +98 -0
- package/server/ssg-runtime.js +112 -0
- package/dist/CHANGELOG.md +0 -49
- package/dist/README.md +0 -41
- package/dist/babel.config.cjs +0 -6
- package/dist/compiler/babel-plugin.cjs +0 -798
- package/dist/core/mount.js +0 -32
- package/dist/index.js +0 -7
- package/dist/node_modules/@babel/core/LICENSE +0 -22
- package/dist/node_modules/@babel/core/README.md +0 -19
- package/dist/node_modules/@babel/core/lib/config/cache-contexts.js +0 -5
- package/dist/node_modules/@babel/core/lib/config/cache-contexts.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/caching.js +0 -261
- package/dist/node_modules/@babel/core/lib/config/caching.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/config-chain.js +0 -469
- package/dist/node_modules/@babel/core/lib/config/config-chain.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/config-descriptors.js +0 -190
- package/dist/node_modules/@babel/core/lib/config/config-descriptors.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/configuration.js +0 -290
- package/dist/node_modules/@babel/core/lib/config/files/configuration.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/import.cjs +0 -6
- package/dist/node_modules/@babel/core/lib/config/files/import.cjs.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/index-browser.js +0 -58
- package/dist/node_modules/@babel/core/lib/config/files/index-browser.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/index.js +0 -78
- package/dist/node_modules/@babel/core/lib/config/files/index.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/module-types.js +0 -203
- package/dist/node_modules/@babel/core/lib/config/files/module-types.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/package.js +0 -61
- package/dist/node_modules/@babel/core/lib/config/files/package.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/plugins.js +0 -220
- package/dist/node_modules/@babel/core/lib/config/files/plugins.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/types.js +0 -5
- package/dist/node_modules/@babel/core/lib/config/files/types.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/files/utils.js +0 -36
- package/dist/node_modules/@babel/core/lib/config/files/utils.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/full.js +0 -312
- package/dist/node_modules/@babel/core/lib/config/full.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js +0 -85
- package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js +0 -23
- package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/helpers/environment.js +0 -12
- package/dist/node_modules/@babel/core/lib/config/helpers/environment.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/index.js +0 -87
- package/dist/node_modules/@babel/core/lib/config/index.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/item.js +0 -67
- package/dist/node_modules/@babel/core/lib/config/item.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/partial.js +0 -158
- package/dist/node_modules/@babel/core/lib/config/partial.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js +0 -38
- package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/plugin.js +0 -33
- package/dist/node_modules/@babel/core/lib/config/plugin.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/printer.js +0 -113
- package/dist/node_modules/@babel/core/lib/config/printer.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js +0 -41
- package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/resolve-targets.js +0 -61
- package/dist/node_modules/@babel/core/lib/config/resolve-targets.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/util.js +0 -31
- package/dist/node_modules/@babel/core/lib/config/util.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js +0 -277
- package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/validation/options.js +0 -187
- package/dist/node_modules/@babel/core/lib/config/validation/options.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/validation/plugins.js +0 -67
- package/dist/node_modules/@babel/core/lib/config/validation/plugins.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/config/validation/removed.js +0 -68
- package/dist/node_modules/@babel/core/lib/config/validation/removed.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/errors/config-error.js +0 -18
- package/dist/node_modules/@babel/core/lib/errors/config-error.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js +0 -98
- package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/gensync-utils/async.js +0 -90
- package/dist/node_modules/@babel/core/lib/gensync-utils/async.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js +0 -31
- package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js +0 -58
- package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/index.js +0 -230
- package/dist/node_modules/@babel/core/lib/index.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/parse.js +0 -45
- package/dist/node_modules/@babel/core/lib/parse.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/parser/index.js +0 -85
- package/dist/node_modules/@babel/core/lib/parser/index.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js +0 -337
- package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js +0 -144
- package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transform-ast.js +0 -48
- package/dist/node_modules/@babel/core/lib/transform-ast.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transform-file-browser.js +0 -23
- package/dist/node_modules/@babel/core/lib/transform-file-browser.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transform-file.js +0 -40
- package/dist/node_modules/@babel/core/lib/transform-file.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transform.js +0 -47
- package/dist/node_modules/@babel/core/lib/transform.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js +0 -84
- package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs +0 -4
- package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/file/file.js +0 -204
- package/dist/node_modules/@babel/core/lib/transformation/file/file.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/file/generate.js +0 -84
- package/dist/node_modules/@babel/core/lib/transformation/file/generate.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js +0 -37
- package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/index.js +0 -90
- package/dist/node_modules/@babel/core/lib/transformation/index.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js +0 -127
- package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js +0 -59
- package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js +0 -48
- package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js +0 -54
- package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js.map +0 -1
- package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js +0 -1042
- package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js.map +0 -1
- package/dist/node_modules/@babel/core/package.json +0 -82
- package/dist/node_modules/@babel/core/src/config/files/index-browser.ts +0 -115
- package/dist/node_modules/@babel/core/src/config/files/index.ts +0 -30
- package/dist/node_modules/@babel/core/src/config/resolve-targets-browser.ts +0 -42
- package/dist/node_modules/@babel/core/src/config/resolve-targets.ts +0 -54
- package/dist/node_modules/@babel/core/src/transform-file-browser.ts +0 -33
- package/dist/node_modules/@babel/core/src/transform-file.ts +0 -56
- package/dist/node_modules/@babel/helper-module-imports/LICENSE +0 -22
- package/dist/node_modules/@babel/helper-module-imports/README.md +0 -19
- package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js +0 -122
- package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js.map +0 -1
- package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js +0 -304
- package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js.map +0 -1
- package/dist/node_modules/@babel/helper-module-imports/lib/index.js +0 -37
- package/dist/node_modules/@babel/helper-module-imports/lib/index.js.map +0 -1
- package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js +0 -11
- package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js.map +0 -1
- package/dist/node_modules/@babel/helper-module-imports/package.json +0 -28
- package/dist/node_modules/@babel/plugin-syntax-jsx/LICENSE +0 -22
- package/dist/node_modules/@babel/plugin-syntax-jsx/README.md +0 -19
- package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js +0 -21
- package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js.map +0 -1
- package/dist/node_modules/@babel/plugin-syntax-jsx/package.json +0 -33
- package/dist/node_modules/@preact/signals-core/CHANGELOG.md +0 -331
- package/dist/node_modules/@preact/signals-core/LICENSE +0 -21
- package/dist/node_modules/@preact/signals-core/README.md +0 -258
- package/dist/node_modules/@preact/signals-core/dist/signals-core.d.ts +0 -149
- package/dist/node_modules/@preact/signals-core/dist/signals-core.js +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.js.map +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js.map +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs.map +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js +0 -1
- package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js.map +0 -1
- package/dist/node_modules/@preact/signals-core/package.json +0 -48
- package/dist/node_modules/@preact/signals-core/src/index.ts +0 -1121
- package/dist/package.json +0 -30
- package/dist/router/Link.js +0 -73
- package/dist/router/index.js +0 -216
- package/dist/runtime/For.js +0 -16
- package/dist/runtime/dom.js +0 -73
- package/dist/runtime/lifecycle.js +0 -25
- package/dist/runtime/props.js +0 -103
package/README.md
CHANGED
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
# @opentf/web
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The native-first **OTF Web** runtime — signal-based reactivity and zero-VDOM DOM
|
|
4
|
+
operations, paired with the IR-based compiler ([`@opentf/web-compiler`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/web-compiler)).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- 📦 **Web Component Native**: Every component is a standard Custom Element, ensuring perfect encapsulation and interoperability.
|
|
10
|
-
- 🛠️ **Convention-over-Configuration**: File-based routing and automatic component registration.
|
|
11
|
-
- 📏 **Ultra-Lightweight**: Minimal runtime footprint.
|
|
6
|
+
You normally don't call this API by hand. You write `.jsx` components; the compiler
|
|
7
|
+
lowers them to plain DOM code that imports its helpers (`signal`, `computed`,
|
|
8
|
+
`bindText`, `bindList`, …) from this package. Components compile to self-registering
|
|
9
|
+
`web-*` Custom Elements.
|
|
12
10
|
|
|
13
11
|
## Installation
|
|
14
12
|
|
|
15
13
|
```bash
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Basic Usage
|
|
20
|
-
|
|
21
|
-
```jsx
|
|
22
|
-
export default function Counter() {
|
|
23
|
-
let count = $state(0);
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<div className="counter">
|
|
27
|
-
<button onclick={() => count--}>-</button>
|
|
28
|
-
<span>{count}</span>
|
|
29
|
-
<button onclick={() => count++}>+</button>
|
|
30
|
-
</div>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
14
|
+
bun add @opentf/web
|
|
33
15
|
```
|
|
34
16
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
17
|
+
Scaffold a ready-to-run app with [`@opentf/create-web`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/create-web)
|
|
18
|
+
and drive it with [`@opentf/web-cli`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/web-cli).
|
|
19
|
+
|
|
20
|
+
## What's inside
|
|
21
|
+
|
|
22
|
+
- **Reactivity** — a from-scratch `signal` / `computed` / `effect` core.
|
|
23
|
+
- **DOM runtime** — fine-grained bindings (`bindText`, `bindAttr`, `bindList`,
|
|
24
|
+
`bindChild`, `setProp`, `spread`, `clsx`) the compiler targets, plus `Context`,
|
|
25
|
+
`Portal`, `ErrorBoundary`, and event dispatch (`emit`).
|
|
26
|
+
- **Compiler macros** — authored in your components, resolved at compile time:
|
|
27
|
+
`$state`, `$derived`, `$ref`, `$context`, `$effect`, `$expose`, and the
|
|
28
|
+
`onMount` / `onCleanup` lifecycle hooks.
|
|
29
|
+
- **`<Link>`** — client-side navigation, shipped as JSX source and compiled by your
|
|
30
|
+
app's pipeline.
|
|
31
|
+
|
|
32
|
+
## Entry points
|
|
33
|
+
|
|
34
|
+
| Import | Contents |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `@opentf/web` | runtime + reactivity + `Link` (what compiled components import) |
|
|
37
|
+
| `@opentf/web/signals` | the reactivity core on its own |
|
|
38
|
+
| `@opentf/web/runtime` | DOM helpers, Context, Portal, ErrorBoundary |
|
|
39
|
+
| `@opentf/web/server` | string-composed render helpers for SSG |
|
|
38
40
|
|
|
39
41
|
## License
|
|
40
42
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// <Link href> — client-side (SPA) navigation.
|
|
2
|
+
//
|
|
3
|
+
// A *pure JSX component*, shipped as source and compiled by the consuming app's
|
|
4
|
+
// pipeline (like a UI library ships components for the user's bundler). The
|
|
5
|
+
// framework dogfoods its own JSX → Custom Element model here instead of a
|
|
6
|
+
// hand-written HTMLElement class — so it gets reactive props, the children slot,
|
|
7
|
+
// and a matching SSG renderer for free, with no separate runtime build step.
|
|
8
|
+
//
|
|
9
|
+
// `class` is a declared prop, so it lands on the rendered <a> (the link), not on
|
|
10
|
+
// the <web-link> host — no duplicated styling.
|
|
11
|
+
|
|
12
|
+
import { localizePath, navigate, shouldInterceptNav } from "../runtime/router.js";
|
|
13
|
+
|
|
14
|
+
export default function Link({ href, class: className, reload, children }) {
|
|
15
|
+
// Keep the link in the active locale (no-op when i18n is off). Locale is a
|
|
16
|
+
// per-load constant under URL-prefix routing, so resolving it once here is
|
|
17
|
+
// correct — a navigation that changes the locale rebuilds this component.
|
|
18
|
+
const target = localizePath(href);
|
|
19
|
+
// `reload` (a bare attribute → "") forces a full-page navigation for this link.
|
|
20
|
+
// Absent resolves to null (getAttribute), so use loose `!= null` to treat only a
|
|
21
|
+
// present, non-false value as opting out.
|
|
22
|
+
const forceReload = reload != null && reload !== false;
|
|
23
|
+
|
|
24
|
+
const onclick = (e) => {
|
|
25
|
+
// Let the browser handle modified clicks (new tab, etc.) and non-primary buttons.
|
|
26
|
+
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Full-page navigation when this link opts out (`reload`) or the app runs in MPA
|
|
30
|
+
// mode (docs/HYDRATION.md §7) — leave the click to the browser.
|
|
31
|
+
if (forceReload || !shouldInterceptNav()) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
navigate(target);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<a href={target} class={className} onclick={onclick}>
|
|
40
|
+
{children}
|
|
41
|
+
</a>
|
|
42
|
+
);
|
|
43
|
+
}
|
package/core/errors.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//! Central error reporting for the OTF Web runtime.
|
|
2
|
+
//
|
|
3
|
+
// Render, effect, lifecycle, and event errors are funneled here instead of being
|
|
4
|
+
// thrown into the void. Userland (and the dev overlay) subscribe via `onError`;
|
|
5
|
+
// in the browser every report is also dispatched as an `otfw:error` window event
|
|
6
|
+
// so a fully decoupled listener (e.g. the dev server's injected overlay) can show
|
|
7
|
+
// it without importing the bundle.
|
|
8
|
+
|
|
9
|
+
const handlers = new Set();
|
|
10
|
+
const isBrowser = typeof window !== "undefined";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to runtime errors. `handler(error, context)` is called for every
|
|
14
|
+
* `reportError`. Returns a disposer that unsubscribes.
|
|
15
|
+
*/
|
|
16
|
+
export function onError(handler) {
|
|
17
|
+
handlers.add(handler);
|
|
18
|
+
return () => handlers.delete(handler);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Report a caught error. `context` carries where it happened
|
|
23
|
+
* (`{ phase: "render" | "effect" | "mount" | "event" | "route", … }`).
|
|
24
|
+
* Notifies subscribers, emits the `otfw:error` window event, and — if nobody is
|
|
25
|
+
* listening — logs to the console so errors are never silently swallowed.
|
|
26
|
+
*/
|
|
27
|
+
export function reportError(error, context = {}) {
|
|
28
|
+
for (const handler of handlers) {
|
|
29
|
+
try {
|
|
30
|
+
handler(error, context);
|
|
31
|
+
} catch {
|
|
32
|
+
// A failing handler must not mask the original error.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (isBrowser) {
|
|
36
|
+
window.dispatchEvent(new CustomEvent("otfw:error", { detail: { error, context } }));
|
|
37
|
+
}
|
|
38
|
+
if (handlers.size === 0) {
|
|
39
|
+
console.error(`[otfw${context.phase ? `:${context.phase}` : ""}]`, error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Signal that the error condition has cleared (e.g. a successful navigation). */
|
|
44
|
+
export function clearError(context = {}) {
|
|
45
|
+
if (isBrowser) {
|
|
46
|
+
window.dispatchEvent(new CustomEvent("otfw:error-clear", { detail: context }));
|
|
47
|
+
}
|
|
48
|
+
}
|
package/core/reactive.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
//! Deep reactive store — a fine-grained, signal-backed reactive object.
|
|
2
|
+
//
|
|
3
|
+
// `reactive(obj)` returns a deep proxy over plain data. Reading a property
|
|
4
|
+
// inside an effect/computed subscribes to *just that path*; writing a property
|
|
5
|
+
// notifies *only* the effects that read it. Object/array nodes are wrapped
|
|
6
|
+
// lazily on access, so a 100-field store costs nothing until a field is touched.
|
|
7
|
+
//
|
|
8
|
+
// This is the public primitive SPEC §1.1 requires to live in `@opentf/web`:
|
|
9
|
+
// apps `import { reactive }` and read values directly (no provider object), and
|
|
10
|
+
// `@opentf/web-form` builds its values/errors trees on top of it instead of a
|
|
11
|
+
// private proxy. It is forward-compatible with the `$signal()` compiler bridge
|
|
12
|
+
// (SPEC §3.5): each node is itself reactive, so a future First-Access unwrap can
|
|
13
|
+
// read `store.user.value` without changing this runtime.
|
|
14
|
+
//
|
|
15
|
+
// Model (mirrors the signals core, one level up):
|
|
16
|
+
// - per *key* signal → fine-grained leaf subscription (read `a.b.c`)
|
|
17
|
+
// - per *node* "struct" signal → bumped on key add/remove so `in`, `ownKeys`,
|
|
18
|
+
// iteration and array `length` stay reactive.
|
|
19
|
+
// State is mutated in place (the store owns it); reads always go through the
|
|
20
|
+
// proxy, so callers never observe the raw object directly. No per-write tree
|
|
21
|
+
// clone, no broadcast — the two costs the old form proxy paid on every keystroke.
|
|
22
|
+
|
|
23
|
+
import { signal, untracked, batch } from "./signals.js";
|
|
24
|
+
|
|
25
|
+
/** Unwrap a reactive proxy back to its underlying plain object. */
|
|
26
|
+
const RAW = Symbol.for("otfw.reactive.raw");
|
|
27
|
+
/** Brand a proxy so `isReactive` is a cheap, collision-free check. */
|
|
28
|
+
const IS_REACTIVE = Symbol.for("otfw.reactive.is");
|
|
29
|
+
|
|
30
|
+
// Per raw-object bookkeeping, kept off the data itself so the data stays clean.
|
|
31
|
+
const proxyCache = new WeakMap(); // raw object → its proxy (stable identity)
|
|
32
|
+
const keySignals = new WeakMap(); // raw object → Map<key, signal(value)>
|
|
33
|
+
const structSignals = new WeakMap(); // raw object → signal(int) bumped on shape change
|
|
34
|
+
|
|
35
|
+
// Array methods that mutate in place; routed through a `batch` so the many
|
|
36
|
+
// internal index/length writes collapse into a single notification.
|
|
37
|
+
const ARRAY_MUTATORS = new Set([
|
|
38
|
+
"push", "pop", "shift", "unshift", "splice", "reverse", "sort", "fill", "copyWithin",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Only plain objects and arrays are wrapped; class instances, Dates, etc. pass through. */
|
|
42
|
+
function isWrappable(v) {
|
|
43
|
+
if (v === null || typeof v !== "object") return false;
|
|
44
|
+
if (Array.isArray(v)) return true;
|
|
45
|
+
const proto = Object.getPrototypeOf(v);
|
|
46
|
+
return proto === Object.prototype || proto === null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getKeySig(target, key) {
|
|
50
|
+
let map = keySignals.get(target);
|
|
51
|
+
if (!map) keySignals.set(target, (map = new Map()));
|
|
52
|
+
let sig = map.get(key);
|
|
53
|
+
if (!sig) map.set(key, (sig = signal(target[key])));
|
|
54
|
+
return sig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getStructSig(target) {
|
|
58
|
+
let sig = structSignals.get(target);
|
|
59
|
+
if (!sig) structSignals.set(target, (sig = signal(0)));
|
|
60
|
+
return sig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Strip a reactive wrapper from a value before it is stored. */
|
|
64
|
+
function toRaw(v) {
|
|
65
|
+
return v && typeof v === "object" && v[RAW] !== undefined ? v[RAW] : v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const handler = {
|
|
69
|
+
get(target, key, receiver) {
|
|
70
|
+
if (key === RAW) return target;
|
|
71
|
+
if (key === IS_REACTIVE) return true;
|
|
72
|
+
|
|
73
|
+
if (typeof key === "symbol") {
|
|
74
|
+
// Spreading/iterating walks length+indices through the proxy, but the
|
|
75
|
+
// iterator lookup itself should subscribe to shape so a re-spread re-runs.
|
|
76
|
+
if (key === Symbol.iterator) getStructSig(target).value;
|
|
77
|
+
return Reflect.get(target, key, receiver);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `length` is structural for arrays — push/splice change it.
|
|
81
|
+
if (key === "length" && Array.isArray(target)) {
|
|
82
|
+
getStructSig(target).value;
|
|
83
|
+
return target.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const value = target[key];
|
|
87
|
+
|
|
88
|
+
if (typeof value === "function") {
|
|
89
|
+
if (Array.isArray(target)) {
|
|
90
|
+
// Mutators re-enter the `set` trap via `receiver`; batch their internal
|
|
91
|
+
// writes so an effect re-runs once.
|
|
92
|
+
if (ARRAY_MUTATORS.has(key)) {
|
|
93
|
+
return (...args) => batch(() => value.apply(receiver, args));
|
|
94
|
+
}
|
|
95
|
+
// Read methods (map/filter/forEach/…) subscribe to *structure* only and
|
|
96
|
+
// run over the raw array, so they re-run when the list grows/shrinks but
|
|
97
|
+
// not when an individual element's value changes. This is what keeps a
|
|
98
|
+
// `list.map(i => <input/>)` from re-rendering (and dropping focus) on
|
|
99
|
+
// every keystroke — the element's own binding handles its value.
|
|
100
|
+
getStructSig(target).value;
|
|
101
|
+
return value.bind(target);
|
|
102
|
+
}
|
|
103
|
+
return value.bind(receiver);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getKeySig(target, key).value; // subscribe to this leaf/branch
|
|
107
|
+
return isWrappable(value) ? wrap(value) : value;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
set(target, key, value) {
|
|
111
|
+
const raw = toRaw(value);
|
|
112
|
+
|
|
113
|
+
if (key === "length" && Array.isArray(target)) {
|
|
114
|
+
if (target.length !== raw) {
|
|
115
|
+
target.length = raw;
|
|
116
|
+
getStructSig(target).value++;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
122
|
+
if (had && Object.is(target[key], raw)) return true;
|
|
123
|
+
|
|
124
|
+
target[key] = raw;
|
|
125
|
+
const map = keySignals.get(target);
|
|
126
|
+
const sig = map && map.get(key);
|
|
127
|
+
if (sig) sig.value = raw; // notify only if someone is listening
|
|
128
|
+
if (!had) getStructSig(target).value++; // new key → shape changed
|
|
129
|
+
return true;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
deleteProperty(target, key) {
|
|
133
|
+
if (!Object.prototype.hasOwnProperty.call(target, key)) return true;
|
|
134
|
+
delete target[key];
|
|
135
|
+
const map = keySignals.get(target);
|
|
136
|
+
const sig = map && map.get(key);
|
|
137
|
+
if (sig) sig.value = undefined;
|
|
138
|
+
getStructSig(target).value++;
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
has(target, key) {
|
|
143
|
+
getStructSig(target).value;
|
|
144
|
+
return Reflect.has(target, key);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
ownKeys(target) {
|
|
148
|
+
getStructSig(target).value;
|
|
149
|
+
return Reflect.ownKeys(target);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function wrap(target) {
|
|
154
|
+
const cached = proxyCache.get(target);
|
|
155
|
+
if (cached) return cached;
|
|
156
|
+
const proxy = new Proxy(target, handler);
|
|
157
|
+
proxyCache.set(target, proxy);
|
|
158
|
+
return proxy;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a deep reactive store over a plain object/array. Property reads inside
|
|
163
|
+
* an effect subscribe fine-grained; writes (`s.a.b = x`, `s.items.push(y)`)
|
|
164
|
+
* notify only dependents of the touched paths.
|
|
165
|
+
*/
|
|
166
|
+
export function reactive(init = {}) {
|
|
167
|
+
if (!isWrappable(init)) {
|
|
168
|
+
throw new TypeError("reactive() expects a plain object or array");
|
|
169
|
+
}
|
|
170
|
+
return wrap(init);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether `v` is a store created by `reactive()`. */
|
|
174
|
+
export function isReactive(v) {
|
|
175
|
+
return !!(v && typeof v === "object" && v[IS_REACTIVE]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The underlying plain object of a store (live, not a copy); identity for non-stores. */
|
|
179
|
+
export function toRawValue(v) {
|
|
180
|
+
return isReactive(v) ? v[RAW] : v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A plain, non-reactive deep copy of a store's current value — for submit
|
|
185
|
+
* payloads, validation input, or persistence. Reads are untracked, so taking a
|
|
186
|
+
* snapshot never subscribes the caller.
|
|
187
|
+
*/
|
|
188
|
+
export function snapshot(v) {
|
|
189
|
+
return untracked(() => structuredClone(toRawValue(v)));
|
|
190
|
+
}
|
package/core/signals.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
//! Fine-grained reactivity — our own signals core.
|
|
2
|
+
//
|
|
3
|
+
// This replaces the previous `@preact/signals-core` dependency to remove a
|
|
4
|
+
// third-party supply-chain risk (SPEC §1.1). The public surface is the
|
|
5
|
+
// industry-standard `signal` / `computed` / `effect` with a `.value` accessor;
|
|
6
|
+
// more APIs (peek/untracked/subscribe) are added only when a consumer needs them.
|
|
7
|
+
//
|
|
8
|
+
// Model: producers (signals, computeds) hold a set of subscribers; consumers
|
|
9
|
+
// (effects, computeds) track the producers they read. Reads register a
|
|
10
|
+
// dependency on the active consumer; writes mark dependent computeds stale and
|
|
11
|
+
// queue dependent effects, which run once per change/batch (glitch-free: an
|
|
12
|
+
// effect that depends on a signal both directly and via a computed runs once).
|
|
13
|
+
|
|
14
|
+
import { reportError } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
/** The consumer (effect or computed) currently executing, if any. */
|
|
17
|
+
let activeConsumer = null;
|
|
18
|
+
/** Depth of nested `batch()` calls; effects flush when this returns to 0. */
|
|
19
|
+
let batchDepth = 0;
|
|
20
|
+
/** Re-entrancy guard for the flush loop. */
|
|
21
|
+
let flushing = false;
|
|
22
|
+
/** Effects queued to run on the next flush. A Set so each runs at most once. */
|
|
23
|
+
const pendingEffects = new Set();
|
|
24
|
+
|
|
25
|
+
/** Register `producer` as a dependency of the consumer currently running. */
|
|
26
|
+
function track(producer) {
|
|
27
|
+
if (activeConsumer) {
|
|
28
|
+
activeConsumer.sources.add(producer);
|
|
29
|
+
producer.subscribers.add(activeConsumer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Detach a consumer from all producers it previously read. */
|
|
34
|
+
function clearSources(consumer) {
|
|
35
|
+
for (const source of consumer.sources) {
|
|
36
|
+
source.subscribers.delete(consumer);
|
|
37
|
+
}
|
|
38
|
+
consumer.sources.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Mark dependents stale: computeds recursively, effects into the run queue. */
|
|
42
|
+
function propagate(producer) {
|
|
43
|
+
for (const sub of producer.subscribers) {
|
|
44
|
+
if (sub.kind === "computed") {
|
|
45
|
+
if (!sub.stale) {
|
|
46
|
+
sub.stale = true;
|
|
47
|
+
propagate(sub);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
pendingEffects.add(sub);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run all queued effects, including any they queue in turn. */
|
|
56
|
+
function flush() {
|
|
57
|
+
if (flushing) return;
|
|
58
|
+
flushing = true;
|
|
59
|
+
try {
|
|
60
|
+
while (pendingEffects.size > 0) {
|
|
61
|
+
const batch = [...pendingEffects];
|
|
62
|
+
pendingEffects.clear();
|
|
63
|
+
for (const effectNode of batch) {
|
|
64
|
+
if (effectNode.disposed) continue;
|
|
65
|
+
// A throwing reactive update is reported but must not break the scheduler
|
|
66
|
+
// (other queued effects still run). Build-time (initial) runs propagate so
|
|
67
|
+
// the surrounding component/page catch can handle them.
|
|
68
|
+
try {
|
|
69
|
+
effectNode.run();
|
|
70
|
+
} catch (e) {
|
|
71
|
+
reportError(e, { phase: "effect" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
flushing = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A writable reactive value. Reading `.value` inside an effect/computed
|
|
82
|
+
* subscribes to it; writing a changed `.value` notifies dependents.
|
|
83
|
+
*/
|
|
84
|
+
export function signal(initial) {
|
|
85
|
+
const node = { kind: "signal", value: initial, subscribers: new Set() };
|
|
86
|
+
return {
|
|
87
|
+
get value() {
|
|
88
|
+
track(node);
|
|
89
|
+
return node.value;
|
|
90
|
+
},
|
|
91
|
+
set value(next) {
|
|
92
|
+
if (Object.is(next, node.value)) return;
|
|
93
|
+
node.value = next;
|
|
94
|
+
propagate(node);
|
|
95
|
+
if (batchDepth === 0) flush();
|
|
96
|
+
},
|
|
97
|
+
/** Read the current value without subscribing the active consumer. */
|
|
98
|
+
peek() {
|
|
99
|
+
return node.value;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run `fn` without subscribing the active consumer to any signals it reads.
|
|
106
|
+
* Use inside an effect/computed to read reactive state without depending on it
|
|
107
|
+
* (e.g. validation that reads many fields but should not re-run on each).
|
|
108
|
+
*/
|
|
109
|
+
export function untracked(fn) {
|
|
110
|
+
const prev = activeConsumer;
|
|
111
|
+
activeConsumer = null;
|
|
112
|
+
try {
|
|
113
|
+
return fn();
|
|
114
|
+
} finally {
|
|
115
|
+
activeConsumer = prev;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A derived, read-only reactive value. Lazy and cached: the compute function
|
|
121
|
+
* runs only when `.value` is read while stale, and recomputes only after a
|
|
122
|
+
* dependency changes.
|
|
123
|
+
*/
|
|
124
|
+
export function computed(fn) {
|
|
125
|
+
const node = {
|
|
126
|
+
kind: "computed",
|
|
127
|
+
fn,
|
|
128
|
+
value: undefined,
|
|
129
|
+
stale: true,
|
|
130
|
+
sources: new Set(),
|
|
131
|
+
subscribers: new Set(),
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
get value() {
|
|
135
|
+
if (node.stale) {
|
|
136
|
+
const prev = activeConsumer;
|
|
137
|
+
clearSources(node);
|
|
138
|
+
activeConsumer = node;
|
|
139
|
+
try {
|
|
140
|
+
node.value = node.fn();
|
|
141
|
+
node.stale = false;
|
|
142
|
+
} finally {
|
|
143
|
+
activeConsumer = prev;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
track(node);
|
|
147
|
+
return node.value;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run `fn` immediately, tracking the signals it reads, and re-run it whenever
|
|
154
|
+
* any of them change. `fn` may return a cleanup function, which runs before the
|
|
155
|
+
* next re-run and on dispose. Returns a disposer that stops the effect.
|
|
156
|
+
*/
|
|
157
|
+
export function effect(fn) {
|
|
158
|
+
const node = {
|
|
159
|
+
kind: "effect",
|
|
160
|
+
fn,
|
|
161
|
+
cleanup: undefined,
|
|
162
|
+
sources: new Set(),
|
|
163
|
+
disposed: false,
|
|
164
|
+
run() {
|
|
165
|
+
if (node.disposed) return;
|
|
166
|
+
runCleanup(node);
|
|
167
|
+
clearSources(node);
|
|
168
|
+
const prev = activeConsumer;
|
|
169
|
+
activeConsumer = node;
|
|
170
|
+
try {
|
|
171
|
+
const result = node.fn();
|
|
172
|
+
if (typeof result === "function") node.cleanup = result;
|
|
173
|
+
} finally {
|
|
174
|
+
activeConsumer = prev;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
node.run();
|
|
179
|
+
return function dispose() {
|
|
180
|
+
if (node.disposed) return;
|
|
181
|
+
node.disposed = true;
|
|
182
|
+
runCleanup(node);
|
|
183
|
+
clearSources(node);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function runCleanup(node) {
|
|
188
|
+
if (node.cleanup) {
|
|
189
|
+
const fn = node.cleanup;
|
|
190
|
+
node.cleanup = undefined;
|
|
191
|
+
fn();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Group multiple writes so dependent effects run once after the outermost batch
|
|
197
|
+
* completes, instead of after each write.
|
|
198
|
+
*/
|
|
199
|
+
export function batch(fn) {
|
|
200
|
+
batchDepth++;
|
|
201
|
+
try {
|
|
202
|
+
return fn();
|
|
203
|
+
} finally {
|
|
204
|
+
batchDepth--;
|
|
205
|
+
if (batchDepth === 0) flush();
|
|
206
|
+
}
|
|
207
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Public entry for the OTF Web runtime: reactivity + DOM operations.
|
|
2
|
+
// CSR-generated modules import their helpers (signal/computed/bindText/…) from here.
|
|
3
|
+
export * from "./core/signals.js";
|
|
4
|
+
export * from "./core/reactive.js";
|
|
5
|
+
export * from "./core/errors.js";
|
|
6
|
+
export * from "./runtime/index.js";
|
|
7
|
+
// `Link` is shipped as JSX source and compiled by the consuming app's pipeline
|
|
8
|
+
// (the `.jsx` lands in the module graph and otfwc transforms it). Its compiled
|
|
9
|
+
// module self-registers `web-link` (CSR `customElements.define` / SSG `defineSSG`).
|
|
10
|
+
// The bare import forces that registration to be retained — `<Link>` is used as a
|
|
11
|
+
// tag, so the `Link` binding alone would be tree-shaken.
|
|
12
|
+
import "./components/Link.jsx";
|
|
13
|
+
export { default as Link } from "./components/Link.jsx";
|
package/package.json
CHANGED
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"module": "dist/index.js",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "The native-first OTF Web runtime — signal-based reactivity and zero-VDOM DOM operations, paired with the IR-based compiler.",
|
|
7
5
|
"type": "module",
|
|
8
|
-
"scripts": {
|
|
9
|
-
"build": "babel . --out-dir dist --extensions .js,.jsx --copy-files --config-file ./babel.config.cjs --ignore \"node_modules,dist,package.json,bun.lock,*.md\"",
|
|
10
|
-
"prepublishOnly": "npm run build"
|
|
11
|
-
},
|
|
12
6
|
"exports": {
|
|
13
|
-
".": "./
|
|
14
|
-
"./
|
|
15
|
-
"./runtime": "./
|
|
16
|
-
"./
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./signals": "./core/signals.js",
|
|
9
|
+
"./runtime": "./runtime/index.js",
|
|
10
|
+
"./server": "./server/index.js"
|
|
17
11
|
},
|
|
18
12
|
"files": [
|
|
19
|
-
"
|
|
13
|
+
"index.js",
|
|
14
|
+
"core",
|
|
15
|
+
"runtime",
|
|
16
|
+
"components",
|
|
17
|
+
"server",
|
|
18
|
+
"!**/*.test.js"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"opentf",
|
|
22
|
+
"web-components",
|
|
23
|
+
"signals",
|
|
24
|
+
"reactivity",
|
|
25
|
+
"runtime",
|
|
26
|
+
"ssg"
|
|
20
27
|
],
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"@babel/plugin-syntax-jsx": "^7.28.6"
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/Open-Tech-Foundation/Web-App-Framework.git",
|
|
31
|
+
"directory": "packages/web"
|
|
26
32
|
},
|
|
33
|
+
"homepage": "https://github.com/Open-Tech-Foundation/Web-App-Framework#readme",
|
|
34
|
+
"bugs": "https://github.com/Open-Tech-Foundation/Web-App-Framework/issues",
|
|
35
|
+
"license": "MIT",
|
|
27
36
|
"publishConfig": {
|
|
28
37
|
"access": "public"
|
|
29
38
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//! Clipboard helpers for "copy" affordances (code blocks, etc.).
|
|
2
|
+
//
|
|
3
|
+
// Behavior travels with the component that needs it — these are imported by the
|
|
4
|
+
// `web-internal-code-block` built-in and by `@opentf/web-docs`'s `CodeBlock`, so a
|
|
5
|
+
// copy button works wherever it's rendered, with no cooperation from an ancestor
|
|
6
|
+
// layout. `copyText` prefers the async Clipboard API and falls back to a hidden
|
|
7
|
+
// `<textarea>` + `execCommand` for non-secure contexts / older browsers.
|
|
8
|
+
|
|
9
|
+
export function copyText(text) {
|
|
10
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
11
|
+
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
|
12
|
+
} else {
|
|
13
|
+
fallbackCopy(text);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fallbackCopy(text) {
|
|
18
|
+
try {
|
|
19
|
+
const ta = document.createElement("textarea");
|
|
20
|
+
ta.value = text;
|
|
21
|
+
ta.style.position = "fixed";
|
|
22
|
+
ta.style.opacity = "0";
|
|
23
|
+
document.body.appendChild(ta);
|
|
24
|
+
ta.select();
|
|
25
|
+
document.execCommand("copy");
|
|
26
|
+
document.body.removeChild(ta);
|
|
27
|
+
} catch {
|
|
28
|
+
/* clipboard unavailable */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Copy `text`, then give `button` the standard "Copied" feedback: add `is-copied`,
|
|
34
|
+
* swap its `.otfw-copy-label` to "Copied", and revert after 2s. Idempotent across
|
|
35
|
+
* rapid clicks (the pending revert is reset each time).
|
|
36
|
+
*/
|
|
37
|
+
export function copyWithFeedback(button, text) {
|
|
38
|
+
copyText(text);
|
|
39
|
+
const label = button.querySelector(".otfw-copy-label");
|
|
40
|
+
button.classList.add("is-copied");
|
|
41
|
+
if (label) label.textContent = "Copied";
|
|
42
|
+
clearTimeout(button._otfwCopyT);
|
|
43
|
+
button._otfwCopyT = setTimeout(() => {
|
|
44
|
+
button.classList.remove("is-copied");
|
|
45
|
+
if (label) label.textContent = "Copy";
|
|
46
|
+
}, 2000);
|
|
47
|
+
}
|