create-zubra 0.0.0-stage → 5.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 +15 -0
- package/index.js +105 -0
- package/package.json +23 -4
- package/templates/angular/index.html +13 -0
- package/templates/angular/package.json +30 -0
- package/templates/angular/src/app.ts +19 -0
- package/templates/angular/src/main.ts +5 -0
- package/templates/angular/src/style.css +3 -0
- package/templates/angular/tsconfig.json +22 -0
- package/templates/angular/vite.config.js +7 -0
- package/templates/astro/astro.config.mjs +5 -0
- package/templates/astro/package.json +20 -0
- package/templates/astro/src/pages/index.astro +23 -0
- package/templates/astro/src/style.css +3 -0
- package/templates/astro/test/build.js +9 -0
- package/templates/lit/index.html +13 -0
- package/templates/lit/package.json +21 -0
- package/templates/lit/src/main.js +24 -0
- package/templates/lit/src/style.css +3 -0
- package/templates/lit/vite.config.js +3 -0
- package/templates/preact/index.html +13 -0
- package/templates/preact/package.json +22 -0
- package/templates/preact/src/App.jsx +14 -0
- package/templates/preact/src/main.jsx +4 -0
- package/templates/preact/src/style.css +3 -0
- package/templates/preact/vite.config.js +4 -0
- package/templates/react/index.html +13 -0
- package/templates/react/package.json +23 -0
- package/templates/react/src/App.jsx +14 -0
- package/templates/react/src/main.jsx +4 -0
- package/templates/react/src/style.css +3 -0
- package/templates/react/vite.config.js +4 -0
- package/templates/shared/AGENTS.md +10 -0
- package/templates/shared/README.md +17 -0
- package/templates/shared/_gitignore +5 -0
- package/templates/shared/skills/zubra-component/SKILL.md +93 -0
- package/templates/shared/skills/zubra-consume/SKILL.md +42 -0
- package/templates/shared/src/components/hello-counter.zb +25 -0
- package/templates/shared/test/app.test.js +49 -0
- package/templates/shared/test/build.js +9 -0
- package/templates/solid/index.html +13 -0
- package/templates/solid/package.json +22 -0
- package/templates/solid/src/App.jsx +14 -0
- package/templates/solid/src/main.jsx +4 -0
- package/templates/solid/src/style.css +3 -0
- package/templates/solid/vite.config.js +4 -0
- package/templates/svelte/index.html +13 -0
- package/templates/svelte/package.json +22 -0
- package/templates/svelte/src/App.svelte +12 -0
- package/templates/svelte/src/main.js +4 -0
- package/templates/svelte/src/style.css +3 -0
- package/templates/svelte/vite.config.js +4 -0
- package/templates/vanilla/index.html +17 -0
- package/templates/vanilla/package.json +20 -0
- package/templates/vanilla/src/main.js +7 -0
- package/templates/vanilla/src/style.css +3 -0
- package/templates/vanilla/vite.config.js +3 -0
- package/templates/vue/index.html +13 -0
- package/templates/vue/package.json +22 -0
- package/templates/vue/src/App.vue +15 -0
- package/templates/vue/src/main.js +4 -0
- package/templates/vue/src/style.css +3 -0
- package/templates/vue/vite.config.js +4 -0
- package/README.md +0 -3
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/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-zubra: write a working app that uses zubra components.
|
|
3
|
+
//
|
|
4
|
+
// npm create zubra asks for a directory and a framework
|
|
5
|
+
// npm create zubra my-app -- --framework react
|
|
6
|
+
//
|
|
7
|
+
// The app has one component (src/components/hello-counter.zb), the plugin wired up, typings,
|
|
8
|
+
// and a browser test (`npm test`). Nothing is installed: the last lines say what to run.
|
|
9
|
+
import { cpSync, existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
11
|
+
import { createInterface } from 'node:readline/promises';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export const FRAMEWORKS = {
|
|
15
|
+
vanilla: 'No framework (plain HTML and JavaScript)',
|
|
16
|
+
react: 'React',
|
|
17
|
+
vue: 'Vue',
|
|
18
|
+
svelte: 'Svelte',
|
|
19
|
+
solid: 'Solid',
|
|
20
|
+
preact: 'Preact',
|
|
21
|
+
lit: 'Lit',
|
|
22
|
+
angular: 'Angular',
|
|
23
|
+
astro: 'Astro (server-rendered pages)',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const templates = fileURLToPath(new URL('./templates/', import.meta.url));
|
|
27
|
+
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
28
|
+
|
|
29
|
+
/** Write the app for `framework` into `dir` (which must be empty or missing); returns the files written. */
|
|
30
|
+
export function create(dir, framework, { name = basename(resolve(dir)) } = {}) {
|
|
31
|
+
if (!(framework in FRAMEWORKS)) throw new Error(`create-zubra: unknown framework "${framework}"; one of ${Object.keys(FRAMEWORKS).join(', ')}`);
|
|
32
|
+
const target = resolve(dir);
|
|
33
|
+
if (existsSync(target) && readdirSync(target).length) throw new Error(`create-zubra: ${dir} isn't empty`);
|
|
34
|
+
cpSync(join(templates, 'shared'), target, { recursive: true });
|
|
35
|
+
cpSync(join(templates, framework), target, { recursive: true });
|
|
36
|
+
// npm drops .gitignore files from packages, so the template's is named _gitignore.
|
|
37
|
+
renameSync(join(target, '_gitignore'), join(target, '.gitignore'));
|
|
38
|
+
const files = walk(target);
|
|
39
|
+
const values = { name: packageName(name), title: name.replace(/[<>{}"'`&\\$]/g, '') || 'Zubra app', version, framework: FRAMEWORKS[framework].replace(/ \(.*/, '') };
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const text = readFileSync(file, 'utf8');
|
|
42
|
+
const filled = text.replace(/\{\{(name|title|version|framework)\}\}/g, (_, key) => values[key]);
|
|
43
|
+
if (filled !== text) writeFileSync(file, filled);
|
|
44
|
+
}
|
|
45
|
+
return files.map((file) => relative(target, file)).sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const walk = (dir) => readdirSync(dir).flatMap((entry) => {
|
|
49
|
+
const path = join(dir, entry);
|
|
50
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const packageName = (name) => name.toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^[-._]+|[-._]+$/g, '') || 'zb-app';
|
|
54
|
+
|
|
55
|
+
/** `[dir] [--framework <name>] [--yes]` → `{ dir, framework, yes, help }`. */
|
|
56
|
+
export function parse(argv) {
|
|
57
|
+
const options = { dir: null, framework: null, yes: false, help: false };
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
if (arg === '--framework' || arg === '-f' || arg === '--template' || arg === '-t') options.framework = argv[++i];
|
|
61
|
+
else if (arg.startsWith('--framework=') || arg.startsWith('--template=')) options.framework = arg.slice(arg.indexOf('=') + 1);
|
|
62
|
+
else if (arg === '--yes' || arg === '-y') options.yes = true;
|
|
63
|
+
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
64
|
+
else if (!arg.startsWith('-') && !options.dir) options.dir = arg;
|
|
65
|
+
else throw new Error(`create-zubra: unexpected argument ${arg}`);
|
|
66
|
+
}
|
|
67
|
+
return options;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main(argv) {
|
|
71
|
+
const options = parse(argv);
|
|
72
|
+
if (options.help) {
|
|
73
|
+
console.log(`usage: npm create zubra [directory] -- [--framework ${Object.keys(FRAMEWORKS).join('|')}] [--yes]`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const ask = !options.yes && process.stdin.isTTY;
|
|
77
|
+
const prompt = ask ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
78
|
+
try {
|
|
79
|
+
let { dir, framework } = options;
|
|
80
|
+
if (!dir) dir = ask ? (await prompt.question('Directory (zb-app): ')).trim() || 'zb-app' : 'zb-app';
|
|
81
|
+
if (!framework) {
|
|
82
|
+
const names = Object.keys(FRAMEWORKS);
|
|
83
|
+
if (ask) {
|
|
84
|
+
console.log(names.map((key, i) => ` ${i + 1}. ${FRAMEWORKS[key]}`).join('\n'));
|
|
85
|
+
const answer = (await prompt.question('Framework (1): ')).trim();
|
|
86
|
+
framework = names[Number(answer || 1) - 1] ?? answer;
|
|
87
|
+
} else {
|
|
88
|
+
framework = 'vanilla';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
create(dir, framework);
|
|
92
|
+
const cd = relative(process.cwd(), resolve(dir)) || '.';
|
|
93
|
+
console.log(`\nA Zubra app (${FRAMEWORKS[framework]}) is in ${cd}. Next:\n\n${cd === '.' ? '' : ` cd ${cd}\n`} npm install\n npm run dev edit src/components/hello-counter.zb: it updates in place\n npm test builds the app and clicks through it in Chromium\n`);
|
|
94
|
+
} finally {
|
|
95
|
+
prompt?.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Run as a command (npm's bin is a link to this file), not imported.
|
|
100
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
101
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
102
|
+
console.error(error.message);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
});
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-zubra",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.1",
|
|
4
|
+
"description": "Start an app with Zubra web components: `npm create zubra`",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
9
|
+
"directory": "create"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"create-zubra": "./index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"templates"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test \"test/*.test.js\""
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<app-root></app-root>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@angular/common": "^22.2.5",
|
|
13
|
+
"@angular/compiler": "^22.2.5",
|
|
14
|
+
"@angular/core": "^22.2.5",
|
|
15
|
+
"@angular/platform-browser": "^22.2.5",
|
|
16
|
+
"@zubra/runtime": "^{{version}}",
|
|
17
|
+
"rxjs": "^7.8.2",
|
|
18
|
+
"tslib": "^2.8.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@analogjs/vite-plugin-angular": "^2.9.6",
|
|
22
|
+
"@angular/build": "^22.2.6",
|
|
23
|
+
"@angular/compiler-cli": "^22.2.5",
|
|
24
|
+
"@zubra/compiler": "^{{version}}",
|
|
25
|
+
"@zubra/unplugin": "^{{version}}",
|
|
26
|
+
"playwright": "^1.63.0",
|
|
27
|
+
"typescript": "~6.0.3",
|
|
28
|
+
"vite": "^8.3.6"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core';
|
|
2
|
+
import './components/hello-counter.zb';
|
|
3
|
+
|
|
4
|
+
// Angular uses the element directly: [count] sets the property, (countchange) listens. For
|
|
5
|
+
// forms, ZubraModel from @zubra/runtime/angular makes an element work with ngModel and formControl.
|
|
6
|
+
@Component({
|
|
7
|
+
selector: 'app-root',
|
|
8
|
+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
9
|
+
template: `
|
|
10
|
+
<main>
|
|
11
|
+
<h1>{{title}}</h1>
|
|
12
|
+
<hello-counter [count]="count()" (countchange)="count.set($any($event).detail)" label="Clicks"></hello-counter>
|
|
13
|
+
<p>Angular sees <span id="seen">{{ count() }}</span></p>
|
|
14
|
+
</main>
|
|
15
|
+
`,
|
|
16
|
+
})
|
|
17
|
+
export class App {
|
|
18
|
+
count = signal(0);
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2023",
|
|
8
|
+
"DOM"
|
|
9
|
+
],
|
|
10
|
+
"strict": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"allowJs": true,
|
|
14
|
+
"useDefineForClassFields": false
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/main.ts"
|
|
18
|
+
],
|
|
19
|
+
"angularCompilerOptions": {
|
|
20
|
+
"strictTemplates": true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import angular from '@analogjs/vite-plugin-angular';
|
|
2
|
+
import zubra from '@zubra/unplugin/vite';
|
|
3
|
+
|
|
4
|
+
// Analog's plugin compiles Angular ahead of time inside Vite; .zb imports go through zubra.
|
|
5
|
+
export default {
|
|
6
|
+
plugins: [zubra(), angular({ tsconfig: new URL('./tsconfig.json', import.meta.url).pathname })],
|
|
7
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "astro dev",
|
|
7
|
+
"build": "astro build",
|
|
8
|
+
"preview": "astro preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"astro": "^7.4.7"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"playwright": "^1.63.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
import HelloCounter from '../components/hello-counter.zb';
|
|
3
|
+
import '../style.css';
|
|
4
|
+
---
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9
|
+
<title>{{title}}</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main>
|
|
13
|
+
<h1>{{title}}</h1>
|
|
14
|
+
<HelloCounter label="Clicks" client:load />
|
|
15
|
+
<p>The page sees <span id="seen">0</span></p>
|
|
16
|
+
</main>
|
|
17
|
+
<script>
|
|
18
|
+
document.querySelector('hello-counter').addEventListener('countchange', (event) => {
|
|
19
|
+
document.querySelector('#seen').textContent = event.detail;
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Builds the site for the browser test; returns the output directory.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function buildApp(root) {
|
|
5
|
+
const { build } = await import('astro');
|
|
6
|
+
const outDir = join(root, 'dist');
|
|
7
|
+
await build({ root, logLevel: 'warn', outDir });
|
|
8
|
+
return outDir;
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<my-app></my-app>
|
|
11
|
+
<script type="module" src="/src/main.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"lit": "^3.3.3"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"playwright": "^1.63.0",
|
|
19
|
+
"vite": "^8.3.6"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
// In a Lit template, .count sets the property and @countchange listens.
|
|
3
|
+
import './components/hello-counter.zb';
|
|
4
|
+
|
|
5
|
+
class MyApp extends LitElement {
|
|
6
|
+
static properties = { count: { state: true } };
|
|
7
|
+
static styles = css`main { display: grid; gap: 1rem; } p { margin: 0; color: #5d6375; } h1 { margin: 0; font-size: 1.5rem; }`;
|
|
8
|
+
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
this.count = 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
render() {
|
|
15
|
+
return html`
|
|
16
|
+
<main>
|
|
17
|
+
<h1>{{title}}</h1>
|
|
18
|
+
<hello-counter .count=${this.count} @countchange=${(event) => (this.count = event.detail)} label="Clicks"></hello-counter>
|
|
19
|
+
<p>Lit sees <span id="seen">${this.count}</span></p>
|
|
20
|
+
</main>
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
customElements.define('my-app', MyApp);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"preact": "^10.29.8"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"@preact/preset-vite": "^2.10.6",
|
|
19
|
+
"playwright": "^1.63.0",
|
|
20
|
+
"vite": "^8.3.6"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
// Preact uses the element directly: props are set as properties, on<name> listens.
|
|
3
|
+
import './components/hello-counter.zb';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
const [count, setCount] = useState(0);
|
|
7
|
+
return (
|
|
8
|
+
<main>
|
|
9
|
+
<h1>{{title}}</h1>
|
|
10
|
+
<hello-counter count={count} oncountchange={(event) => setCount(event.detail)} label="Clicks" />
|
|
11
|
+
<p>Preact sees <span id="seen">{count}</span></p>
|
|
12
|
+
</main>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"react": "^19.3.1",
|
|
14
|
+
"react-dom": "^19.3.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@zubra/compiler": "^{{version}}",
|
|
18
|
+
"@zubra/unplugin": "^{{version}}",
|
|
19
|
+
"@vitejs/plugin-react": "^6.2.1",
|
|
20
|
+
"playwright": "^1.63.0",
|
|
21
|
+
"vite": "^8.3.6"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
// Imported into React, a component is a React component (typed, with onCountChange).
|
|
3
|
+
import HelloCounter from './components/hello-counter.zb';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
const [count, setCount] = useState(0);
|
|
7
|
+
return (
|
|
8
|
+
<main>
|
|
9
|
+
<h1>{{title}}</h1>
|
|
10
|
+
<HelloCounter count={count} onCountChange={(event) => setCount(event.detail)} label="Clicks" />
|
|
11
|
+
<p>React sees <span id="seen">{count}</span></p>
|
|
12
|
+
</main>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# {{title}}
|
|
2
|
+
|
|
3
|
+
An app with Zubra components (`src/components/*.zb`): HTML-first web components that work in any framework and render on the server.
|
|
4
|
+
|
|
5
|
+
| Task | Read |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Write or change a component | `skills/zubra-component/SKILL.md` |
|
|
8
|
+
| Use a component from the app's framework, or plain HTML | `skills/zubra-consume/SKILL.md` |
|
|
9
|
+
|
|
10
|
+
`npx zubra check src/components/<name>.zb` checks a component; `npm run dev` runs the app with hot reload; `npm test` runs the browser test. The full reference is https://github.com/journey-to-code-org/zubra#readme.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# {{title}}
|
|
2
|
+
|
|
3
|
+
A {{framework}} app using [Zubra](https://github.com/journey-to-code-org/zubra) web components.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # then edit src/components/hello-counter.zb: it updates in place
|
|
8
|
+
npm run build
|
|
9
|
+
npm test # builds the app and clicks through it in Chromium (run `npx playwright install chromium` once)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- `src/components/` holds the components: one `.zb` file each, with its script, styles and template.
|
|
13
|
+
- The Zubra plugin (`@zubra/unplugin`) compiles them when they're imported, and writes their typings next to them.
|
|
14
|
+
- The elements are standard custom elements, so they also work in plain HTML, other frameworks and server-rendered pages.
|
|
15
|
+
- `AGENTS.md` and `skills/` tell a coding agent how to write and use the components, so it doesn't have to learn Zubra from the source.
|
|
16
|
+
|
|
17
|
+
The [tutorial](https://github.com/journey-to-code-org/zubra/blob/main/docs/guide/README.md) goes from here to forms, async data and server rendering.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zubra-component
|
|
3
|
+
description: Write or change a Zubra component (a `.zb` file, or a `.zb.jsx` function). Use when asked for a web component, a custom element, a form control, or a component that must work in more than one framework or render on the server. Covers the format, state, events, slots, forms, styles, and the boundary between what belongs in the component and what belongs in the app.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
A Zubra component is one `.zb` file: a `<script>`, a `<style>`, and a template whose root is the host tag. The compiler turns it into a custom element, its server HTML, its typings and a React or Vue wrapper. You write the HTML; the compiler writes the class.
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
1. **Decide whether it's a leaf.** A component takes data in through props and sends it out through events; it may hold children through slots. If it would need the app's store, router, context or component types, it's a container: write that in the app's framework and put Zubra components inside it. (https://github.com/journey-to-code-org/zubra/blob/main/docs/what-crosses-the-boundary.md has the full list.)
|
|
11
|
+
2. **Name the element with a hyphen** (`star-rating`, not `rating`), and put the file at `src/components/<tag>.zb` unless the project has another convention.
|
|
12
|
+
3. **Write the host tag with every prop**, each with its default, then the script, then the template. Follow the format below.
|
|
13
|
+
4. **Run `zubra check <file>`** (`npx zubra check` without a Rust toolchain). It prints each error with a caret and a suggested fix; `--format json` gives the same as data. Done when it prints `no errors`.
|
|
14
|
+
5. **If the component is server-rendered, run `zubra check --server <file>`** and move anything it names (a `Date`, an `Intl` call, a `window` read) into `onMount` or behind `hydrated()`, so the server HTML is complete.
|
|
15
|
+
6. **Write the test** the project's other components have (a browser test from `npm create zubra`, or `node --test` with the project's harness), covering each prop, each event and keyboard use.
|
|
16
|
+
|
|
17
|
+
## The format
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<!--
|
|
21
|
+
A star rating. ← the description, for docs and typings
|
|
22
|
+
@fires valuechange - The value changed; `detail` is the new value.
|
|
23
|
+
-->
|
|
24
|
+
<script>
|
|
25
|
+
import { onMount } from '@zubra/runtime'
|
|
26
|
+
let hovered = null // state: assigning it updates the DOM
|
|
27
|
+
const shown = hovered ?? value // derived: reads state or props, stays in sync
|
|
28
|
+
const stars = [1, 2, 3, 4, 5].slice(0, max) // in the server's subset, so the HTML has the stars
|
|
29
|
+
function rate(stars) { value = stars } // a prop is a name in the script, readable and writable
|
|
30
|
+
onMount(() => console.log('in the DOM'))
|
|
31
|
+
</script>
|
|
32
|
+
<style>
|
|
33
|
+
button { font: inherit; } /* scoped to this component, in light DOM too */
|
|
34
|
+
button.lit { color: gold; }
|
|
35
|
+
</style>
|
|
36
|
+
<star-rating value:model={0} max={5} label="Rate">
|
|
37
|
+
<zb-each items={stars} as="star" key={star}>
|
|
38
|
+
<button type="button" aria-label="{star} of {max}" class:lit={star <= shown}
|
|
39
|
+
onclick={() => rate(star)} onmouseenter={() => (hovered = star)} onmouseleave={() => (hovered = null)}>★</button>
|
|
40
|
+
</zb-each>
|
|
41
|
+
<output>{label}: {value}</output>
|
|
42
|
+
</star-rating>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **Host tag**: `<tag attr={default} …>`. `value={0}` is a number prop, `label="Rate"` a string, `items={[]}` an array, bare `name` a required prop. Kebab-case attributes are camelCase props (`max-value` → `maxValue`). Flags: `shadow` (shadow DOM; light DOM is the default), `form-associated` (a form control), `host-reset` (for other people's pages).
|
|
46
|
+
- **`name:model={default}`** makes a prop two-way: assigning it in the script fires `<name>change` with the new value in `detail`. Frameworks bind it (`v-model`, `bind:value`, React's controlled pattern). Use it for every control's value; never fire the change event by hand.
|
|
47
|
+
- **`{expr}`** in text, inside quoted attributes (`class="card {kind}"`) or as a whole attribute (`items={list}`, any type). Any JavaScript expression; signals and props read as values.
|
|
48
|
+
- **Events on elements**: `onclick={fn}` or `onclick={() => …}`. Modifiers: `onsubmit.prevent`, `.stop`, `.once`, `.self`, `.capture`, `.passive`, `.submit` (submits the form after the handler); key filters `onkeydown.enter`, `.escape`, `.space`, `.up`, `.shift.enter`, `.mod.enter` (⌘ or Ctrl).
|
|
49
|
+
- **`class:name={bool}`**, `class={{ a, b: cond }}`, **`style:prop={value}`**, `style:--var={value}`, `style={{ … }}`. Never build a class or style string by hand.
|
|
50
|
+
- **`{...object}`** spreads attributes, `on<Name>` listeners and properties onto an HTML element (`<input {...api.getInputProps()}>`): use it for a headless library's prop getters. Explicit attributes win; it isn't allowed on the host, directives or Zubra components.
|
|
51
|
+
- **`ref={el}`** assigns an element to a `let` once rendered. **`<zb-window>`/`<zb-document>`** take listeners (`<zb-document onkeydown.escape={close}>`), removed on unmount.
|
|
52
|
+
|
|
53
|
+
## Directives
|
|
54
|
+
|
|
55
|
+
| | |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `<p zb-if={…}>`, then `<p zb-else-if={…}>` / `<p zb-else>` on the next siblings | a condition on one element (prefer this for one element) |
|
|
58
|
+
| `<zb-if test={…}>…<zb-else-if test={…}>…</zb-else-if><zb-else>…</zb-else></zb-if>` | a condition over several siblings or text |
|
|
59
|
+
| `<zb-each items={list} as="item" index="i" key={item.id}>` | lists; always give a `key` when rows can reorder or be removed from the middle |
|
|
60
|
+
| `<zb-switch value={x}><zb-case is="a">…</zb-case><zb-case is={["b", "c"]}>…</zb-case><zb-else>…</zb-else></zb-switch>` | one value, several cases (status values, enums) |
|
|
61
|
+
| `let:total={item.price * item.qty}` on `zb-each` / `zb-then` | a derived name for the row or result, instead of repeating the expression |
|
|
62
|
+
| `<zb-slot>` and `<zb-slot name="x">fallback</zb-slot>` | children; `hasSlot('x')` says whether content was given |
|
|
63
|
+
| `<zb-await value={promiseOrResource}><zb-pending>…</zb-pending><zb-then as="data">…</zb-then><zb-catch as="error">… {retry}</zb-catch></zb-await>` | async; `value={[a, b]}` waits for both; `as` may destructure (`as="{ name }"`); `retry()` in zb-catch; `delay={ms}`, `keep` |
|
|
64
|
+
| `<my-child value:bind={state}>` | two-way binding to a child's `:model` prop |
|
|
65
|
+
|
|
66
|
+
Markup in braces (`{a && <p>…</p>}`, `{a ? <b>…</b> : <i>…</i>}`, `{list.map((x) => <li key={x.id}>…</li>)}`) compiles to the same directives and is accepted, but write the directives: they're the house style. Any other `zb-` attribute is an error.
|
|
67
|
+
|
|
68
|
+
## Script rules
|
|
69
|
+
|
|
70
|
+
- **A top-level `let` is state.** Assign it (`count++`, `items = []`) or mutate it (`items.push(x)`, `item.done = !item.done`, `form.email = ""`): both update what read it. Arrays and plain objects in state are reactive proxies; `raw(items)` gives the original for `structuredClone` or `postMessage`.
|
|
71
|
+
- **A top-level `const` that reads props or state is derived**, kept in sync. Other `const`s, functions and imports run once per element.
|
|
72
|
+
- **Props are names.** Read `value`, assign `value = 3`. The script can't declare a name a prop has; a default from the script uses another name (`<x-card title={titleDefault}>`).
|
|
73
|
+
- **`let x = from(prop)`** is state that starts from a prop and resets when the prop changes; `let x = prop` reads it once.
|
|
74
|
+
- **Events**: `emit('name', detail)` from `@zubra/runtime`, name a string literal, all lowercase like the DOM's own (`itemselect`, not `itemSelect` or `item-select`); reuse native names where the meaning matches (`change`, `toggle`, `close`). `before<name>` events are cancelable: `if (!emit('beforeclose')) return`. Document each with `@fires name - text` in the description comment.
|
|
75
|
+
- **Hooks**, plain functions called during the script: `onMount(fn)` (may return a cleanup), `onUnmount(fn)`, `effect(fn)`, `watch(source, (next, prev) => …)`, `computed(fn)`, `state(v)`, `resource(fetcher)`, `id()` (for `id`/`for`/`aria-*`), `host()`, `hasSlot(name)`, `hydrated()`, `createContext`/`provide`/`inject`. Nothing re-renders, so there are no hook rules beyond "call them while the script runs".
|
|
76
|
+
- **Forms**: `form-associated` on the host makes the element a control; a single `:model` prop is its submitted value under its `name`. `validity(() => message)` takes part in validation (`''` is valid). `onFormReset`, `onFormRestore`, `onFormDisabled`.
|
|
77
|
+
- **`<script lang="ts">`** for TypeScript (erasable syntax only). `declare const value: number | null` types a prop whose default doesn't say enough.
|
|
78
|
+
|
|
79
|
+
## Accessible widgets
|
|
80
|
+
|
|
81
|
+
There is no Radix here; use, in order: the platform (`<dialog>` with `showModal()`, `popover` + `popovertarget`, CSS anchor positioning, `<details name>`, `<select>`, `<input type=range>`); `rovingFocus(container, { selector, orientation, onMove })` and `typeahead(container, { selector })` from `@zubra/runtime/primitives`, called in `onMount` (each returns its cleanup) for menus, tab lists, toolbars and listboxes; and for combobox, date picker, colour picker or tree view, a Zag.js machine through `@zubra/runtime/zag`: `const machine = useMachine(combobox.machine, () => ({ id: uid, … }))` with `uid = id()` hoisted, `const api = computed(() => combobox.connect(machine.current, normalizeProps))`, then `{...api.getXProps()}` on the elements. Copy the registry's `tab-list`, `dropdown-menu`, `tool-tip`, `accordion-panel` and `combo-box` rather than writing one from scratch.
|
|
82
|
+
|
|
83
|
+
## JSX form
|
|
84
|
+
|
|
85
|
+
A `.zb.jsx` / `.zb.tsx` file default-exports a function; same element, same rules: `function StarRating({ value = model(0), max = 5, onValueChange })`, top-level `let` is state, `cond && <X/>` and `list.map(…)` with `key` are the directives, `children` is the default slot, `onValueChange(x)` fires `valuechange`. Prefer `.zb` unless the project is already JSX.
|
|
86
|
+
|
|
87
|
+
## What the server can't render
|
|
88
|
+
|
|
89
|
+
The server evaluates the deterministic subset (props, arithmetic, ternaries, template literals, string and array methods, `Math` except `random`, `Object.keys/values/entries`). `Date`, `Intl`, `Math.random`, `toLocaleString`, `window`, `document` and `navigator` are browser-only: put them in `onMount`, in a handler, or in a `let` set from an effect, and show a server-safe fallback. `hydrated()` is `false` on the server and `true` once the element has started, for markup that only works with JavaScript.
|
|
90
|
+
|
|
91
|
+
## Reference
|
|
92
|
+
|
|
93
|
+
The Zubra README is the full reference; read a section only when a rule above isn't enough: https://github.com/journey-to-code-org/zubra#writing-a-component, `#directives`, `#classes-styles-events-and-references`, `#script-state-and-hooks`, `#events`, `#slots`, `#forms`, `#styles`, `#async-data`, `#server-rendering-and-hydration`. The registry holds twenty-five tested, accessible components to copy the shape from (`npx zubra add --list`; `registry/components/` in the Zubra repository).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zubra-consume
|
|
3
|
+
description: Use a Zubra component (or a library built with `zubra build --lib`) from an app: React, Vue, Svelte, Solid, Angular, Lit, Preact, Astro or plain HTML, with the bundler plugin or without one. Use when wiring a `.zb` import, passing an object prop, listening for a component's event, binding a value two-way, or putting children in a slot.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every framework uses the element the same way underneath: **properties in, DOM events out, children as slots.** Nothing is written by hand to bridge them; the plugin or the library's wrappers do it.
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
1. **Check how the project builds.** Vite, Rollup, webpack, esbuild or Rspack: `@zubra/unplugin` (`zubra()` in the bundler config; `npm create zubra` wrote it). Next.js: `@zubra/unplugin/loader` in `turbopack.rules`. The Angular CLI: compile with `zubra compile --emit js` and import the `.js`. No bundler: import the compiled module as a `<script type="module">`.
|
|
11
|
+
2. **Import the component** and write the tag. What the import gives depends on the importing file (table below). Typings are written next to the `.zb` file, so props and events are checked.
|
|
12
|
+
3. **Pass objects and arrays as properties, never as attribute strings.** Through the plugin, the wrappers and every framework listed below, a bound value *is* a property. In raw HTML, an attribute is the fallback: JSON for arrays and objects (`data='{"a":1}'`).
|
|
13
|
+
4. **Listen for the DOM event name**: all lowercase (`valuechange`), with the data in `event.detail`. Each framework's spelling is in the table.
|
|
14
|
+
5. **Bind two-way** through the `<prop>` + `<prop>change` pair the component declares with `:model`.
|
|
15
|
+
6. Done when the framework's dev server shows the component, a typo'd prop is a type error, and the event handler receives `detail`.
|
|
16
|
+
|
|
17
|
+
## Per framework
|
|
18
|
+
|
|
19
|
+
| Framework | Import gives | Props | Events | Two-way | Children |
|
|
20
|
+
|---|---|---|---|---|---|
|
|
21
|
+
| React 18/19 (`.jsx`/`.tsx`) | A React component (`import Rating from './rating.zb'`) | `<Rating value={3} items={list} />` | `onValueChange={(e) => e.detail}` | `value={v} onValueChange={…}` | JSX children; `slot="name"` on an element for named slots |
|
|
22
|
+
| Vue 3 (`.vue`) | A Vue component | `:items="list"` | `@valuechange="…"` | `v-model:value` (or `v-model` for `value`) | default and `<template #name>` slots |
|
|
23
|
+
| Svelte 5 | The element (typings written) | `<star-rating {value} items={list}>` | `onvaluechange={…}` | `bind:value` | children; `slot="name"` |
|
|
24
|
+
| Solid | The element (typings written) | `prop:items={list}` for objects; plain attributes for primitives | `on:valuechange={…}` | `prop:value` + `on:valuechange` | children; `slot="name"` |
|
|
25
|
+
| Angular | The element; `CUSTOM_ELEMENTS_SCHEMA` on the component | `[items]="list"` | `(valuechange)="…"` | `zubraModel [(ngModel)]="x"` from `@zubra/runtime/angular` | content projection; `slot="name"` |
|
|
26
|
+
| Lit, Preact | The element | `.items=${list}` (Lit), `items={list}` (Preact) | `@valuechange=${…}`, `onvaluechange={…}` | the pair by hand | children |
|
|
27
|
+
| Astro (`@zubra/unplugin/astro`) | A server-rendered component (`<Rating value={3} client:load />`) | props as values in the frontmatter's JSX | a `<script>` on the page: `addEventListener('valuechange', …)` | the pair in a script | children; `slot="name"` |
|
|
28
|
+
| Plain HTML | The element, once its module is loaded | `el.items = list` in a script, or JSON attributes | `el.addEventListener('valuechange', …)` | the pair by hand | children; `slot="name"` |
|
|
29
|
+
|
|
30
|
+
A library from `zubra build --lib` has the same per element: `@acme/ui/star-rating` (the element), `@acme/ui/react` and `@acme/ui/vue` (wrappers), typings, and `custom-elements.json` for editors and Storybook.
|
|
31
|
+
|
|
32
|
+
## What crosses, and what doesn't
|
|
33
|
+
|
|
34
|
+
Props, events, two-way binding, slots, form participation, refs to the host and server rendering cross the element boundary. Render props, the framework's context, Suspense and transitions, and the framework's own components as children-by-name do not: for those, put the Zubra component inside a framework component rather than the other way round. https://github.com/journey-to-code-org/zubra/blob/main/docs/what-crosses-the-boundary.md has the list with the alternatives. React-only widget libraries (Radix, shadcn/ui, React Aria) don't cross either. Inside a Zubra component, use the platform, `@zubra/runtime/primitives` or Zag.js, as in the registry's `tab-list`, `dropdown-menu`, `tool-tip` and `combo-box`. https://github.com/journey-to-code-org/zubra/blob/main/docs/guide/accessible-widgets.md maps Radix to these.
|
|
35
|
+
|
|
36
|
+
## Server rendering in a framework
|
|
37
|
+
|
|
38
|
+
Nuxt, SvelteKit, React Router and SolidStart: components must be `shadow` and the page goes through `@zubra/server`'s `injectShadowRoots()` (one hook per framework, in `examples/meta/`). Next.js: the loader's server rule renders shadow-DOM elements without React children. Astro and plain servers render light-DOM components as they are. Details: https://github.com/journey-to-code-org/zubra/blob/main/docs/server-rendering.md.
|
|
39
|
+
|
|
40
|
+
## Reference
|
|
41
|
+
|
|
42
|
+
https://github.com/journey-to-code-org/zubra#using-components-in-an-app, `plugin/README.md` in the Zubra repository, and a complete tested app per framework in its `examples/frameworks/` and `examples/meta/`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A counter: your first zubra component. While `npm run dev` runs, edit it here and it updates
|
|
3
|
+
in place, keeping its count.
|
|
4
|
+
|
|
5
|
+
- The host tag (`<hello-counter …>`) declares the props and their defaults.
|
|
6
|
+
- `count:model` makes `count` two-way: assigning it fires `countchange`, which frameworks
|
|
7
|
+
bind to (`v-model:count`, `onCountChange`, …).
|
|
8
|
+
- `let` in the script is state; `const` derived from props or state stays in sync.
|
|
9
|
+
@fires countchange - The count changed; `detail` is the new count.
|
|
10
|
+
-->
|
|
11
|
+
<script>
|
|
12
|
+
const atMin = count <= min
|
|
13
|
+
</script>
|
|
14
|
+
<style>
|
|
15
|
+
:host { display: inline-flex; align-items: center; gap: 0.75rem; font: 1rem system-ui, sans-serif; }
|
|
16
|
+
button { width: 2.25rem; height: 2.25rem; border: 1px solid #c9cdd6; border-radius: 50%; background: white; font: inherit; font-size: 1.2rem; cursor: pointer; }
|
|
17
|
+
button:hover { border-color: #5b6cff; }
|
|
18
|
+
button:disabled { opacity: 0.4; cursor: default; }
|
|
19
|
+
output { min-width: 6ch; text-align: center; font-variant-numeric: tabular-nums; }
|
|
20
|
+
</style>
|
|
21
|
+
<hello-counter count:model={0} min={0} label="Clicks">
|
|
22
|
+
<button type="button" class="less" aria-label="Fewer" disabled={atMin} onclick={() => count--}>−</button>
|
|
23
|
+
<output aria-live="polite">{label}: {count}</output>
|
|
24
|
+
<button type="button" class="more" aria-label="More" onclick={() => count++}>+</button>
|
|
25
|
+
</hello-counter>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The app in a real browser: builds it, serves the build and clicks the counter.
|
|
2
|
+
// `npm test` (needs Chromium for Playwright: `npx playwright install chromium`).
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { extname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { chromium } from 'playwright';
|
|
10
|
+
import { buildApp } from './build.js';
|
|
11
|
+
|
|
12
|
+
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
13
|
+
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
|
|
14
|
+
|
|
15
|
+
test('the counter counts, and the app sees its count', async () => {
|
|
16
|
+
const dist = await buildApp(root);
|
|
17
|
+
const server = createServer((request, response) => {
|
|
18
|
+
let path = join(dist, decodeURIComponent(new URL(request.url, 'http://x').pathname));
|
|
19
|
+
if (existsSync(path) && statSync(path).isDirectory()) path = join(path, 'index.html');
|
|
20
|
+
try {
|
|
21
|
+
response.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'text/plain' }).end(readFileSync(path));
|
|
22
|
+
} catch {
|
|
23
|
+
response.writeHead(404).end();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
27
|
+
const browser = await chromium.launch();
|
|
28
|
+
try {
|
|
29
|
+
const page = await browser.newPage();
|
|
30
|
+
const errors = [];
|
|
31
|
+
page.on('pageerror', (error) => errors.push(error.message));
|
|
32
|
+
page.on('console', (message) => message.type() === 'error' && errors.push(message.text()));
|
|
33
|
+
await page.goto(`http://127.0.0.1:${server.address().port}/`);
|
|
34
|
+
await page.waitForSelector('hello-counter button.more');
|
|
35
|
+
assert.equal(await page.textContent('hello-counter output'), 'Clicks: 0');
|
|
36
|
+
assert.equal(await page.isDisabled('hello-counter button.less'), true);
|
|
37
|
+
|
|
38
|
+
await page.click('hello-counter button.more');
|
|
39
|
+
await page.click('hello-counter button.more');
|
|
40
|
+
await page.waitForFunction(() => document.querySelector('#seen')?.textContent === '2');
|
|
41
|
+
assert.equal(await page.textContent('hello-counter output'), 'Clicks: 2');
|
|
42
|
+
await page.click('hello-counter button.less');
|
|
43
|
+
await page.waitForFunction(() => document.querySelector('#seen')?.textContent === '1');
|
|
44
|
+
assert.deepEqual(errors, []);
|
|
45
|
+
} finally {
|
|
46
|
+
await browser.close();
|
|
47
|
+
server.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Builds the app for the browser test; returns the output directory.
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function buildApp(root) {
|
|
5
|
+
const { build } = await import('vite');
|
|
6
|
+
const outDir = join(root, 'dist');
|
|
7
|
+
await build({ root, configFile: join(root, 'vite.config.js'), configLoader: 'native', logLevel: 'warn', build: { outDir, emptyOutDir: true } });
|
|
8
|
+
return outDir;
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"solid-js": "^1.9.18"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"playwright": "^1.63.0",
|
|
19
|
+
"vite": "^8.3.6",
|
|
20
|
+
"vite-plugin-solid": "^2.11.15"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createSignal } from 'solid-js';
|
|
2
|
+
// Solid uses the element directly: prop:name sets a property, on:name listens.
|
|
3
|
+
import './components/hello-counter.zb';
|
|
4
|
+
|
|
5
|
+
export default function App() {
|
|
6
|
+
const [count, setCount] = createSignal(0);
|
|
7
|
+
return (
|
|
8
|
+
<main>
|
|
9
|
+
<h1>{{title}}</h1>
|
|
10
|
+
<hello-counter prop:count={count()} on:countchange={(event) => setCount(event.detail)} label="Clicks" />
|
|
11
|
+
<p>Solid sees <span id="seen">{count()}</span></p>
|
|
12
|
+
</main>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"svelte": "^5.57.3"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
19
|
+
"playwright": "^1.63.0",
|
|
20
|
+
"vite": "^8.3.6"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
// Svelte uses the element directly: props are set as properties, on<name> listens.
|
|
3
|
+
import './components/hello-counter.zb';
|
|
4
|
+
|
|
5
|
+
let count = $state(0);
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<main>
|
|
9
|
+
<h1>{{title}}</h1>
|
|
10
|
+
<hello-counter {count} oncountchange={(event) => (count = event.detail)} label="Clicks"></hello-counter>
|
|
11
|
+
<p>Svelte sees <span id="seen">{count}</span></p>
|
|
12
|
+
</main>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main>
|
|
11
|
+
<h1>{{title}}</h1>
|
|
12
|
+
<hello-counter label="Clicks"></hello-counter>
|
|
13
|
+
<p>The page sees <span id="seen">0</span></p>
|
|
14
|
+
</main>
|
|
15
|
+
<script type="module" src="/src/main.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@zubra/compiler": "^{{version}}",
|
|
16
|
+
"@zubra/unplugin": "^{{version}}",
|
|
17
|
+
"playwright": "^1.63.0",
|
|
18
|
+
"vite": "^8.3.6"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import './components/hello-counter.zb';
|
|
2
|
+
|
|
3
|
+
// The element is a standard custom element: listen for its events like any other.
|
|
4
|
+
const counter = document.querySelector('hello-counter');
|
|
5
|
+
counter.addEventListener('countchange', (event) => {
|
|
6
|
+
document.querySelector('#seen').textContent = event.detail;
|
|
7
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"test": "node --test test/*.test.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@zubra/runtime": "^{{version}}",
|
|
13
|
+
"vue": "^3.5.45"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@zubra/compiler": "^{{version}}",
|
|
17
|
+
"@zubra/unplugin": "^{{version}}",
|
|
18
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
19
|
+
"playwright": "^1.63.0",
|
|
20
|
+
"vite": "^8.3.6"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from 'vue';
|
|
3
|
+
// Imported into Vue, a component is a Vue component: v-model:count binds its two-way prop.
|
|
4
|
+
import HelloCounter from './components/hello-counter.zb';
|
|
5
|
+
|
|
6
|
+
const count = ref(0);
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<main>
|
|
11
|
+
<h1>{{title}}</h1>
|
|
12
|
+
<HelloCounter v-model:count="count" label="Clicks" />
|
|
13
|
+
<p>Vue sees <span id="seen">{{ count }}</span></p>
|
|
14
|
+
</main>
|
|
15
|
+
</template>
|
package/README.md
DELETED