@proto-labs-inc/islands 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +30 -2
- package/package.json +41 -4
- package/src/VariantFailed.svelte +8 -0
- package/src/loader.cjs +16 -0
- package/src/next.js +47 -0
- package/src/react.js +85 -0
- package/src/registry.js +147 -0
- package/src/selection.js +96 -0
- package/src/svelte.js +66 -0
- package/src/transform.js +221 -0
- package/src/vite.js +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Proto Labs Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @proto-labs-inc/islands
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shows Proto prototype alternates inside your own running dev server, without copying your app or changing its source.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Next.js:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// next.config.ts
|
|
11
|
+
import { withProto } from "@proto-labs-inc/islands/next";
|
|
12
|
+
|
|
13
|
+
export default withProto(nextConfig);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Vite (React, Svelte, SvelteKit, TanStack Start and others):
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// vite.config.ts
|
|
20
|
+
import { protoIslands } from "@proto-labs-inc/islands/vite";
|
|
21
|
+
|
|
22
|
+
export default defineConfig({ plugins: [protoIslands(), /* your plugins */] });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Islands run only on your dev server. A production build ignores the plugin unless `PROTO_ISLANDS=1` turns it on for a preview deploy, and `PROTO_ISLANDS=0` turns it off everywhere.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
Proto's agent writes alternates into `.proto/` next to your build config. While your app compiles, the plugin wraps each recorded spot in an island, in memory only. The Proto Frame picks an alternate and the island swaps it in place, with no reload. Files named `*.generated.*` are rebuilt by the plugin and ignored by git.
|
|
30
|
+
|
|
31
|
+
Supports React and Svelte.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proto-labs-inc/islands",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Proto's islands: show prototype variants inside a maker's own running app, without changing its source.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/proto-labs-inc/proto.git",
|
|
9
|
+
"directory": "packages/islands"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
"./next": "./src/next.js",
|
|
14
|
+
"./vite": "./src/vite.js",
|
|
15
|
+
"./loader": "./src/loader.cjs",
|
|
16
|
+
"./react": "./src/react.js",
|
|
17
|
+
"./svelte": "./src/svelte.js",
|
|
18
|
+
"./registry": "./src/registry.js",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": ">=18",
|
|
29
|
+
"svelte": ">=4"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"react": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"svelte": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@babel/parser": "^7.28.0",
|
|
41
|
+
"magic-string": "^0.30.17"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
export let message = "";
|
|
3
|
+
export let variant = "";
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div role="alert" data-proto-failed={variant} style="border: 1px dashed #d92d20; border-radius: 8px; padding: 12px; color: #b42318; font: 13px/1.4 system-ui, sans-serif; background: #fef3f2">
|
|
7
|
+
Proto variant "{variant}" failed: {message}
|
|
8
|
+
</div>
|
package/src/loader.cjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { join } = require("node:path");
|
|
2
|
+
|
|
3
|
+
/** Webpack and Turbopack loader: wraps recorded spots in islands, leaves every other file as it is. */
|
|
4
|
+
module.exports = function protoIslandsLoader(source) {
|
|
5
|
+
const callback = this.async();
|
|
6
|
+
const { root } = this.getOptions();
|
|
7
|
+
this.addDependency(join(root, ".proto", "targets.generated.json"));
|
|
8
|
+
import("./transform.js").then(
|
|
9
|
+
({ transformFile }) => {
|
|
10
|
+
const result = transformFile(root, this.resourcePath, source);
|
|
11
|
+
if (!result) return callback(null, source);
|
|
12
|
+
callback(null, result.code, result.map);
|
|
13
|
+
},
|
|
14
|
+
error => callback(error),
|
|
15
|
+
);
|
|
16
|
+
};
|
package/src/next.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { watchRegistry } from "./registry.js";
|
|
3
|
+
|
|
4
|
+
const loader = createRequire(import.meta.url).resolve("./loader.cjs");
|
|
5
|
+
const SOURCE = /\.(tsx|jsx|ts|js|mjs)$/;
|
|
6
|
+
|
|
7
|
+
/** One response header with any ban on being framed removed; none when nothing else is left in it. */
|
|
8
|
+
export function allowFraming({ key, value }) {
|
|
9
|
+
const name = key.toLowerCase();
|
|
10
|
+
if (name === "x-frame-options") return [];
|
|
11
|
+
if (name !== "content-security-policy") return [{ key, value }];
|
|
12
|
+
const rest = value.split(";").map(part => part.trim()).filter(part => part && !/^frame-ancestors\b/i.test(part)).join("; ");
|
|
13
|
+
return rest ? [{ key, value: rest }] : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Next.js setup: `export default withProto(nextConfig)`. Islands run only in
|
|
18
|
+
* `next dev`, unless PROTO_ISLANDS=1 turns them on for a preview build;
|
|
19
|
+
* a normal production build never reads .proto.
|
|
20
|
+
*/
|
|
21
|
+
export function withProto(config = {}, { root = process.cwd() } = {}) {
|
|
22
|
+
if (process.env.PROTO_ISLANDS === "0") return config;
|
|
23
|
+
const always = process.env.PROTO_ISLANDS === "1";
|
|
24
|
+
// `next build` sets NODE_ENV before it loads the config. Leaving the config untouched keeps
|
|
25
|
+
// the dev-only loader out of what a build saves, such as the standalone server's copy of the config.
|
|
26
|
+
if (process.env.NODE_ENV === "production" && !always) return config;
|
|
27
|
+
if (!globalThis.__protoIslandsWatcher) globalThis.__protoIslandsWatcher = watchRegistry(root);
|
|
28
|
+
const use = { loader, options: { root } };
|
|
29
|
+
const condition = { all: [...(always ? [] : ["development"]), { not: "foreign" }, { path: SOURCE }] };
|
|
30
|
+
const rules = { ...config.turbopack?.rules };
|
|
31
|
+
const existing = rules["*"];
|
|
32
|
+
rules["*"] = [...(existing ? [existing].flat() : []), { condition, loaders: [use] }];
|
|
33
|
+
return {
|
|
34
|
+
...config,
|
|
35
|
+
// Proto shows the app inside its Frame, so in development the app's own "never show me in a frame" rules are dropped.
|
|
36
|
+
async headers() {
|
|
37
|
+
const rules = config.headers ? await config.headers() : [];
|
|
38
|
+
return rules.map(rule => ({ ...rule, headers: rule.headers.flatMap(allowFraming) }));
|
|
39
|
+
},
|
|
40
|
+
turbopack: { ...config.turbopack, rules },
|
|
41
|
+
webpack(webpackConfig, context) {
|
|
42
|
+
const result = config.webpack ? config.webpack(webpackConfig, context) : webpackConfig;
|
|
43
|
+
if (context.dev || always) result.module.rules.push({ test: SOURCE, exclude: /node_modules/, enforce: "pre", use: [use] });
|
|
44
|
+
return result;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/react.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Component, Fragment, Suspense, createElement, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
|
|
4
|
+
import { selection } from "./selection.js";
|
|
5
|
+
|
|
6
|
+
const modules = new WeakMap();
|
|
7
|
+
|
|
8
|
+
/** Start loading a variant once; later calls share the same load. */
|
|
9
|
+
function preload(load) {
|
|
10
|
+
let entry = modules.get(load);
|
|
11
|
+
if (!entry) {
|
|
12
|
+
entry = { status: "loading", module: null, error: null };
|
|
13
|
+
entry.promise = load().then(
|
|
14
|
+
module => Object.assign(entry, { status: "ready", module }),
|
|
15
|
+
error => Object.assign(entry, { status: "failed", error }),
|
|
16
|
+
);
|
|
17
|
+
modules.set(load, entry);
|
|
18
|
+
}
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Renders a loaded variant straight away; suspends only on the first, unloaded pick. */
|
|
23
|
+
function LoadedVariant({ load, ...props }) {
|
|
24
|
+
const entry = preload(load);
|
|
25
|
+
if (entry.status === "loading") {
|
|
26
|
+
if (React.use) React.use(entry.promise);
|
|
27
|
+
else throw entry.promise;
|
|
28
|
+
}
|
|
29
|
+
if (entry.status === "failed") throw entry.error;
|
|
30
|
+
return createElement(entry.module.default, props);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Rendered({ set, variant }) {
|
|
34
|
+
useLayoutEffect(() => selection.markRendered(set, variant), [set, variant]);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A failing variant shows a card in its own spot; the rest of the page keeps working. */
|
|
39
|
+
class VariantBoundary extends Component {
|
|
40
|
+
state = { error: null };
|
|
41
|
+
|
|
42
|
+
static getDerivedStateFromError(error) {
|
|
43
|
+
return { error };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
componentDidCatch(error) {
|
|
47
|
+
selection.markRendered(this.props.set, this.props.variant, "failed", String(error?.message ?? error));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render() {
|
|
51
|
+
if (!this.state.error) return this.props.children;
|
|
52
|
+
return createElement(
|
|
53
|
+
"div",
|
|
54
|
+
{ role: "alert", "data-proto-failed": this.props.set, style: { border: "1px dashed #d92d20", borderRadius: 8, padding: 12, color: "#b42318", font: "13px/1.4 system-ui, sans-serif", background: "#fef3f2" } },
|
|
55
|
+
`Proto variant "${this.props.variant}" failed: ${String(this.state.error?.message ?? this.state.error)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One spot in the maker's app. Renders the original children until the
|
|
62
|
+
* Frame picks a variant; a variant gets the spot's original props, the
|
|
63
|
+
* component's values it reads (`scope`) and the original element itself.
|
|
64
|
+
*/
|
|
65
|
+
export function ProtoIsland({ set, title, variants, scope, children }) {
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
selection.register(set, { title, variants: Object.fromEntries(Object.entries(variants).map(([id, variant]) => [id, variant.title])) });
|
|
68
|
+
// Load every variant while the page is idle, so even the first swap is instant.
|
|
69
|
+
const idle = window.requestIdleCallback ?? (callback => setTimeout(callback, 200));
|
|
70
|
+
idle(() => Object.values(variants).forEach(variant => preload(variant.load)));
|
|
71
|
+
}, [set, title, variants]);
|
|
72
|
+
const chosen = useSyncExternalStore(selection.subscribe, () => selection.get(set), () => null);
|
|
73
|
+
const variant = chosen ? variants[chosen] : null;
|
|
74
|
+
if (!variant) return createElement(Fragment, null, children, createElement(Rendered, { set, variant: "original" }));
|
|
75
|
+
return createElement(
|
|
76
|
+
VariantBoundary,
|
|
77
|
+
{ key: chosen, set, variant: chosen },
|
|
78
|
+
createElement(
|
|
79
|
+
Suspense,
|
|
80
|
+
{ fallback: children },
|
|
81
|
+
createElement(LoadedVariant, { load: variant.load, props: children?.props ?? {}, scope: scope ?? {}, original: children }),
|
|
82
|
+
createElement(Rendered, { set, variant: chosen }),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The .proto folder next to an app's own config: one folder per prototype,
|
|
6
|
+
* one folder per variant set (a spot in the app), one folder per variant.
|
|
7
|
+
*
|
|
8
|
+
* .proto/prototypes/<prototype>/<set>/set.json
|
|
9
|
+
* .proto/prototypes/<prototype>/<set>/<variant>/variant.json + Variant.tsx | Variant.svelte
|
|
10
|
+
*
|
|
11
|
+
* Everything named *.generated.* is rebuilt from those files and never committed.
|
|
12
|
+
*/
|
|
13
|
+
export const PROTO_DIR = ".proto";
|
|
14
|
+
export const TARGETS_FILE = "targets.generated.json";
|
|
15
|
+
const ISLAND_FILE = "island.generated.js";
|
|
16
|
+
const SVELTE = new Set([".svelte"]);
|
|
17
|
+
const REACT = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs"]);
|
|
18
|
+
|
|
19
|
+
function readJson(file) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function folders(dir) {
|
|
28
|
+
if (!existsSync(dir)) return [];
|
|
29
|
+
return readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory() && !entry.name.startsWith(".")).map(entry => entry.name).sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every set with its variants, read from disk. */
|
|
33
|
+
export function readSets(root) {
|
|
34
|
+
const prototypes = join(root, PROTO_DIR, "prototypes");
|
|
35
|
+
const sets = [];
|
|
36
|
+
for (const prototype of folders(prototypes)) {
|
|
37
|
+
for (const setName of folders(join(prototypes, prototype))) {
|
|
38
|
+
const dir = join(prototypes, prototype, setName);
|
|
39
|
+
const set = readJson(join(dir, "set.json"));
|
|
40
|
+
if (!set?.target?.file || !set.target.element) continue;
|
|
41
|
+
const variants = [];
|
|
42
|
+
for (const variantName of folders(dir)) {
|
|
43
|
+
const variantDir = join(dir, variantName);
|
|
44
|
+
const meta = readJson(join(variantDir, "variant.json"));
|
|
45
|
+
if (!meta) continue;
|
|
46
|
+
const entry = meta.entry ?? readdirSync(variantDir).find(file => /^Variant\.(tsx|jsx|js|svelte)$/.test(file));
|
|
47
|
+
if (!entry || !existsSync(join(variantDir, entry))) continue;
|
|
48
|
+
variants.push({ id: variantName, title: meta.title ?? variantName, entry: `./${variantName}/${entry}` });
|
|
49
|
+
}
|
|
50
|
+
sets.push({ key: `${prototype}.${setName}`, title: set.title ?? setName, dir, target: set.target, variants });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return sets;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function islandModule(set) {
|
|
57
|
+
const framework = SVELTE.has(extname(set.target.file)) ? "svelte" : "react";
|
|
58
|
+
const variants = set.variants
|
|
59
|
+
.map(variant => ` ${JSON.stringify(variant.id)}: { title: ${JSON.stringify(variant.title)}, load: () => import(${JSON.stringify(variant.entry)}) },`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
if (framework === "svelte") {
|
|
62
|
+
return `import { island } from "@proto-labs-inc/islands/svelte";\n\nexport default island(${JSON.stringify(set.key)}, ${JSON.stringify(set.title)}, {\n${variants}\n});\n`;
|
|
63
|
+
}
|
|
64
|
+
return `"use client";
|
|
65
|
+
import { createElement } from "react";
|
|
66
|
+
import { ProtoIsland } from "@proto-labs-inc/islands/react";
|
|
67
|
+
|
|
68
|
+
const variants = {
|
|
69
|
+
${variants}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default function ProtoSet(props) {
|
|
73
|
+
return createElement(ProtoIsland, { ...props, set: ${JSON.stringify(set.key)}, title: ${JSON.stringify(set.title)}, variants });
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeIfChanged(file, content) {
|
|
79
|
+
if (existsSync(file) && readFileSync(file, "utf8") === content) return false;
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
81
|
+
writeFileSync(file, content);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rebuild each set's island module and the list of target files. Returns
|
|
87
|
+
* the target files whose islands were added or removed, so a dev server can
|
|
88
|
+
* recompile exactly those.
|
|
89
|
+
*/
|
|
90
|
+
export function generate(root) {
|
|
91
|
+
const protoDir = join(root, PROTO_DIR);
|
|
92
|
+
if (!existsSync(protoDir)) return [];
|
|
93
|
+
writeIfChanged(join(protoDir, ".gitignore"), "*.generated.*\n");
|
|
94
|
+
const targets = {};
|
|
95
|
+
for (const set of readSets(root)) {
|
|
96
|
+
const module = join(set.dir, ISLAND_FILE);
|
|
97
|
+
writeIfChanged(module, islandModule(set));
|
|
98
|
+
const file = set.target.file.split("/").join(sep);
|
|
99
|
+
(targets[file] ??= []).push({ key: set.key, module, element: set.target.element, in: set.target.in ?? null, nth: set.target.nth ?? 0 });
|
|
100
|
+
}
|
|
101
|
+
const targetsFile = join(protoDir, TARGETS_FILE);
|
|
102
|
+
const before = readJson(targetsFile) ?? {};
|
|
103
|
+
const content = `${JSON.stringify(targets, null, 2)}\n`;
|
|
104
|
+
if (!writeIfChanged(targetsFile, content)) return [];
|
|
105
|
+
const changed = new Set([...Object.keys(before), ...Object.keys(targets)].filter(file => JSON.stringify(before[file]) !== JSON.stringify(targets[file])));
|
|
106
|
+
return [...changed].map(file => join(root, file));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let cached = { file: null, mtime: 0, targets: {} };
|
|
110
|
+
|
|
111
|
+
/** The islands recorded for one source file, or none. */
|
|
112
|
+
export function targetsFor(root, file) {
|
|
113
|
+
const targetsFile = join(root, PROTO_DIR, TARGETS_FILE);
|
|
114
|
+
let mtime = 0;
|
|
115
|
+
try {
|
|
116
|
+
mtime = statSync(targetsFile).mtimeMs;
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
if (cached.file !== targetsFile || cached.mtime !== mtime) cached = { file: targetsFile, mtime, targets: readJson(targetsFile) ?? {} };
|
|
121
|
+
return cached.targets[relative(root, file)] ?? [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Keep the generated files current while a dev server runs. */
|
|
125
|
+
export function watchRegistry(root, onTargetsChanged = () => {}) {
|
|
126
|
+
const protoDir = join(root, PROTO_DIR);
|
|
127
|
+
mkdirSync(join(protoDir, "prototypes"), { recursive: true });
|
|
128
|
+
generate(root);
|
|
129
|
+
let timer = null;
|
|
130
|
+
const watcher = watch(protoDir, { recursive: true }, (_event, name) => {
|
|
131
|
+
if (name && name.includes(".generated.")) return;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
timer = setTimeout(() => {
|
|
134
|
+
const changed = generate(root);
|
|
135
|
+
if (changed.length) onTargetsChanged(changed);
|
|
136
|
+
}, 30);
|
|
137
|
+
});
|
|
138
|
+
watcher.unref?.();
|
|
139
|
+
return watcher;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function frameworkOf(file) {
|
|
143
|
+
const extension = extname(file);
|
|
144
|
+
if (SVELTE.has(extension)) return "svelte";
|
|
145
|
+
if (REACT.has(extension)) return "react";
|
|
146
|
+
return null;
|
|
147
|
+
}
|
package/src/selection.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which variant each island shows, shared by every island on the page.
|
|
3
|
+
* It starts from the address (?v.<prototype>.<set>=<variant>) and follows
|
|
4
|
+
* the Frame's messages, so a swap re-renders one island and nothing else.
|
|
5
|
+
* One copy lives on window, so islands from different bundles agree.
|
|
6
|
+
*/
|
|
7
|
+
const GLOBAL = "__protoIslands";
|
|
8
|
+
const PARAM = "v.";
|
|
9
|
+
|
|
10
|
+
function post(message) {
|
|
11
|
+
if (window.parent !== window) window.parent.postMessage(message, "*");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The page-space outline of the nodes a swap put on screen, or null when it drew no DOM (a WebGL scene). */
|
|
15
|
+
function regionOf(nodes) {
|
|
16
|
+
const added = new Set(nodes);
|
|
17
|
+
let box = null;
|
|
18
|
+
for (const node of added) {
|
|
19
|
+
if (!node.isConnected || added.has(node.parentNode)) continue;
|
|
20
|
+
if (node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) continue;
|
|
21
|
+
const range = document.createRange();
|
|
22
|
+
range.selectNode(node);
|
|
23
|
+
const rect = range.getBoundingClientRect();
|
|
24
|
+
if (!rect.width && !rect.height) continue;
|
|
25
|
+
box = box
|
|
26
|
+
? { left: Math.min(box.left, rect.left), top: Math.min(box.top, rect.top), right: Math.max(box.right, rect.right), bottom: Math.max(box.bottom, rect.bottom) }
|
|
27
|
+
: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
28
|
+
}
|
|
29
|
+
if (!box) return null;
|
|
30
|
+
return { x: Math.round(box.left + scrollX), y: Math.round(box.top + scrollY), width: Math.round(box.right - box.left), height: Math.round(box.bottom - box.top) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createSelection() {
|
|
34
|
+
const chosen = new Map();
|
|
35
|
+
const listeners = new Set();
|
|
36
|
+
const sets = new Map();
|
|
37
|
+
const rendered = new Map();
|
|
38
|
+
// Watches the DOM from a pick until its island has rendered, to learn where the island sits.
|
|
39
|
+
let watching = null;
|
|
40
|
+
for (const [key, value] of new URLSearchParams(window.location.search)) {
|
|
41
|
+
if (key.startsWith(PARAM) && value && value !== "original") chosen.set(key.slice(PARAM.length), value);
|
|
42
|
+
}
|
|
43
|
+
const announce = () => post({ type: "proto:islands", sets: Object.fromEntries(sets) });
|
|
44
|
+
const selection = {
|
|
45
|
+
get: set => chosen.get(set) ?? null,
|
|
46
|
+
select(set, variant) {
|
|
47
|
+
if (!variant || variant === "original") chosen.delete(set);
|
|
48
|
+
else chosen.set(set, variant);
|
|
49
|
+
watching?.observer.disconnect();
|
|
50
|
+
const added = [];
|
|
51
|
+
const observer = new MutationObserver(records => records.forEach(record => added.push(...record.addedNodes)));
|
|
52
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
53
|
+
watching = { set, variant: variant && variant !== "original" ? variant : "original", added, observer };
|
|
54
|
+
listeners.forEach(listener => listener());
|
|
55
|
+
},
|
|
56
|
+
subscribe(listener) {
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
return () => listeners.delete(listener);
|
|
59
|
+
},
|
|
60
|
+
register(set, info) {
|
|
61
|
+
sets.set(set, info);
|
|
62
|
+
announce();
|
|
63
|
+
},
|
|
64
|
+
sets: () => Object.fromEntries(sets),
|
|
65
|
+
markRendered(set, variant, status = "ready", message = null) {
|
|
66
|
+
let rect = rendered.get(set)?.rect ?? null;
|
|
67
|
+
if (watching?.set === set && watching.variant === variant) {
|
|
68
|
+
watching.observer.takeRecords().forEach(record => watching.added.push(...record.addedNodes));
|
|
69
|
+
watching.observer.disconnect();
|
|
70
|
+
rect = regionOf(watching.added);
|
|
71
|
+
watching = null;
|
|
72
|
+
}
|
|
73
|
+
rendered.set(set, { variant, status, message, rect, at: performance.now() });
|
|
74
|
+
post({ type: "proto:rendered", set, variant, status, message, rect });
|
|
75
|
+
},
|
|
76
|
+
rendered: set => rendered.get(set) ?? null,
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener("message", event => {
|
|
79
|
+
const data = event.data;
|
|
80
|
+
if (data?.type === "proto:select") selection.select(data.set, data.variant);
|
|
81
|
+
else if (data?.type === "proto:islands?") announce();
|
|
82
|
+
});
|
|
83
|
+
return selection;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const serverSelection = {
|
|
87
|
+
get: () => null,
|
|
88
|
+
select() {},
|
|
89
|
+
subscribe: () => () => {},
|
|
90
|
+
register() {},
|
|
91
|
+
sets: () => ({}),
|
|
92
|
+
markRendered() {},
|
|
93
|
+
rendered: () => null,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const selection = typeof window === "undefined" ? serverSelection : (window[GLOBAL] ??= createSelection());
|
package/src/svelte.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { tick } from "svelte";
|
|
2
|
+
import { readable } from "svelte/store";
|
|
3
|
+
import { selection } from "./selection.js";
|
|
4
|
+
import VariantFailed from "./VariantFailed.svelte";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Svelte has no error boundary, so a variant that throws while starting would
|
|
8
|
+
* break the page. The guard builds the variant, and an error card in its
|
|
9
|
+
* place when it throws. `new.target` covers Svelte 4 classes and Svelte 5 functions.
|
|
10
|
+
*/
|
|
11
|
+
function guard(Variant, variant, onError) {
|
|
12
|
+
return function GuardedVariant(...args) {
|
|
13
|
+
try {
|
|
14
|
+
return new.target ? new Variant(...args) : Variant(...args);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = String(error?.message ?? error);
|
|
17
|
+
onError(message);
|
|
18
|
+
if (new.target) return new VariantFailed({ ...args[0], props: { message, variant } });
|
|
19
|
+
return VariantFailed(args[0], { message, variant });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One spot in a Svelte app. The store holds the variant component to render,
|
|
26
|
+
* or null for the original. Variant modules load on first pick.
|
|
27
|
+
*/
|
|
28
|
+
export function island(set, title, variants) {
|
|
29
|
+
return () => {
|
|
30
|
+
selection.register(set, { title, variants: Object.fromEntries(Object.entries(variants).map(([id, variant]) => [id, variant.title])) });
|
|
31
|
+
// Load every variant while the page is idle, so even the first swap is instant.
|
|
32
|
+
if (typeof window !== "undefined") {
|
|
33
|
+
const idle = window.requestIdleCallback ?? (callback => setTimeout(callback, 200));
|
|
34
|
+
idle(() => Object.values(variants).forEach(variant => variant.load().catch(() => {})));
|
|
35
|
+
}
|
|
36
|
+
return readable(null, update => {
|
|
37
|
+
let current = Symbol("none");
|
|
38
|
+
const follow = async () => {
|
|
39
|
+
const chosen = selection.get(set);
|
|
40
|
+
if (chosen === current) return;
|
|
41
|
+
current = chosen;
|
|
42
|
+
const variant = chosen ? variants[chosen] : null;
|
|
43
|
+
if (!variant) {
|
|
44
|
+
update(null);
|
|
45
|
+
await tick();
|
|
46
|
+
selection.markRendered(set, "original");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const module = await variant.load();
|
|
51
|
+
if (current !== chosen) return;
|
|
52
|
+
let failure = null;
|
|
53
|
+
update(guard(module.default, chosen, message => (failure = message)));
|
|
54
|
+
await tick();
|
|
55
|
+
if (failure) selection.markRendered(set, chosen, "failed", failure);
|
|
56
|
+
else selection.markRendered(set, chosen);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
update(null);
|
|
59
|
+
selection.markRendered(set, chosen, "failed", String(error?.message ?? error));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
follow();
|
|
63
|
+
return selection.subscribe(follow);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/transform.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import { parse as parseJs } from "@babel/parser";
|
|
4
|
+
import MagicString from "magic-string";
|
|
5
|
+
import { frameworkOf, targetsFor } from "./registry.js";
|
|
6
|
+
|
|
7
|
+
const SKIP_KEYS = new Set(["loc", "start", "end", "extra", "leadingComments", "trailingComments", "innerComments", "typeAnnotation", "typeParameters", "typeArguments", "returnType", "range"]);
|
|
8
|
+
|
|
9
|
+
function walk(node, visit, parent = null, key = null) {
|
|
10
|
+
if (!node || typeof node.type !== "string") return;
|
|
11
|
+
if (visit(node, parent, key) === false) return;
|
|
12
|
+
for (const childKey of Object.keys(node)) {
|
|
13
|
+
if (SKIP_KEYS.has(childKey)) continue;
|
|
14
|
+
const value = node[childKey];
|
|
15
|
+
if (Array.isArray(value)) value.forEach(child => walk(child, visit, node, childKey));
|
|
16
|
+
else if (value && typeof value.type === "string") walk(value, visit, node, childKey);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function importPath(fromFile, toFile) {
|
|
21
|
+
const path = relative(dirname(fromFile), toFile).split(sep).join("/");
|
|
22
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Wrap each recorded spot in a source file with its island. Returns null when the file has none. */
|
|
26
|
+
export function transformFile(root, file, source) {
|
|
27
|
+
const targets = targetsFor(root, file);
|
|
28
|
+
if (!targets.length) return null;
|
|
29
|
+
const framework = frameworkOf(file);
|
|
30
|
+
if (framework === "react") return transformReact(file, source, targets);
|
|
31
|
+
if (framework === "svelte") return transformSvelte(root, file, source, targets);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function jsxName(name) {
|
|
36
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
37
|
+
if (name.type === "JSXMemberExpression") return `${jsxName(name.object)}.${name.property.name}`;
|
|
38
|
+
if (name.type === "JSXNamespacedName") return `${name.namespace.name}:${name.name.name}`;
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function patternNames(pattern, names) {
|
|
43
|
+
if (!pattern) return;
|
|
44
|
+
switch (pattern.type) {
|
|
45
|
+
case "Identifier":
|
|
46
|
+
names.add(pattern.name);
|
|
47
|
+
break;
|
|
48
|
+
case "ObjectPattern":
|
|
49
|
+
for (const property of pattern.properties) patternNames(property.type === "RestElement" ? property.argument : property.value, names);
|
|
50
|
+
break;
|
|
51
|
+
case "ArrayPattern":
|
|
52
|
+
pattern.elements.forEach(element => patternNames(element, names));
|
|
53
|
+
break;
|
|
54
|
+
case "AssignmentPattern":
|
|
55
|
+
patternNames(pattern.left, names);
|
|
56
|
+
break;
|
|
57
|
+
case "RestElement":
|
|
58
|
+
patternNames(pattern.argument, names);
|
|
59
|
+
break;
|
|
60
|
+
case "TSParameterProperty":
|
|
61
|
+
patternNames(pattern.parameter, names);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isFunction(node) {
|
|
67
|
+
return node?.type === "FunctionDeclaration" || node?.type === "FunctionExpression" || node?.type === "ArrowFunctionExpression";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The function declared under this name, looking through wrappers like memo() and forwardRef(). */
|
|
71
|
+
function findComponent(program, name) {
|
|
72
|
+
let found = null;
|
|
73
|
+
walk(program, node => {
|
|
74
|
+
if (found) return false;
|
|
75
|
+
if (node.type === "FunctionDeclaration" && node.id?.name === name) found = node;
|
|
76
|
+
else if (node.type === "VariableDeclarator" && node.id?.name === name) {
|
|
77
|
+
let init = node.init;
|
|
78
|
+
while (init?.type === "CallExpression") init = init.arguments.find(argument => isFunction(argument) || argument.type === "CallExpression");
|
|
79
|
+
if (isFunction(init)) found = init;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The component's own values a variant may use (`scope`): its props and every
|
|
87
|
+
* top-level declaration before the spot. Later or nested names are left out,
|
|
88
|
+
* since reading them at the spot would fail.
|
|
89
|
+
*/
|
|
90
|
+
function scopeOf(component, element) {
|
|
91
|
+
if (!component) return [];
|
|
92
|
+
const names = new Set();
|
|
93
|
+
component.params?.forEach(param => patternNames(param, names));
|
|
94
|
+
if (component.body?.type !== "BlockStatement") return [...names];
|
|
95
|
+
for (const statement of component.body.body) {
|
|
96
|
+
if (statement.start >= element.start) break;
|
|
97
|
+
if (statement.type === "VariableDeclaration") statement.declarations.forEach(item => patternNames(item.id, names));
|
|
98
|
+
else if (statement.type === "FunctionDeclaration" && statement.id) names.add(statement.id.name);
|
|
99
|
+
}
|
|
100
|
+
return [...names];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function transformReact(file, source, targets) {
|
|
104
|
+
let ast;
|
|
105
|
+
try {
|
|
106
|
+
ast = parseJs(source, { sourceType: "module", plugins: ["jsx", "typescript"], errorRecovery: false });
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const code = new MagicString(source);
|
|
111
|
+
const imports = [];
|
|
112
|
+
const wraps = new Map();
|
|
113
|
+
targets.forEach((target, index) => {
|
|
114
|
+
const container = target.in ? findComponent(ast.program, target.in) : ast.program;
|
|
115
|
+
if (!container) return;
|
|
116
|
+
const matches = [];
|
|
117
|
+
walk(container, node => {
|
|
118
|
+
if (node.type === "JSXElement" && jsxName(node.openingElement.name) === target.element) matches.push(node);
|
|
119
|
+
});
|
|
120
|
+
const element = matches[target.nth];
|
|
121
|
+
if (!element) return;
|
|
122
|
+
const name = `__ProtoSet${index}`;
|
|
123
|
+
imports.push(`import ${name} from ${JSON.stringify(importPath(file, target.module))};`);
|
|
124
|
+
const scope = scopeOf(target.in ? container : null, element);
|
|
125
|
+
const list = wraps.get(element) ?? [];
|
|
126
|
+
list.push({ name, scope });
|
|
127
|
+
wraps.set(element, list);
|
|
128
|
+
});
|
|
129
|
+
if (!imports.length) return null;
|
|
130
|
+
for (const [element, list] of wraps) {
|
|
131
|
+
const open = list.map(({ name, scope }) => `<${name} scope={{ ${scope.join(", ")} }}>`).join("");
|
|
132
|
+
const close = list.map(({ name }) => `</${name}>`).reverse().join("");
|
|
133
|
+
code.prependRight(element.start, open);
|
|
134
|
+
code.appendLeft(element.end, close);
|
|
135
|
+
}
|
|
136
|
+
const directives = ast.program.directives;
|
|
137
|
+
const at = directives.length ? directives.at(-1).end : 0;
|
|
138
|
+
if (at) code.appendLeft(at, `\n${imports.join("\n")}`);
|
|
139
|
+
else code.prepend(`${imports.join("\n")}\n`);
|
|
140
|
+
return { code: code.toString(), map: code.generateMap({ source: file, includeContent: true, hires: true }) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Blank out <script> and <style> bodies so the markup parses without a TypeScript preprocessor. Positions stay the same. */
|
|
144
|
+
function blankBlocks(source) {
|
|
145
|
+
return source.replace(/(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, open, _tag, body, close) => open + body.replace(/[^\n]/g, " ") + close);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function svelteNodes(ast) {
|
|
149
|
+
return ast.html ?? ast.fragment ?? ast;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function instanceScript(source) {
|
|
153
|
+
for (const match of source.matchAll(/<script(\s[^>]*)?>/g)) {
|
|
154
|
+
const attributes = match[1] ?? "";
|
|
155
|
+
if (/context\s*=\s*["']module["']|\bmodule\b/.test(attributes)) continue;
|
|
156
|
+
const bodyStart = match.index + match[0].length;
|
|
157
|
+
const bodyEnd = source.indexOf("</script>", bodyStart);
|
|
158
|
+
return { bodyStart, body: source.slice(bodyStart, bodyEnd) };
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Top-level names in the component's script, including `$:` reactive ones. */
|
|
164
|
+
function scriptNames(body) {
|
|
165
|
+
const names = new Set();
|
|
166
|
+
let ast;
|
|
167
|
+
try {
|
|
168
|
+
ast = parseJs(body, { sourceType: "module", plugins: ["typescript"] });
|
|
169
|
+
} catch {
|
|
170
|
+
return names;
|
|
171
|
+
}
|
|
172
|
+
for (const statement of ast.program.body) {
|
|
173
|
+
const declaration = statement.type.startsWith("Export") && statement.declaration ? statement.declaration : statement;
|
|
174
|
+
if (declaration.type === "VariableDeclaration") declaration.declarations.forEach(item => patternNames(item.id, names));
|
|
175
|
+
else if (declaration.type === "FunctionDeclaration" && declaration.id) names.add(declaration.id.name);
|
|
176
|
+
else if (declaration.type === "LabeledStatement" && declaration.label.name === "$" && declaration.body.type === "ExpressionStatement") {
|
|
177
|
+
const expression = declaration.body.expression;
|
|
178
|
+
if (expression.type === "AssignmentExpression") patternNames(expression.left, names);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return names;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function transformSvelte(root, file, source, targets) {
|
|
185
|
+
const require = createRequire(join(root, "package.json"));
|
|
186
|
+
let ast;
|
|
187
|
+
try {
|
|
188
|
+
ast = require("svelte/compiler").parse(blankBlocks(source));
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const script = instanceScript(source);
|
|
193
|
+
const declared = script ? scriptNames(script.body) : new Set();
|
|
194
|
+
const code = new MagicString(source);
|
|
195
|
+
const setup = [];
|
|
196
|
+
targets.forEach((target, index) => {
|
|
197
|
+
const matches = [];
|
|
198
|
+
walk(svelteNodes(ast), node => {
|
|
199
|
+
if ((node.type === "InlineComponent" || node.type === "Element" || node.type === "Component" || node.type === "RegularElement") && node.name === target.element) matches.push(node);
|
|
200
|
+
});
|
|
201
|
+
const node = matches[target.nth];
|
|
202
|
+
if (!node) return;
|
|
203
|
+
const pick = `__protoPick${index}`;
|
|
204
|
+
setup.push(`import __protoSet${index} from ${JSON.stringify(importPath(file, target.module))};`, `const ${pick} = __protoSet${index}();`);
|
|
205
|
+
if (/^[A-Z]/.test(node.name)) {
|
|
206
|
+
code.overwrite(node.start, node.start + 1 + node.name.length, `<svelte:component this={$${pick} ?? ${node.name}}`);
|
|
207
|
+
if (source.slice(node.end - 2, node.end) !== "/>") {
|
|
208
|
+
const closeAt = source.lastIndexOf("</", node.end);
|
|
209
|
+
code.overwrite(closeAt, node.end, "</svelte:component>");
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
const scope = [...declared];
|
|
213
|
+
code.prependRight(node.start, `{#if $${pick}}<svelte:component this={$${pick}} scope={{ ${scope.join(", ")} }} />{:else}`);
|
|
214
|
+
code.appendLeft(node.end, "{/if}");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (!setup.length) return null;
|
|
218
|
+
if (script) code.appendLeft(script.bodyStart, `\n${setup.join("\n")}\n`);
|
|
219
|
+
else code.prepend(`<script>\n${setup.join("\n")}\n</script>\n`);
|
|
220
|
+
return { code: code.toString(), map: code.generateMap({ source: file, includeContent: true, hires: true }) };
|
|
221
|
+
}
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { transformFile } from "./transform.js";
|
|
3
|
+
import { PROTO_DIR, watchRegistry } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vite setup: add `protoIslands()` to `plugins`. Covers React, Svelte and
|
|
7
|
+
* anything else on Vite. Islands run only in `vite dev`, unless
|
|
8
|
+
* PROTO_ISLANDS=1 turns them on for a preview build.
|
|
9
|
+
*/
|
|
10
|
+
export function protoIslands({ root } = {}) {
|
|
11
|
+
let appRoot = root;
|
|
12
|
+
return {
|
|
13
|
+
name: "proto-islands",
|
|
14
|
+
enforce: "pre",
|
|
15
|
+
apply: (_config, env) => process.env.PROTO_ISLANDS !== "0" && (env.command === "serve" || process.env.PROTO_ISLANDS === "1"),
|
|
16
|
+
configResolved(config) {
|
|
17
|
+
appRoot ??= config.root;
|
|
18
|
+
// Frameworks like SvelteKit set their own list of servable folders; .proto joins whatever list won.
|
|
19
|
+
config.server.fs.allow.push(resolve(appRoot, PROTO_DIR));
|
|
20
|
+
},
|
|
21
|
+
configureServer(server) {
|
|
22
|
+
// Proto shows the app inside its Frame, so the dev server's own "never show me in a frame" headers are dropped.
|
|
23
|
+
server.middlewares.use((_request, response, next) => {
|
|
24
|
+
const setHeader = response.setHeader.bind(response);
|
|
25
|
+
response.setHeader = (name, value) => {
|
|
26
|
+
const lower = String(name).toLowerCase();
|
|
27
|
+
if (lower === "x-frame-options") return response;
|
|
28
|
+
if (lower === "content-security-policy") {
|
|
29
|
+
const rest = String(value).split(";").map(part => part.trim()).filter(part => part && !/^frame-ancestors\b/i.test(part)).join("; ");
|
|
30
|
+
return rest ? setHeader(name, rest) : response;
|
|
31
|
+
}
|
|
32
|
+
return setHeader(name, value);
|
|
33
|
+
};
|
|
34
|
+
next();
|
|
35
|
+
});
|
|
36
|
+
watchRegistry(appRoot, files => {
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
for (const module of server.moduleGraph.getModulesByFile(file) ?? []) server.reloadModule(module);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
transform(source, id) {
|
|
43
|
+
const result = transformFile(appRoot, id.split("?")[0], source);
|
|
44
|
+
return result ? { code: result.code, map: result.map } : null;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|