@zubra/unplugin 0.0.0-stage → 5.0.3
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 +15 -0
- package/README.md +107 -2
- package/client.d.ts +32 -0
- package/package.json +74 -4
- package/src/astro-client.js +3 -0
- package/src/astro-server.js +35 -0
- package/src/astro.js +42 -0
- package/src/esbuild.js +3 -0
- package/src/index.js +372 -0
- package/src/loader.cjs +83 -0
- package/src/props.js +128 -0
- package/src/rollup.js +3 -0
- package/src/rspack.js +3 -0
- package/src/vite.js +3 -0
- package/src/webpack.js +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Mikey Nichols
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zubra/unplugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Compile `.zb` components in Vite (and Rollup, webpack, esbuild and Rspack, through [unplugin](https://github.com/unjs/unplugin)). Importing a component gives each file what its framework expects, so nobody writes integration code.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
// vite.config.js
|
|
7
|
+
import { defineConfig } from 'vite';
|
|
8
|
+
import react from '@vitejs/plugin-react';
|
|
9
|
+
import zubra from '@zubra/unplugin/vite';
|
|
10
|
+
|
|
11
|
+
export default defineConfig({ plugins: [zubra(), react()] });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
// App.tsx
|
|
16
|
+
import Rating from './rating.zb';
|
|
17
|
+
|
|
18
|
+
export function App() {
|
|
19
|
+
const [stars, setStars] = useState(1);
|
|
20
|
+
return <Rating value={stars} onValueChange={(event) => setStars(event.detail)} />;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```vue
|
|
25
|
+
<!-- App.vue -->
|
|
26
|
+
<script setup>
|
|
27
|
+
import Rating from './rating.zb';
|
|
28
|
+
const stars = ref(1);
|
|
29
|
+
</script>
|
|
30
|
+
<template><Rating v-model="stars" /></template>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## JSX components
|
|
34
|
+
|
|
35
|
+
`.zb.jsx` and `.zb.tsx` files are components too (see the main README), resolved and wrapped like `.zb` files: `import Rating from './rating.zb.tsx'` in a React file is a React component, and `?element` gives the element. TypeScript reads the component's own source for its types, so no typings file is written for it.
|
|
36
|
+
|
|
37
|
+
## What an import gives
|
|
38
|
+
|
|
39
|
+
| Imported from | Default export |
|
|
40
|
+
|---|---|
|
|
41
|
+
| a `.jsx` / `.tsx` file | a React component (`@zubra/runtime/react`, React 18 and 19) |
|
|
42
|
+
| a `.vue` file | a Vue 3 component (`@zubra/runtime/vue`) |
|
|
43
|
+
| another `.zb` component | the custom element class |
|
|
44
|
+
| any other file | the project's framework (detected from the React or Vue Vite plugin), or the element class when there is none |
|
|
45
|
+
|
|
46
|
+
Every variant defines the custom element, so `<zb-rating>` also works in plain HTML. Add `?element`, `?react` or `?vue` to an import to choose explicitly. `tag` is also exported.
|
|
47
|
+
|
|
48
|
+
- **React**: props are set as properties; `on<Event>` in any casing listens for the component's events (`onValueChange` for `valuechange`); `value` + `onValueChange` is the controlled pattern. Children go into the component's slots (`slot="name"` for named slots) and stay live as React updates them.
|
|
49
|
+
- **Vue**: props are set as DOM properties before the element starts; events are Vue events (`@valuechange`); every `<prop>` + `<prop>change` pair supports `v-model:<prop>`, and `v-model` binds `value`. Named slots (`<template #title>`) become `slot="title"` content.
|
|
50
|
+
- **Anything else** (Svelte, Solid, Preact, Lit, Angular, plain DOM) uses the element directly: properties in, DOM events out, as with built-in elements. In Svelte, Solid and Preact projects (detected from their Vite plugins) every file gets the element, and the typings are written for that framework.
|
|
51
|
+
|
|
52
|
+
## Server builds and Astro
|
|
53
|
+
|
|
54
|
+
In a server build (Vite's SSR), each component module also registers its server renderer, which renders with `@zubra/compiler` from the component's sources embedded in the bundle. `renderToString(Element, { props, attributes, slots })` from `@zubra/runtime/server` uses it. Install `@zubra/compiler` in apps that render on the server, or pass `ssr: false` to leave it out.
|
|
55
|
+
|
|
56
|
+
For Astro, add the integration (it adds this plugin to Astro's Vite config):
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// astro.config.mjs
|
|
60
|
+
import zubra from '@zubra/unplugin/astro'
|
|
61
|
+
export default { integrations: [zubra()] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```astro
|
|
65
|
+
---
|
|
66
|
+
import Rating from '../components/rating.zb'
|
|
67
|
+
---
|
|
68
|
+
<Rating value={3} class="big" /> <!-- server-rendered HTML, no JavaScript -->
|
|
69
|
+
<Rating value={3} client:load /> <!-- server-rendered, then hydrated -->
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Props go to the component, other attributes to its host, children to its slots (`slot="name"` for a named one).
|
|
73
|
+
|
|
74
|
+
## Styles
|
|
75
|
+
|
|
76
|
+
With Vite, a component's `<style>` is a CSS module in Vite's pipeline, so PostCSS, Tailwind (`@reference "tailwindcss"`, `@apply`) and preprocessors (`<style lang="scss">`) process it. Light-DOM components' CSS lands in the page's CSS bundle; shadow components get theirs as a string (`?inline`) for their shadow roots. With other bundlers, plain CSS is inlined in the module. See the main README's Styles section.
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
zubra({
|
|
82
|
+
compiler: '/path/to/zubra', // default: $ZUBRA_COMPILER, then @zubra/compiler, then `zubra` on PATH
|
|
83
|
+
framework: 'auto', // or 'react' | 'vue' | 'element': the default for plain files
|
|
84
|
+
dts: true, // write typings next to each component
|
|
85
|
+
runtime: '@zubra/runtime', // the runtime module compiled code imports
|
|
86
|
+
ssr: true, // server builds register a renderer (needs @zubra/compiler at run time)
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Types
|
|
91
|
+
|
|
92
|
+
Next to each component the plugin writes `rating.d.zb.ts`, typed for the project's framework: the React component's props and handlers, or the Vue component's props, events and `v-model`. TypeScript uses these files when `allowArbitraryExtensions` is on:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "compilerOptions": { "allowArbitraryExtensions": true, "types": ["@zubra/unplugin/client"] } }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`@zubra/unplugin/client` covers `.zb` imports that don't have typings yet (before the first build) and the `?element` form. The generated files can be committed or ignored (`*.d.zb.ts`).
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
In Vite's dev server, editing a component **updates it in place**: the elements on the page get the new template, script and styles, and keep their props, state (by the order of the script's `let`s, as long as a value's kind doesn't change) and slotted content. A change to the element's interface (its props, events, `shadow` or `form-associated`) reloads the page instead. A compile error appears in Vite's overlay with the line, a code frame and the compiler's suggestion.
|
|
103
|
+
|
|
104
|
+
Components that import the edited one are recompiled too. Other bundlers' watch modes (Rollup, webpack) recompile on change.
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
`npm test` builds real React, Vue and plain Vite apps, runs them in Chromium, type-checks the generated typings with TypeScript, and checks the dev server, `build --watch` and error reporting. It needs a Rust toolchain (the compiler is built from this repository) and Playwright with Chromium.
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Fallback typings for `.zb` imports, for files the plugin hasn't written typings next to yet.
|
|
2
|
+
// Add "@zubra/unplugin/client" to `types` in tsconfig.json; with `allowArbitraryExtensions`, the
|
|
3
|
+
// generated `x.d.zb.ts` files next to each component give exact types.
|
|
4
|
+
declare module '*.zb' {
|
|
5
|
+
const component: any;
|
|
6
|
+
export const tag: string;
|
|
7
|
+
export default component;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// An explicit variant: `import Card from './card.zb?element'` is always the element class.
|
|
11
|
+
declare module '*.zb?element' {
|
|
12
|
+
const element: CustomElementConstructor & {
|
|
13
|
+
readonly zubra: {
|
|
14
|
+
readonly tag: string;
|
|
15
|
+
readonly props: Readonly<Record<string, { readonly attribute: string; readonly default: unknown; readonly required: boolean }>>;
|
|
16
|
+
readonly events: readonly string[];
|
|
17
|
+
readonly slots: readonly string[];
|
|
18
|
+
readonly shadow: boolean;
|
|
19
|
+
readonly form: boolean;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export const tag: string;
|
|
23
|
+
export default element;
|
|
24
|
+
}
|
|
25
|
+
declare module '*.zb?react' {
|
|
26
|
+
const component: any;
|
|
27
|
+
export default component;
|
|
28
|
+
}
|
|
29
|
+
declare module '*.zb?vue' {
|
|
30
|
+
const component: any;
|
|
31
|
+
export default component;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/unplugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.3",
|
|
4
|
+
"description": "Compile .zb components in Vite, Rollup, webpack, esbuild and Rspack, with React and Vue wrappers picked automatically",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
9
|
+
"directory": "plugin"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.js",
|
|
14
|
+
"./vite": "./src/vite.js",
|
|
15
|
+
"./rollup": "./src/rollup.js",
|
|
16
|
+
"./webpack": "./src/webpack.js",
|
|
17
|
+
"./esbuild": "./src/esbuild.js",
|
|
18
|
+
"./loader": "./src/loader.cjs",
|
|
19
|
+
"./rspack": "./src/rspack.js",
|
|
20
|
+
"./astro": "./src/astro.js",
|
|
21
|
+
"./astro/server": "./src/astro-server.js",
|
|
22
|
+
"./astro/client": "./src/astro-client.js",
|
|
23
|
+
"./client": {
|
|
24
|
+
"types": "./client.d.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test \"test/*.test.js\""
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"unplugin": "3.7.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@zubra/compiler": "^5.0.0",
|
|
38
|
+
"@zubra/runtime": "^5.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@zubra/compiler": "file:../compiler",
|
|
42
|
+
"@zubra/runtime": "file:../runtime",
|
|
43
|
+
"@rollup/plugin-node-resolve": "16.0.3",
|
|
44
|
+
"@sveltejs/vite-plugin-svelte": "7.3.1",
|
|
45
|
+
"@tailwindcss/vite": "4.3.5",
|
|
46
|
+
"@types/react": "19.3.0",
|
|
47
|
+
"@types/react-dom": "19.3.0",
|
|
48
|
+
"@vitejs/plugin-react": "6.2.1",
|
|
49
|
+
"@vitejs/plugin-vue": "6.0.10",
|
|
50
|
+
"astro": "7.4.7",
|
|
51
|
+
"esbuild": "0.28.2",
|
|
52
|
+
"lit": "3.3.3",
|
|
53
|
+
"playwright": "1.63.0",
|
|
54
|
+
"react": "19.3.1",
|
|
55
|
+
"react-dom": "19.3.1",
|
|
56
|
+
"rollup": "4.68.0",
|
|
57
|
+
"sass": "1.108.1",
|
|
58
|
+
"solid-js": "1.9.18",
|
|
59
|
+
"svelte": "5.57.3",
|
|
60
|
+
"tailwindcss": "4.3.5",
|
|
61
|
+
"typescript": "7.0.2",
|
|
62
|
+
"vite": "8.3.6",
|
|
63
|
+
"vite-plugin-solid": "2.11.15",
|
|
64
|
+
"vue": "3.5.45",
|
|
65
|
+
"webpack": "5.112.2"
|
|
66
|
+
},
|
|
67
|
+
"files": [
|
|
68
|
+
"src",
|
|
69
|
+
"client.d.ts"
|
|
70
|
+
],
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"@zubra/compiler": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The Astro renderer's server side: renders a zubra element with @zubra/runtime/server.
|
|
2
|
+
import { canRender, renderToString } from '@zubra/runtime/server';
|
|
3
|
+
|
|
4
|
+
// Astro passes named slots camelCased (`slot="side-note"` → `sideNote`).
|
|
5
|
+
const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
6
|
+
|
|
7
|
+
function slotName(meta, name) {
|
|
8
|
+
if (name === 'default' || meta.slots.includes(name)) return name;
|
|
9
|
+
if (meta.slots.includes(kebab(name))) return kebab(name);
|
|
10
|
+
throw new Error(`zubra: <${meta.tag}> has no slot \`${name}\``);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
name: '@zubra/astro',
|
|
15
|
+
check(Component) {
|
|
16
|
+
return canRender(Component);
|
|
17
|
+
},
|
|
18
|
+
async renderToStaticMarkup(Component, given, slotted) {
|
|
19
|
+
const meta = Component.zubra;
|
|
20
|
+
const props = {};
|
|
21
|
+
const attributes = {};
|
|
22
|
+
for (const [name, value] of Object.entries(given ?? {})) {
|
|
23
|
+
if (value === undefined || typeof value === 'function') continue;
|
|
24
|
+
if (Object.hasOwn(meta.props, name)) props[name] = value;
|
|
25
|
+
else attributes[name] = value;
|
|
26
|
+
}
|
|
27
|
+
const slots = {};
|
|
28
|
+
for (const [name, html] of Object.entries(slotted ?? {})) {
|
|
29
|
+
const content = String(html ?? '');
|
|
30
|
+
if (content.trim()) slots[slotName(meta, name)] = content;
|
|
31
|
+
}
|
|
32
|
+
return { html: renderToString(Component, { props, attributes, slots }) };
|
|
33
|
+
},
|
|
34
|
+
supportsAstroStaticSlot: false,
|
|
35
|
+
};
|
package/src/astro.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Astro integration: `.zb` components in Astro pages, server-rendered, hydrated with a client
|
|
2
|
+
// directive.
|
|
3
|
+
//
|
|
4
|
+
// // astro.config.mjs
|
|
5
|
+
// import zubra from '@zubra/unplugin/astro'
|
|
6
|
+
// export default { integrations: [zubra()] }
|
|
7
|
+
//
|
|
8
|
+
// ---
|
|
9
|
+
// import Rating from '../components/rating.zb'
|
|
10
|
+
// ---
|
|
11
|
+
// <Rating value={3} client:load /> <!-- server-rendered, then hydrated in the browser -->
|
|
12
|
+
// <Rating value={3} /> <!-- server-rendered HTML only -->
|
|
13
|
+
//
|
|
14
|
+
// Props go to the component; other attributes (`class`, `id`, `data-*`) to its host element.
|
|
15
|
+
// Children are slot content (`slot="name"` for a named slot). Options are the plugin's.
|
|
16
|
+
import vite from './vite.js';
|
|
17
|
+
|
|
18
|
+
export default function zubra(options = {}) {
|
|
19
|
+
return {
|
|
20
|
+
name: '@zubra/astro',
|
|
21
|
+
hooks: {
|
|
22
|
+
'astro:config:setup': ({ addRenderer, updateConfig }) => {
|
|
23
|
+
addRenderer({
|
|
24
|
+
name: '@zubra/astro',
|
|
25
|
+
serverEntrypoint: '@zubra/unplugin/astro/server',
|
|
26
|
+
clientEntrypoint: '@zubra/unplugin/astro/client',
|
|
27
|
+
});
|
|
28
|
+
updateConfig({
|
|
29
|
+
vite: {
|
|
30
|
+
plugins: [vite({ framework: 'element', ...options })],
|
|
31
|
+
// The server build renders with @zubra/compiler, which loads zubra.wasm from beside
|
|
32
|
+
// itself: bundled into Astro's prerender chunks, it would look for it there. (From
|
|
33
|
+
// npm it's external anyway; linked or in a workspace, Vite would bundle it.)
|
|
34
|
+
// Astro prerenders in its own Vite environment, so this is `resolve.external` (every
|
|
35
|
+
// server environment), not `ssr.external`.
|
|
36
|
+
resolve: { external: [options.compilerModule ?? '@zubra/compiler'] },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/esbuild.js
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// @zubra/unplugin: `.zb` components in any bundler unplugin supports (Vite first).
|
|
2
|
+
//
|
|
3
|
+
// // vite.config.js
|
|
4
|
+
// import zubra from '@zubra/unplugin/vite'
|
|
5
|
+
// export default { plugins: [zubra(), react()] }
|
|
6
|
+
//
|
|
7
|
+
// // App.tsx — a React component, because this is a React file
|
|
8
|
+
// import Rating from './rating.zb'
|
|
9
|
+
// <Rating value={stars} onValueChange={(event) => setStars(event.detail)} />
|
|
10
|
+
//
|
|
11
|
+
// What an import of a `.zb` file gives depends on who imports it, so nobody writes glue code:
|
|
12
|
+
//
|
|
13
|
+
// - from a `.jsx`/`.tsx` file: a React component (`@zubra/runtime/react`);
|
|
14
|
+
// - from a `.vue` file: a Vue component (`@zubra/runtime/vue`);
|
|
15
|
+
// - from anything else (plain JS/TS, another `.zb`): the custom element class. In a project
|
|
16
|
+
// using React or Vue, plain `.js`/`.ts` files get that framework's component.
|
|
17
|
+
//
|
|
18
|
+
// Every variant defines the element, so `<zb-rating>` also works in HTML and in templates.
|
|
19
|
+
// `?element`, `?react` or `?vue` on the import picks one explicitly. Compile errors show the
|
|
20
|
+
// source line (in Vite's overlay, too); editing a `.zb` file reloads the page, because the
|
|
21
|
+
// platform can't redefine a custom element. Next to each component the plugin writes typings
|
|
22
|
+
// (`rating.d.zb.ts`, used by TypeScript with `allowArbitraryExtensions`) for the project's
|
|
23
|
+
// framework.
|
|
24
|
+
import { createUnplugin } from 'unplugin';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
27
|
+
import { addProps, zubraImports } from './props.js';
|
|
28
|
+
import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const VARIANTS = ['element', 'react', 'vue'];
|
|
31
|
+
/** A component file: `.zb`, or a JSX component (`.zb.jsx` / `.zb.tsx`). */
|
|
32
|
+
const COMPONENT = /\.zb(?:\.[jt]sx)?$/;
|
|
33
|
+
const QUERY = '?zubra=';
|
|
34
|
+
|
|
35
|
+
const split = (id) => {
|
|
36
|
+
const at = id.indexOf('?');
|
|
37
|
+
return at < 0 ? [id, ''] : [id.slice(0, at), id.slice(at + 1)];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The variant an importer gets: by explicit query, by the importer's kind, or the project's. */
|
|
41
|
+
export function variantFor(query, importer, project) {
|
|
42
|
+
const asked = VARIANTS.find((variant) => new URLSearchParams(query).has(variant));
|
|
43
|
+
if (asked) return asked;
|
|
44
|
+
if (!importer) return project;
|
|
45
|
+
const [file] = split(importer);
|
|
46
|
+
if (COMPONENT.test(file)) return 'element'; // a component, or a wrapper (`x.zb?zubra=react`)
|
|
47
|
+
if (file.endsWith('.vue')) return 'vue';
|
|
48
|
+
// JSX is React's, or Vue's in a Vue project (vue-jsx); Solid and Preact use custom elements
|
|
49
|
+
// directly.
|
|
50
|
+
if (/\.[jt]sx$/.test(file)) return project === 'vue' ? 'vue' : project === 'react' || project === 'element' ? 'react' : 'element';
|
|
51
|
+
return project === 'react' || project === 'vue' ? project : 'element';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `.zb` files a component imports, recursively, so changing one recompiles its importers. */
|
|
55
|
+
function dependencies(path, seen = new Set()) {
|
|
56
|
+
if (seen.has(path)) return seen;
|
|
57
|
+
seen.add(path);
|
|
58
|
+
let source = '';
|
|
59
|
+
try { source = readFileSync(path, 'utf8'); } catch { return seen; }
|
|
60
|
+
// A JSX component's imports are the whole file's; an .zb file's are its <script>'s.
|
|
61
|
+
const script = /\.[jt]sx$/.test(path) ? source : source.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
|
|
62
|
+
for (const [, specifier] of script.matchAll(/import\s+(?:[^'"]*?from\s+)?['"]([^'"]+\.zb(?:\.[jt]sx)?)['"]/g)) {
|
|
63
|
+
if (specifier.startsWith('.')) dependencies(resolve(dirname(path), specifier), seen);
|
|
64
|
+
}
|
|
65
|
+
return seen;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A component module in Vite's dev server: it accepts its own updates, which the runtime swaps
|
|
70
|
+
* into the live elements (keeping their state). An update that changes the element's interface
|
|
71
|
+
* (its props, events, shadow or form setting) reloads the page instead.
|
|
72
|
+
*/
|
|
73
|
+
function hot(code) {
|
|
74
|
+
// On the first line, so line numbers in errors stay the compiler's.
|
|
75
|
+
return `globalThis.__ZUBRA_HOT__ = true; ${code}
|
|
76
|
+
if (import.meta.hot) {
|
|
77
|
+
import.meta.hot.accept((next) => {
|
|
78
|
+
// (A reload, not invalidate(): a framework wrapper above accepts its own updates.)
|
|
79
|
+
if (!next || next.default?.hotReloaded === false) location.reload();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a server build appends to a component: its renderer, registered by tag. It renders with
|
|
87
|
+
* @zubra/compiler from the component's sources, embedded (keyed by path below their common
|
|
88
|
+
* directory), so the server bundle needs nothing from the build machine.
|
|
89
|
+
*/
|
|
90
|
+
function serverRenderer(path, compilerModule, runtime) {
|
|
91
|
+
const paths = [...dependencies(path)].filter((file) => existsSync(file));
|
|
92
|
+
let common = dirname(path);
|
|
93
|
+
while (!paths.every((file) => file.startsWith(common + sep))) common = dirname(common);
|
|
94
|
+
// Imports may go up (`../x/y.zb`) before coming back down: keep the directories they pass.
|
|
95
|
+
let up = 0;
|
|
96
|
+
for (const file of paths) {
|
|
97
|
+
for (const [, specifier] of readFileSync(file, 'utf8').matchAll(/import\s+(?:[^'"]*?from\s+)?['"]((?:\.\.\/)+)[^'"]*\.zb(?:\.[jt]sx)?['"]/g)) {
|
|
98
|
+
up = Math.max(up, specifier.length / 3);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (let i = 0; i < up && dirname(common) !== common; i++) common = dirname(common);
|
|
102
|
+
const key = (file) => `/${relative(common, file).split(sep).join('/')}`;
|
|
103
|
+
const files = Object.fromEntries(paths.map((file) => [key(file), readFileSync(file, 'utf8')]));
|
|
104
|
+
return `
|
|
105
|
+
import { registerRenderer as $zubraRegister } from ${JSON.stringify(`${runtime}/server`)};
|
|
106
|
+
import { request as $zubraRequest } from ${JSON.stringify(compilerModule)};
|
|
107
|
+
const $zubraFiles = ${JSON.stringify(files)};
|
|
108
|
+
$zubraRegister(tag, (props, slots) => {
|
|
109
|
+
const response = $zubraRequest({ emit: 'html', path: ${JSON.stringify(key(path))}, files: $zubraFiles, props, slots, slotSource: 'html', partial: true });
|
|
110
|
+
if (!response.ok) throw new Error(response.error);
|
|
111
|
+
return response.output;
|
|
112
|
+
});
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const unplugin = createUnplugin((options = {}, meta = {}) => {
|
|
117
|
+
// The compiler: a CLI when one is given (option or $ZUBRA_COMPILER); otherwise the WebAssembly
|
|
118
|
+
// build (@zubra/compiler) if it's installed, so no Rust toolchain is needed; else `zubra` on PATH.
|
|
119
|
+
const explicit = options.compiler ?? process.env.ZUBRA_COMPILER;
|
|
120
|
+
const compiler = explicit ?? 'zubra';
|
|
121
|
+
let wasm = null;
|
|
122
|
+
async function chooseBackend() {
|
|
123
|
+
if (explicit || wasm) return;
|
|
124
|
+
try {
|
|
125
|
+
const module = await import('@zubra/compiler');
|
|
126
|
+
if (module.available()) wasm = module;
|
|
127
|
+
} catch {
|
|
128
|
+
// Not installed: use the CLI.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const runtime = options.runtime ?? '@zubra/runtime';
|
|
132
|
+
let project = options.framework && options.framework !== 'auto' ? options.framework : null;
|
|
133
|
+
const typings = options.dts ?? true;
|
|
134
|
+
// Server builds render components with @zubra/compiler at request time (`ssr: false` leaves
|
|
135
|
+
// that out: the elements then render on the client only).
|
|
136
|
+
const serverRendering = options.ssr ?? true;
|
|
137
|
+
let ssrFramework = null; // React Router, SvelteKit or Nuxt (Vite): see the light-DOM warning
|
|
138
|
+
const warnedLight = new Set();
|
|
139
|
+
const relativePath = (path) => path.startsWith(process.cwd()) ? path.slice(process.cwd().length + 1) : path;
|
|
140
|
+
const compilerModule = options.compilerModule ?? '@zubra/compiler';
|
|
141
|
+
let dev = false; // Vite's dev server: components hot-reload
|
|
142
|
+
|
|
143
|
+
/** CLI flags → a request for the WebAssembly compiler. */
|
|
144
|
+
function asRequest(args) {
|
|
145
|
+
const request = { emit: 'html' };
|
|
146
|
+
for (let i = 0; i < args.length; i++) {
|
|
147
|
+
const flag = args[i];
|
|
148
|
+
if (flag === '--keep-imports') request.keepImports = true;
|
|
149
|
+
else if (flag === '--no-imports') request.noImports = true;
|
|
150
|
+
else {
|
|
151
|
+
const key = { '--emit': 'emit', '--framework': 'framework', '--runtime': 'runtime', '--css-import': 'cssImport', '--css-import-inline': 'cssImportInline' }[flag];
|
|
152
|
+
if (!key) throw new Error(`zubra: unexpected compiler flag ${flag}`);
|
|
153
|
+
request[key] = args[++i];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return request;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function compile(path, ...args) {
|
|
160
|
+
if (wasm) {
|
|
161
|
+
const response = wasm.compileFile(path, asRequest(args));
|
|
162
|
+
if (!response.ok) throw new Error(response.error);
|
|
163
|
+
return { code: response.output, warnings: response.warnings.join('').trim() };
|
|
164
|
+
}
|
|
165
|
+
const result = spawnSync(compiler, ['compile', path, ...args], { encoding: 'utf8' });
|
|
166
|
+
if (result.error) {
|
|
167
|
+
throw new Error(`zubra: couldn't run the compiler \`${compiler}\` (${result.error.code ?? result.error.message}). `
|
|
168
|
+
+ 'Install @zubra/compiler (the WebAssembly build), install the CLI, or pass { compiler: "/path/to/zubra" }.');
|
|
169
|
+
}
|
|
170
|
+
if (result.status !== 0) throw new Error(result.stderr.trim() || `zubra: the compiler exited with ${result.status}`);
|
|
171
|
+
return { code: result.stdout, warnings: result.stderr.trim() };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A compile error for the bundler: with Vite, `loc` and `frame` put it in the dev overlay with
|
|
176
|
+
* the source line and the compiler's help. Always throws: Rollup's and Vite's `error()` throw,
|
|
177
|
+
* but esbuild's and Bun's (through unplugin) only record the error, and drop it when the hook
|
|
178
|
+
* returns nothing.
|
|
179
|
+
*/
|
|
180
|
+
function fail(context, error, id) {
|
|
181
|
+
const text = String(error.message ?? error);
|
|
182
|
+
const at = text.match(/-->\s+(.+?):(\d+):(\d+)/);
|
|
183
|
+
const message = text.split('\n')[0].replace(/^error:\s*/, '');
|
|
184
|
+
if (!at) context.error(text);
|
|
185
|
+
else {
|
|
186
|
+
context.error({
|
|
187
|
+
message: `${message}\n\n${text}`,
|
|
188
|
+
id,
|
|
189
|
+
loc: { file: at[1], line: Number(at[2]), column: Number(at[3]) },
|
|
190
|
+
frame: text.split('\n').slice(1).join('\n'),
|
|
191
|
+
plugin: 'zubra',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
throw new Error(text);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Each component's tag and prop names, for `zb-props` (by path, while its file is unchanged).
|
|
198
|
+
const interfaces = new Map();
|
|
199
|
+
function interfaceOf(path) {
|
|
200
|
+
const stamp = statSync(path).mtimeMs;
|
|
201
|
+
const known = interfaces.get(path);
|
|
202
|
+
if (known?.stamp === stamp) return known.value;
|
|
203
|
+
const description = JSON.parse(compile(path, '--emit', 'interface').code);
|
|
204
|
+
const value = { tag: description.tag, props: new Set(description.props.map((prop) => prop.name)) };
|
|
205
|
+
interfaces.set(path, { stamp, value });
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function writeTypings(path) {
|
|
210
|
+
const framework = ['react', 'vue', 'svelte', 'solid'].includes(project) ? project : 'none';
|
|
211
|
+
const target = resolve(dirname(path), `${basename(path, '.zb')}.d.zb.ts`);
|
|
212
|
+
const contents = compile(path, '--emit', 'dts', '--framework', framework).code;
|
|
213
|
+
// Only when changed, so watchers don't loop.
|
|
214
|
+
if (!existsSync(target) || readFileSync(target, 'utf8') !== contents) writeFileSync(target, contents);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
name: 'zubra',
|
|
219
|
+
enforce: 'pre',
|
|
220
|
+
|
|
221
|
+
async buildStart() {
|
|
222
|
+
await chooseBackend();
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async resolveId(source, importer) {
|
|
226
|
+
const [specifier, query] = split(source);
|
|
227
|
+
if (!COMPONENT.test(specifier)) return null;
|
|
228
|
+
let path;
|
|
229
|
+
if (specifier.startsWith('/') && existsSync(specifier)) path = specifier;
|
|
230
|
+
else if (importer && specifier.startsWith('.')) path = resolve(dirname(split(importer)[0]), specifier);
|
|
231
|
+
else {
|
|
232
|
+
const resolved = await this.resolve?.(specifier, importer, { skipSelf: true });
|
|
233
|
+
if (!resolved) return null;
|
|
234
|
+
path = split(resolved.id)[0];
|
|
235
|
+
}
|
|
236
|
+
// A component's stylesheet keeps its whole query: Vite (and Tailwind) recognize CSS by its
|
|
237
|
+
// `&lang.css` suffix, and inline CSS by `&inline`.
|
|
238
|
+
if (new URLSearchParams(query).get('zubra') === 'style') return `${path}?${query}`;
|
|
239
|
+
// A variant already chosen (a wrapper's import of its element, a dev server URL) stays.
|
|
240
|
+
const variant = new URLSearchParams(query).get('zubra') ?? variantFor(query, importer, project ?? 'element');
|
|
241
|
+
return `${path}${QUERY}${variant}`;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
loadInclude(id) {
|
|
245
|
+
return /\.zb(?:\.[jt]sx)?\?zubra=/.test(id);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
load(id, loadOptions) {
|
|
249
|
+
// A server build (Vite's SSR, or its server environments): the module also registers its
|
|
250
|
+
// server renderer.
|
|
251
|
+
const server = loadOptions?.ssr === true || this?.environment?.config?.consumer === 'server';
|
|
252
|
+
const [path, query] = split(id);
|
|
253
|
+
const variant = new URLSearchParams(query).get('zubra');
|
|
254
|
+
if (variant === 'style') {
|
|
255
|
+
// This component's stylesheet, for the bundler's CSS pipeline (PostCSS, Tailwind,
|
|
256
|
+
// preprocessors) to process.
|
|
257
|
+
try {
|
|
258
|
+
return compile(path, '--emit', 'css', '--no-imports').code;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return fail(this, error, path);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (variant === 'react' || variant === 'vue') {
|
|
264
|
+
const element = JSON.stringify(`${path}${QUERY}element`);
|
|
265
|
+
// In development the wrapper accepts its updates: the element it wraps hot-reloads itself.
|
|
266
|
+
return `import Element from ${element};\nimport { wrap } from ${JSON.stringify(`${runtime}/${variant}`)};\n`
|
|
267
|
+
+ `export { tag } from ${element};\nexport default wrap(Element);\n`
|
|
268
|
+
+ (dev && meta.framework === 'vite' ? 'if (import.meta.hot) import.meta.hot.accept(() => {});\n' : '');
|
|
269
|
+
}
|
|
270
|
+
for (const dependency of dependencies(path)) this.addWatchFile?.(dependency);
|
|
271
|
+
if (server && ssrFramework && path.endsWith('.zb') && !warnedLight.has(path) && isLightDom(readFileSync(path, 'utf8'))) {
|
|
272
|
+
warnedLight.add(path);
|
|
273
|
+
// Since 3.3 the React and Vue wrappers server-render a light-DOM element's content and
|
|
274
|
+
// keep the framework out of the host while hydrating, so light DOM works there when the
|
|
275
|
+
// element is given no framework children. Svelte uses the element directly, claims every
|
|
276
|
+
// node, and fails on the element's own.
|
|
277
|
+
if (ssrFramework === 'SvelteKit') {
|
|
278
|
+
this.warn(`${relativePath(path)} is a light-DOM component, and SvelteKit hydrates the server's HTML: when the element renders into its host first, Svelte fails to hydrate the page. `
|
|
279
|
+
+ 'Add `shadow` to its host tag (see examples/meta/README.md).');
|
|
280
|
+
} else {
|
|
281
|
+
this.warn(`${relativePath(path)} is a light-DOM component: in ${ssrFramework}, that works when the element is given no children from ${ssrFramework === 'Nuxt' ? 'Vue' : 'React'} (its content is server-rendered and hydrated in place); `
|
|
282
|
+
+ `an element given children must be \`shadow\`, or ${ssrFramework === 'Nuxt' ? 'Vue removes what it finds there' : 'React re-renders the page'} (see examples/meta/README.md).`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// (A JSX component is typed by its own source: TypeScript reads the function.)
|
|
286
|
+
if (typings && path.endsWith('.zb')) writeTypings(path);
|
|
287
|
+
// With Vite, a <style> goes through Vite's CSS pipeline; elsewhere it's inlined as written.
|
|
288
|
+
const styles = [];
|
|
289
|
+
if (meta.framework === 'vite' && path.endsWith('.zb') && /<style[\s>]/i.test(readFileSync(path, 'utf8'))) {
|
|
290
|
+
const lang = readFileSync(path, 'utf8').match(/<style[^>]*\slang=["']([a-z0-9]+)["']/i)?.[1] ?? 'css';
|
|
291
|
+
styles.push('--css-import', `${path}?zubra=style&lang.${lang}`, '--css-import-inline', `${path}?zubra=style&inline&lang.${lang}`);
|
|
292
|
+
}
|
|
293
|
+
let output;
|
|
294
|
+
try {
|
|
295
|
+
output = compile(path, '--emit', 'js', '--keep-imports', '--runtime', runtime, ...styles);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return fail(this, error, path);
|
|
298
|
+
}
|
|
299
|
+
if (output.warnings) this.warn(output.warnings);
|
|
300
|
+
if (server) return serverRendering ? output.code + serverRenderer(path, compilerModule, runtime) : output.code;
|
|
301
|
+
return dev && meta.framework === 'vite' ? hot(output.code) : output.code;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
rollup: {
|
|
305
|
+
// The runtime loads remark.js (stripped markup) with a dynamic import, which is a second
|
|
306
|
+
// chunk; a single-file build (`output.file`) inlines it instead of failing.
|
|
307
|
+
outputOptions(outputOptions) {
|
|
308
|
+
if (outputOptions.file && !outputOptions.dir && outputOptions.inlineDynamicImports === undefined) {
|
|
309
|
+
return { ...outputOptions, inlineDynamicImports: true };
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
vite: {
|
|
316
|
+
config() {
|
|
317
|
+
// One copy of each framework, even when @zubra/runtime is linked from elsewhere.
|
|
318
|
+
return { resolve: { dedupe: ['react', 'react-dom', 'vue', '@angular/core', '@angular/common', '@angular/forms'] } };
|
|
319
|
+
},
|
|
320
|
+
configResolved(config) {
|
|
321
|
+
const names = config.plugins.map((plugin) => plugin.name);
|
|
322
|
+
// Frameworks that hydrate the server's HTML around elements (Solid doesn't mind).
|
|
323
|
+
ssrFramework = names.includes('react-router') ? 'React Router'
|
|
324
|
+
: names.some((name) => name.startsWith('vite-plugin-sveltekit')) ? 'SvelteKit'
|
|
325
|
+
: names.some((name) => name.startsWith('nuxt:')) ? 'Nuxt' : null;
|
|
326
|
+
if (project) return;
|
|
327
|
+
if (names.some((name) => /^vite:react/.test(name) || name === 'react-router')) project = 'react';
|
|
328
|
+
else if (names.includes('vite:vue')) project = 'vue';
|
|
329
|
+
else if (names.includes('solid')) project = 'solid';
|
|
330
|
+
else if (names.some((name) => name.startsWith('preact'))) project = 'preact';
|
|
331
|
+
else if (names.some((name) => name.startsWith('vite-plugin-svelte'))) project = 'svelte';
|
|
332
|
+
else project = 'element';
|
|
333
|
+
},
|
|
334
|
+
configureServer() {
|
|
335
|
+
dev = true;
|
|
336
|
+
},
|
|
337
|
+
// Svelte and Solid use the elements directly: in their server builds, the hosts get
|
|
338
|
+
// `zb-props` (see props.js), for @zubra/server's injectShadowRoots().
|
|
339
|
+
async transform(code, id, transformOptions) {
|
|
340
|
+
const dialect = project === 'svelte' ? 'svelte' : project === 'solid' ? 'solid' : null;
|
|
341
|
+
if (!dialect) return null;
|
|
342
|
+
const server = transformOptions?.ssr === true || this?.environment?.config?.consumer === 'server';
|
|
343
|
+
const [path] = split(id);
|
|
344
|
+
if (!server || path.includes('/node_modules/') || COMPONENT.test(path)) return null;
|
|
345
|
+
if (dialect === 'svelte' ? !path.endsWith('.svelte') : !/\.[jt]sx$/.test(path)) return null;
|
|
346
|
+
const tags = new Map();
|
|
347
|
+
for (const specifier of zubraImports(code)) {
|
|
348
|
+
const resolved = specifier.startsWith('.') ? resolve(dirname(path), specifier) : (await this.resolve(specifier, id, { skipSelf: true }))?.id;
|
|
349
|
+
const file = resolved && split(resolved)[0];
|
|
350
|
+
if (!file || !existsSync(file)) continue;
|
|
351
|
+
try {
|
|
352
|
+
const { tag, props } = interfaceOf(file);
|
|
353
|
+
tags.set(tag, props);
|
|
354
|
+
} catch {
|
|
355
|
+
// A compile error shows when the component itself loads.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const code2 = addProps(code, tags, dialect);
|
|
359
|
+
return code2 == null ? null : { code: code2, map: null };
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** Whether a `.zb` component renders into light DOM (no `shadow` on its host tag). */
|
|
366
|
+
export function isLightDom(source) {
|
|
367
|
+
const markup = source.replace(/<!--[^]*?-->/g, '').replace(/<(script|style)\b[^]*?<\/\1>/gi, '');
|
|
368
|
+
const host = markup.match(/<([a-z][a-z0-9]*-[a-z0-9-]*)\b([^>]*)>/i);
|
|
369
|
+
return !!host && !/(^|\s)shadow(\s|=|$|\/)/.test(host[2]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default unplugin;
|
package/src/loader.cjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A webpack-style loader for `.zb` files, for bundlers that take loaders but not plugins:
|
|
2
|
+
// Next.js's Turbopack (`turbopack.rules`), and webpack itself.
|
|
3
|
+
//
|
|
4
|
+
// // next.config.js
|
|
5
|
+
// export default {
|
|
6
|
+
// turbopack: { rules: { '*.zb': { loaders: ['@zubra/unplugin/loader'], as: '*.js' } } },
|
|
7
|
+
// };
|
|
8
|
+
//
|
|
9
|
+
// The module defines the element (importing a component's own `.zb` imports, which go through
|
|
10
|
+
// the loader too) and, as its default export, the element as a React component (a client
|
|
11
|
+
// component). `?element` imports the element itself instead. Options: `{ runtime, react: false,
|
|
12
|
+
// server: true }`.
|
|
13
|
+
//
|
|
14
|
+
// `server: true`, for the server's build, also registers how to render the component there
|
|
15
|
+
// (with @zubra/compiler, from its sources embedded in the module). Then the React wrapper renders
|
|
16
|
+
// a shadow-DOM component's declarative shadow root into the server's HTML, so it shows before
|
|
17
|
+
// JavaScript (Next.js, which has no HTML hook for @zubra/server's injectShadowRoots):
|
|
18
|
+
//
|
|
19
|
+
// '*.zb': [
|
|
20
|
+
// { condition: 'browser', loaders: ['@zubra/unplugin/loader'], as: '*.js' },
|
|
21
|
+
// { condition: { not: 'browser' }, loaders: [{ loader: '@zubra/unplugin/loader', options: { server: true } }], as: '*.js' },
|
|
22
|
+
// ],
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { existsSync } = require('node:fs');
|
|
26
|
+
const { dirname, join, relative, sep } = require('node:path');
|
|
27
|
+
|
|
28
|
+
// Inside the zubra repository, the CLI from `cargo build` stands in for @zubra/compiler's
|
|
29
|
+
// WebAssembly build (as in the plugin).
|
|
30
|
+
function findCli(from) {
|
|
31
|
+
for (let dir = from, up = 0; up < 10; up++, dir = dirname(dir)) {
|
|
32
|
+
for (const profile of ['debug', 'release']) {
|
|
33
|
+
const cli = join(dir, 'target', profile, `zubra${process.platform === 'win32' ? '.exe' : ''}`);
|
|
34
|
+
if (existsSync(cli)) return cli;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The module's server renderer: @zubra/compiler, from the component's sources (by relative path). */
|
|
41
|
+
function serverRenderer(path, files, runtime, compilerModule) {
|
|
42
|
+
const paths = Object.keys(files);
|
|
43
|
+
let common = dirname(path);
|
|
44
|
+
while (!paths.every((file) => file.startsWith(common + sep))) common = dirname(common);
|
|
45
|
+
const key = (file) => `/${relative(common, file).split(sep).join('/')}`;
|
|
46
|
+
const embedded = Object.fromEntries(paths.map((file) => [key(file), files[file]]));
|
|
47
|
+
return `
|
|
48
|
+
import { registerRenderer as $zubraRegister } from ${JSON.stringify(`${runtime}/server`)};
|
|
49
|
+
import { request as $zubraRequest } from ${JSON.stringify(compilerModule)};
|
|
50
|
+
$zubraRegister(tag, (props, slots) => {
|
|
51
|
+
const response = $zubraRequest({ emit: 'html', path: ${JSON.stringify(key(path))}, files: ${JSON.stringify(embedded)}, props, slots, slotSource: 'html', partial: true });
|
|
52
|
+
if (!response.ok) throw new Error(response.error);
|
|
53
|
+
return response.output;
|
|
54
|
+
});
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = function zubraLoader() {
|
|
59
|
+
const callback = this.async();
|
|
60
|
+
const options = (typeof this.getOptions === 'function' ? this.getOptions() : this.query) || {};
|
|
61
|
+
const runtime = options.runtime || '@zubra/runtime';
|
|
62
|
+
const path = this.resourcePath;
|
|
63
|
+
const element = /(^|[?&])element\b/.test(this.resourceQuery || '');
|
|
64
|
+
import('@zubra/compiler')
|
|
65
|
+
.then((compiler) => {
|
|
66
|
+
if (!compiler.available() && !process.env.ZUBRA_COMPILER) {
|
|
67
|
+
const cli = findCli(this.rootContext || process.cwd()) || findCli(__dirname);
|
|
68
|
+
if (cli) process.env.ZUBRA_COMPILER = cli;
|
|
69
|
+
}
|
|
70
|
+
const files = compiler.sources(path);
|
|
71
|
+
for (const file of Object.keys(files)) if (file !== path) this.addDependency(file);
|
|
72
|
+
const response = compiler.compileFile(path, { emit: 'js', runtime, keepImports: true });
|
|
73
|
+
if (!response.ok) throw new Error(response.error);
|
|
74
|
+
for (const warning of response.warnings) this.emitWarning(new Error(warning));
|
|
75
|
+
let code = response.output;
|
|
76
|
+
if (options.server) code += serverRenderer(path, files, runtime, options.compilerModule || '@zubra/compiler');
|
|
77
|
+
if (element || options.react === false) return callback(null, code);
|
|
78
|
+
// The element, and the element as a React component (React's hooks: a client component).
|
|
79
|
+
code = code.replace(/^export default \$define\(/m, 'const $element = $define(');
|
|
80
|
+
callback(null, `'use client';\n${code}\nimport { wrap as $wrap } from ${JSON.stringify(`${runtime}/react`)};\nexport { $element as Element };\nexport default $wrap($element);\n`);
|
|
81
|
+
})
|
|
82
|
+
.catch((error) => callback(error));
|
|
83
|
+
};
|
package/src/props.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// `zb-props` for Svelte and Solid. React's and Vue's adapters write it themselves; Svelte and Solid
|
|
2
|
+
// use the elements directly, and their server HTML has no object props (Svelte writes
|
|
3
|
+
// "[object Object]", Solid's `prop:` values are properties). So in their server builds, each zubra
|
|
4
|
+
// host in a component's markup gets `zb-props={JSON.stringify({ … })}` from the attributes that
|
|
5
|
+
// set its props, and `@zubra/server`'s injectShadowRoots() renders it complete.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `code` with `zb-props` added to the hosts of `tags` (tag → its prop names). `dialect` is
|
|
9
|
+
* `svelte` (`log={…}`, `{log}`, `bind:value={…}`) or `solid` (`prop:items={…}`, `items={…}`).
|
|
10
|
+
*/
|
|
11
|
+
export function addProps(code, tags, dialect) {
|
|
12
|
+
if (!tags.size) return null;
|
|
13
|
+
const names = [...tags.keys()].map((tag) => tag.replace(/[-]/g, '\\-')).join('|');
|
|
14
|
+
const opening = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
|
|
15
|
+
const edits = [];
|
|
16
|
+
for (let match; (match = opening.exec(code)); ) {
|
|
17
|
+
const props = tags.get(match[1]);
|
|
18
|
+
const attributes = readAttributes(code, match.index + match[0].length);
|
|
19
|
+
if (!attributes) continue;
|
|
20
|
+
opening.lastIndex = attributes.end;
|
|
21
|
+
if (attributes.list.some((attribute) => attribute.name === 'zb-props')) continue;
|
|
22
|
+
const entries = [];
|
|
23
|
+
for (const { name, expression, shorthand } of attributes.list) {
|
|
24
|
+
if (expression == null) continue;
|
|
25
|
+
const prop = dialect === 'solid' ? name.replace(/^prop:/, '') : name.replace(/^bind:/, '');
|
|
26
|
+
if (!props.has(prop)) continue;
|
|
27
|
+
if (shorthand) entries.push(prop);
|
|
28
|
+
else entries.push(`${JSON.stringify(prop)}: (${expression})`);
|
|
29
|
+
}
|
|
30
|
+
if (!entries.length) continue;
|
|
31
|
+
edits.push({ at: match.index + match[0].length, text: ` zb-props={JSON.stringify({ ${entries.join(', ')} })}` });
|
|
32
|
+
}
|
|
33
|
+
if (!edits.length) return null;
|
|
34
|
+
let out = code;
|
|
35
|
+
for (const { at, text } of edits.reverse()) out = out.slice(0, at) + text + out.slice(at);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The attributes of a tag whose name ends at `start`, up to its `>`: `{ list, end }`, or null. */
|
|
40
|
+
export function readAttributes(code, start) {
|
|
41
|
+
const list = [];
|
|
42
|
+
let i = start;
|
|
43
|
+
while (i < code.length) {
|
|
44
|
+
while (/\s/.test(code[i])) i++;
|
|
45
|
+
if (code[i] === '>') return { list, end: i + 1 };
|
|
46
|
+
if (code[i] === '/' && code[i + 1] === '>') return { list, end: i + 2 };
|
|
47
|
+
if (code[i] === '{') {
|
|
48
|
+
// Svelte's shorthand (`{log}`) or a spread (`{...rest}`).
|
|
49
|
+
const end = closeBrace(code, i);
|
|
50
|
+
if (end < 0) return null;
|
|
51
|
+
const inner = code.slice(i + 1, end).trim();
|
|
52
|
+
if (/^[A-Za-z_$][\w$]*$/.test(inner)) list.push({ name: inner, expression: inner, shorthand: true });
|
|
53
|
+
i = end + 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const name = /^[^\s=/>{"']+/.exec(code.slice(i))?.[0];
|
|
57
|
+
if (!name) return null;
|
|
58
|
+
i += name.length;
|
|
59
|
+
while (/\s/.test(code[i])) i++;
|
|
60
|
+
if (code[i] !== '=') {
|
|
61
|
+
list.push({ name, expression: null });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
i++;
|
|
65
|
+
while (/\s/.test(code[i])) i++;
|
|
66
|
+
if (code[i] === '"' || code[i] === "'") {
|
|
67
|
+
const end = code.indexOf(code[i], i + 1);
|
|
68
|
+
if (end < 0) return null;
|
|
69
|
+
list.push({ name, expression: null, text: code.slice(i + 1, end) });
|
|
70
|
+
i = end + 1;
|
|
71
|
+
} else if (code[i] === '{') {
|
|
72
|
+
const end = closeBrace(code, i);
|
|
73
|
+
if (end < 0) return null;
|
|
74
|
+
list.push({ name, expression: code.slice(i + 1, end).trim() });
|
|
75
|
+
i = end + 1;
|
|
76
|
+
} else {
|
|
77
|
+
const value = /^[^\s>]+/.exec(code.slice(i))?.[0] ?? '';
|
|
78
|
+
list.push({ name, expression: null, text: value });
|
|
79
|
+
i += value.length;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The index of the `}` closing the `{` at `start` (strings and template literals skipped), or -1. */
|
|
86
|
+
function closeBrace(code, start) {
|
|
87
|
+
let depth = 0;
|
|
88
|
+
for (let i = start; i < code.length; i++) {
|
|
89
|
+
const c = code[i];
|
|
90
|
+
if (c === '"' || c === "'") {
|
|
91
|
+
i = skipString(code, i);
|
|
92
|
+
if (i < 0) return -1;
|
|
93
|
+
} else if (c === '`') {
|
|
94
|
+
i = skipTemplate(code, i);
|
|
95
|
+
if (i < 0) return -1;
|
|
96
|
+
} else if (c === '{') depth++;
|
|
97
|
+
else if (c === '}' && --depth === 0) return i;
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function skipString(code, start) {
|
|
103
|
+
for (let i = start + 1; i < code.length; i++) {
|
|
104
|
+
if (code[i] === '\\') i++;
|
|
105
|
+
else if (code[i] === code[start]) return i;
|
|
106
|
+
}
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function skipTemplate(code, start) {
|
|
111
|
+
for (let i = start + 1; i < code.length; i++) {
|
|
112
|
+
if (code[i] === '\\') i++;
|
|
113
|
+
else if (code[i] === '`') return i;
|
|
114
|
+
else if (code[i] === '$' && code[i + 1] === '{') {
|
|
115
|
+
i = closeBrace(code, i + 1);
|
|
116
|
+
if (i < 0) return -1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The `.zb` files a module imports (specifiers, as written). */
|
|
123
|
+
export function zubraImports(code) {
|
|
124
|
+
const found = [];
|
|
125
|
+
const pattern = /\bimport\s+(?:[^'"]*?\sfrom\s+)?['"]([^'"]+\.zb(?:\.[jt]sx)?)(?:\?[^'"]*)?['"]/g;
|
|
126
|
+
for (let match; (match = pattern.exec(code)); ) found.push(match[1]);
|
|
127
|
+
return found;
|
|
128
|
+
}
|
package/src/rollup.js
ADDED
package/src/rspack.js
ADDED
package/src/vite.js
ADDED
package/src/webpack.js
ADDED