@zubra/compiler 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/cli.js +175 -0
- package/index.d.ts +81 -0
- package/index.js +123 -0
- package/library.d.ts +20 -0
- package/library.js +224 -0
- package/package.json +41 -4
- package/registry/README.md +41 -0
- package/registry/build-index.mjs +58 -0
- package/registry/components/accordion-panel.zb +23 -0
- package/registry/components/chat-message.zb +65 -0
- package/registry/components/choice-cards.zb +47 -0
- package/registry/components/color-swatches.zb +30 -0
- package/registry/components/combo-box.zb +59 -0
- package/registry/components/date-range.zb +37 -0
- package/registry/components/donut-chart.zb +56 -0
- package/registry/components/dropdown-menu.zb +83 -0
- package/registry/components/flip-card.zb +29 -0
- package/registry/components/kanban-column.zb +65 -0
- package/registry/components/line-chart.zb +58 -0
- package/registry/components/message-composer.zb +80 -0
- package/registry/components/modal-dialog.zb +44 -0
- package/registry/components/money-input.zb +41 -0
- package/registry/components/progress-ring.zb +24 -0
- package/registry/components/quantity-stepper.zb +22 -0
- package/registry/components/segmented-control.zb +23 -0
- package/registry/components/spark-line.zb +19 -0
- package/registry/components/star-rating.zb +34 -0
- package/registry/components/step-indicator.zb +31 -0
- package/registry/components/streak-grid.zb +57 -0
- package/registry/components/tab-list.zb +59 -0
- package/registry/components/tag-input.zb +50 -0
- package/registry/components/toggle-switch.zb +27 -0
- package/registry/components/tool-tip.zb +68 -0
- package/registry/index.json +233 -0
- package/zubra.wasm +0 -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/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `zubra` from npm (@zubra/compiler): the WebAssembly compiler on the command line, with no Rust.
|
|
3
|
+
//
|
|
4
|
+
// zubra build --lib <dir> [--out dist] [--name <package>] [--version <x.y.z>] [--watch]
|
|
5
|
+
// zubra add <component>... [--dir src/components] [--force] copy registry components in
|
|
6
|
+
// zubra add --list
|
|
7
|
+
// zubra compile <file> [--emit js|html|css|dts|manifest] [--framework react|vue|svelte|solid] [--partial]
|
|
8
|
+
// zubra check <file>... [--server] --server: what a server without JavaScript leaves out
|
|
9
|
+
// zubra convert <Component.jsx> [--prefix <word>] [--as jsx|zubra] [--out <file> | --stdout] React → zubra JSX, or .zb
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { compileFile, request } from './index.js';
|
|
14
|
+
import { buildLibrary } from './library.js';
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
/** The component registry: shipped in the package, or the repository's when run from a checkout. */
|
|
19
|
+
export function registryDir() {
|
|
20
|
+
for (const dir of [join(here, 'registry'), join(here, '../registry')]) {
|
|
21
|
+
if (existsSync(join(dir, 'index.json'))) return dir;
|
|
22
|
+
}
|
|
23
|
+
throw new Error('zubra add: the component registry is missing from this install');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readRegistry(dir = registryDir()) {
|
|
27
|
+
return JSON.parse(readFileSync(join(dir, 'index.json'), 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Copy registry components (and those they use) into `target`. Returns the files written.
|
|
32
|
+
* An existing file is an error unless `force`, so your edits are never overwritten.
|
|
33
|
+
*/
|
|
34
|
+
export function add(names, { target = 'src/components', force = false, registry = registryDir() } = {}) {
|
|
35
|
+
const { components } = readRegistry(registry);
|
|
36
|
+
const byName = new Map(components.map((component) => [component.name, component]));
|
|
37
|
+
const wanted = new Set();
|
|
38
|
+
const visit = (name) => {
|
|
39
|
+
const component = byName.get(name);
|
|
40
|
+
if (!component) {
|
|
41
|
+
const near = [...byName.keys()].filter((candidate) => candidate.startsWith(name.slice(0, 3)) || distance(candidate, name) <= 2);
|
|
42
|
+
throw new Error(`zubra add: no component "${name}" in the registry${near.length ? `; did you mean ${near.join(', ')}?` : ''} (zubra add --list)`);
|
|
43
|
+
}
|
|
44
|
+
if (wanted.has(name)) return;
|
|
45
|
+
wanted.add(name);
|
|
46
|
+
for (const dependency of component.dependencies ?? []) visit(dependency);
|
|
47
|
+
};
|
|
48
|
+
for (const name of names) visit(name);
|
|
49
|
+
const files = [...wanted].flatMap((name) => byName.get(name).files);
|
|
50
|
+
mkdirSync(target, { recursive: true });
|
|
51
|
+
const clashes = files.filter((file) => existsSync(join(target, file)));
|
|
52
|
+
if (clashes.length && !force) throw new Error(`zubra add: ${clashes.map((file) => join(target, file)).join(', ')} already exists (--force replaces it)`);
|
|
53
|
+
for (const file of files) copyFileSync(join(registry, 'components', file), join(target, file));
|
|
54
|
+
return files.map((file) => join(target, file));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The npm packages the added files import (beyond the runtime), from the registry's index. */
|
|
58
|
+
export function packagesFor(files, registry = registryDir()) {
|
|
59
|
+
const added = new Set(files.map((file) => basename(file)));
|
|
60
|
+
const { components } = readRegistry(registry);
|
|
61
|
+
return [...new Set(components.filter((component) => component.files.some((file) => added.has(file))).flatMap((component) => component.packages ?? []))];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Edit distance, for "did you mean". */
|
|
65
|
+
function distance(a, b) {
|
|
66
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
67
|
+
for (let i = 1; i <= a.length; i++) {
|
|
68
|
+
const next = [i];
|
|
69
|
+
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
70
|
+
row = next;
|
|
71
|
+
}
|
|
72
|
+
return row[b.length];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function options(args) {
|
|
76
|
+
const flags = {};
|
|
77
|
+
const rest = [];
|
|
78
|
+
for (let i = 0; i < args.length; i++) {
|
|
79
|
+
const arg = args[i];
|
|
80
|
+
if (!arg.startsWith('--')) rest.push(arg);
|
|
81
|
+
else if (['--lib', '--force', '--list', '--partial', '--stdout', '--server', '--watch'].includes(arg)) flags[arg.slice(2)] = true;
|
|
82
|
+
else flags[arg.slice(2)] = args[++i];
|
|
83
|
+
}
|
|
84
|
+
return { flags, rest };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main(argv) {
|
|
88
|
+
const [command, ...args] = argv;
|
|
89
|
+
const { flags, rest } = options(args);
|
|
90
|
+
if (command === 'build') {
|
|
91
|
+
if (!flags.lib || rest.length !== 1) throw new Error('usage: zubra build --lib <components dir> [--out dist] [--name <package>] [--version <x.y.z>] [--watch]');
|
|
92
|
+
const out = flags.out ?? 'dist';
|
|
93
|
+
const build = () => {
|
|
94
|
+
const { components, warnings } = buildLibrary(rest[0], { out, name: flags.name, version: flags.version });
|
|
95
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
96
|
+
console.log(`zubra: built ${components.length} component${components.length === 1 ? '' : 's'} into ${relative(process.cwd(), resolve(out)) || '.'}: ${components.map(({ tag }) => `<${tag}>`).join(' ')}`);
|
|
97
|
+
};
|
|
98
|
+
if (!flags.watch) return build();
|
|
99
|
+
// --watch: build, then again after each change to the components (a compile error is
|
|
100
|
+
// reported and the last good build stays).
|
|
101
|
+
const attempt = () => {
|
|
102
|
+
try {
|
|
103
|
+
build();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error(error.message);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
attempt();
|
|
109
|
+
let timer = null;
|
|
110
|
+
watch(rest[0], { recursive: true }, (_event, file) => {
|
|
111
|
+
if (file && !/\.zb(\.[jt]sx)?$/.test(file)) return;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
timer = setTimeout(attempt, 50);
|
|
114
|
+
});
|
|
115
|
+
console.log(`zubra: watching ${rest[0]} (Ctrl+C to stop)`);
|
|
116
|
+
} else if (command === 'add') {
|
|
117
|
+
if (flags.list) {
|
|
118
|
+
for (const component of readRegistry().components) console.log(`${component.name.padEnd(20)} ${component.description}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!rest.length) throw new Error('usage: zubra add <component>... [--dir src/components] [--force] (zubra add --list)');
|
|
122
|
+
const files = add(rest, { target: flags.dir ?? 'src/components', force: flags.force });
|
|
123
|
+
console.log(`zubra: added ${files.join(', ')}. They're yours now: edit them as you like.`);
|
|
124
|
+
const packages = packagesFor(files);
|
|
125
|
+
if (packages.length) console.log(`zubra: they import ${packages.join(', ')}: npm install ${packages.join(' ')}`);
|
|
126
|
+
} else if (command === 'convert') {
|
|
127
|
+
if (rest.length !== 1) throw new Error('usage: zubra convert <Component.jsx> [--prefix <word>] [--as jsx|zubra] [--out <file> | --stdout]');
|
|
128
|
+
const path = resolve(rest[0]);
|
|
129
|
+
const response = request({ command: 'convert', path, files: { [path]: readFileSync(path, 'utf8') }, prefix: flags.prefix, as: flags.as });
|
|
130
|
+
if (!response.ok) throw new Error(response.error);
|
|
131
|
+
for (const change of response.changes) process.stderr.write(`${rest[0]}: ${change}\n`);
|
|
132
|
+
if (flags.stdout) process.stdout.write(response.output);
|
|
133
|
+
else {
|
|
134
|
+
const target = flags.out ?? relative(process.cwd(), response.file);
|
|
135
|
+
writeFileSync(target, response.output);
|
|
136
|
+
process.stderr.write(`${rest[0]}: wrote ${target} (<${response.tag}>)\n`);
|
|
137
|
+
}
|
|
138
|
+
for (const warning of response.warnings) process.stderr.write(warning);
|
|
139
|
+
if (!response.complete) {
|
|
140
|
+
process.stderr.write(`${response.diagnostics.length} thing${response.diagnostics.length === 1 ? '' : 's'} to finish by hand (above)\n`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
} else if (command === 'compile' || command === 'check') {
|
|
144
|
+
if (!rest.length) throw new Error(`usage: zubra ${command} <file>${command === 'check' ? '...' : ''}`);
|
|
145
|
+
let failed = false;
|
|
146
|
+
for (const file of command === 'check' ? rest : rest.slice(0, 1)) {
|
|
147
|
+
const response = compileFile(file, {
|
|
148
|
+
command, emit: flags.emit ?? 'html', framework: flags.framework, runtime: flags.runtime, partial: flags.partial, server: flags.server,
|
|
149
|
+
props: flags.props ? JSON.parse(readFileSync(flags.props, 'utf8')) : undefined,
|
|
150
|
+
});
|
|
151
|
+
for (const warning of response.warnings) process.stderr.write(warning);
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
process.stderr.write(response.error);
|
|
154
|
+
failed = true;
|
|
155
|
+
} else if (command === 'compile') {
|
|
156
|
+
if (flags.output) writeFileSync(flags.output, response.output);
|
|
157
|
+
else process.stdout.write(response.output);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (command === 'check' && !failed) console.log(`checked ${rest.length} file${rest.length === 1 ? '' : 's'}: no errors`);
|
|
161
|
+
if (failed) process.exitCode = 1;
|
|
162
|
+
} else {
|
|
163
|
+
console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(2, 9).map((line) => line.replace(/^\/\/ ?/, '')).join('\n'));
|
|
164
|
+
if (command && command !== 'help' && command !== '--help') process.exitCode = 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
169
|
+
try {
|
|
170
|
+
main(process.argv.slice(2));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(error.message);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface Diagnostic {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
severity: 'error' | 'warning';
|
|
6
|
+
message: string;
|
|
7
|
+
help: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Response {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
/** The compiled text (null for `command: 'check'` or on error). */
|
|
13
|
+
output: string | null;
|
|
14
|
+
/** The error, rendered for a terminal. */
|
|
15
|
+
error: string | null;
|
|
16
|
+
/** Warnings, rendered for a terminal. */
|
|
17
|
+
warnings: string[];
|
|
18
|
+
/** Every error and warning, for editors. */
|
|
19
|
+
diagnostics: Diagnostic[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Options {
|
|
23
|
+
command?: 'compile' | 'check';
|
|
24
|
+
emit?: 'html' | 'js' | 'css' | 'dts' | 'manifest' | 'html-data' | 'interface';
|
|
25
|
+
runtime?: string;
|
|
26
|
+
keepImports?: boolean;
|
|
27
|
+
noImports?: boolean;
|
|
28
|
+
cssImport?: string;
|
|
29
|
+
cssImportInline?: string;
|
|
30
|
+
framework?: 'react' | 'vue' | 'svelte' | 'solid' | 'none';
|
|
31
|
+
modulePath?: string;
|
|
32
|
+
props?: Record<string, unknown>;
|
|
33
|
+
slots?: Record<string, string>;
|
|
34
|
+
/** What `slots` is written in: zubra template syntax (the default here) or rendered HTML. */
|
|
35
|
+
slotSource?: 'template' | 'html';
|
|
36
|
+
/** For `emit: 'html'`: leave what only the browser can compute for the element. */
|
|
37
|
+
partial?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Request extends Options {
|
|
41
|
+
path: string;
|
|
42
|
+
/** Every source the compilation needs, by absolute path. */
|
|
43
|
+
files: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The text of a 4.x server's hydration-marker comments, for tools that remove comments: keep these. (5.0 writes processing instructions, which they keep.) */
|
|
47
|
+
export declare const ZUBRA_MARKER_COMMENT: RegExp;
|
|
48
|
+
export declare function available(): boolean;
|
|
49
|
+
export declare function request(request: Request): Response;
|
|
50
|
+
export declare function sources(path: string, files?: Record<string, string>): Record<string, string>;
|
|
51
|
+
export declare function compileFile(path: string, options?: Options): Response;
|
|
52
|
+
|
|
53
|
+
export interface RenderOptions {
|
|
54
|
+
/** The host's prop values. */
|
|
55
|
+
props?: Record<string, unknown>;
|
|
56
|
+
/** Supplied content by slot name (`default` for unnamed content). */
|
|
57
|
+
slots?: Record<string, string>;
|
|
58
|
+
/** What `slots` is written in: rendered HTML, kept as written (the default), or zubra template syntax. */
|
|
59
|
+
slotSource?: 'html' | 'template';
|
|
60
|
+
/** Leave what only the browser can compute for the element (the default), instead of an error. */
|
|
61
|
+
partial?: boolean;
|
|
62
|
+
/** The sources, by absolute path, instead of reading them from disk. */
|
|
63
|
+
files?: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Rendered {
|
|
67
|
+
/** The server-rendered markup, which the element hydrates. */
|
|
68
|
+
html: string;
|
|
69
|
+
/** The scoped stylesheets of the component and the components it uses. */
|
|
70
|
+
css: string;
|
|
71
|
+
warnings: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export declare class CompileError extends Error {
|
|
75
|
+
diagnostics: Diagnostic[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Server-render a component. Throws a `CompileError` on errors. */
|
|
79
|
+
export declare function render(path: string, options?: RenderOptions): Rendered;
|
|
80
|
+
/** A component's server-rendered HTML. Throws a `CompileError` on errors. */
|
|
81
|
+
export declare function renderToString(path: string, options?: RenderOptions): string;
|
package/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @zubra/compiler: the zubra compiler as WebAssembly, for Node (and bundler plugins) without a
|
|
2
|
+
// Rust toolchain. Same compiler, same output, same errors as the `zubra` CLI.
|
|
3
|
+
//
|
|
4
|
+
// import { compileFile, renderToString } from '@zubra/compiler';
|
|
5
|
+
// const { ok, output, error, warnings } = compileFile('src/inbox.zb', { emit: 'js' });
|
|
6
|
+
// const html = renderToString('src/inbox.zb', { props: { heading: 'Mail' } });
|
|
7
|
+
//
|
|
8
|
+
// `request()` is the raw interface: one JSON-shaped request in, one response out (see the
|
|
9
|
+
// compiler's `request` module). `compileFile()` reads the file and every `.zb` file it
|
|
10
|
+
// imports, and makes that request.
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The text of the hydration-marker comments a 4.x server writes (`<!--[-->`, `<!--]-->`, `<!---->`,
|
|
17
|
+
* `<!--[?-->`, `<!--[!-->`), for tools that remove comments: keep the ones matching this. For example
|
|
18
|
+
* html-minifier-terser's `ignoreCustomComments: [ZUBRA_MARKER_COMMENT]`. Since 5.0 the markers are
|
|
19
|
+
* processing instructions (`<?zb [?>`), which minifiers keep without it.
|
|
20
|
+
*/
|
|
21
|
+
export const ZUBRA_MARKER_COMMENT = /^[[\]?!]*$/;
|
|
22
|
+
|
|
23
|
+
const wasmUrl = new URL('./zubra.wasm', import.meta.url);
|
|
24
|
+
let exports = null;
|
|
25
|
+
|
|
26
|
+
/** Whether the WebAssembly module is present (it's built by `npm run build`, or ships in the package). */
|
|
27
|
+
export function available() {
|
|
28
|
+
return existsSync(wasmUrl);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wasm() {
|
|
32
|
+
if (!exports) {
|
|
33
|
+
if (!available()) throw new Error('@zubra/compiler: zubra.wasm is missing; run `npm run build` in its package (needs Rust with the wasm32-unknown-unknown target)');
|
|
34
|
+
exports = new WebAssembly.Instance(new WebAssembly.Module(readFileSync(wasmUrl)), {}).exports;
|
|
35
|
+
}
|
|
36
|
+
return exports;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const encoder = new TextEncoder();
|
|
40
|
+
const decoder = new TextDecoder();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One request to the compiler; returns `{ ok, output, error, warnings, diagnostics }`. Without
|
|
44
|
+
* zubra.wasm, a native `zubra` named by $ZUBRA_COMPILER answers it instead (`zubra request`).
|
|
45
|
+
*/
|
|
46
|
+
export function request(body) {
|
|
47
|
+
if (!available() && process.env.ZUBRA_COMPILER) return native(process.env.ZUBRA_COMPILER, body);
|
|
48
|
+
const { memory, zubra_alloc, zubra_request, zubra_free } = wasm();
|
|
49
|
+
const bytes = encoder.encode(JSON.stringify(body));
|
|
50
|
+
const pointer = zubra_alloc(bytes.length);
|
|
51
|
+
new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes);
|
|
52
|
+
const packed = BigInt.asUintN(64, zubra_request(pointer, bytes.length));
|
|
53
|
+
const at = Number(packed >> 32n);
|
|
54
|
+
const length = Number(packed & 0xffffffffn);
|
|
55
|
+
// The memory may have grown during the request; read through the current buffer.
|
|
56
|
+
const text = decoder.decode(new Uint8Array(memory.buffer, at, length).slice());
|
|
57
|
+
zubra_free(at, length);
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function native(compiler, body) {
|
|
62
|
+
const result = spawnSync(compiler, ['request'], { input: JSON.stringify(body), encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
63
|
+
if (result.error || result.status !== 0) throw new Error(`@zubra/compiler: ${compiler} request failed: ${result.error?.message ?? result.stderr}`);
|
|
64
|
+
return JSON.parse(result.stdout);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The components (`.zb`, `.zb.jsx`, `.zb.tsx`) `path` imports, recursively, with their sources (the path itself included). */
|
|
68
|
+
export function sources(path, files = {}) {
|
|
69
|
+
const absolute = resolve(path);
|
|
70
|
+
if (absolute in files) return files;
|
|
71
|
+
let source;
|
|
72
|
+
try { source = readFileSync(absolute, 'utf8'); } catch { return files; }
|
|
73
|
+
files[absolute] = source;
|
|
74
|
+
// A JSX component's imports are the whole file's; an .zb file's are its <script>'s.
|
|
75
|
+
const script = /\.[jt]sx$/.test(absolute) ? source : source.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
|
|
76
|
+
for (const [, specifier] of script.matchAll(/import\s+(?:[^'"]*?from\s+)?['"]([^'"]+\.zb(?:\.[jt]sx)?)['"]/g)) {
|
|
77
|
+
if (specifier.startsWith('.')) sources(resolve(dirname(absolute), specifier), files);
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compile the component at `path`. Options mirror the CLI's: `emit` (`html` | `js` | `css` |
|
|
84
|
+
* `dts` | `manifest`), `command: 'check'`, `runtime`, `keepImports`, `noImports`, `cssImport` +
|
|
85
|
+
* `cssImportInline`, `framework`, `modulePath`, `props`, `slots`.
|
|
86
|
+
*/
|
|
87
|
+
export function compileFile(path, options = {}) {
|
|
88
|
+
const absolute = resolve(path);
|
|
89
|
+
return request({ ...options, path: absolute, files: sources(absolute) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A compile error, with the rendered message and the diagnostics. */
|
|
93
|
+
export class CompileError extends Error {
|
|
94
|
+
constructor(response) {
|
|
95
|
+
super(response.error);
|
|
96
|
+
this.name = 'CompileError';
|
|
97
|
+
this.diagnostics = response.diagnostics;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Server-render the component at `path`: its HTML (which the element hydrates in the browser)
|
|
103
|
+
* and the scoped stylesheets of it and the components it uses. `props` are the host's values
|
|
104
|
+
* (JSON), `slots` the supplied content by slot name (`default` for unnamed content): HTML,
|
|
105
|
+
* kept as written (`slotSource: 'template'` reads it as zubra template syntax instead). `files`
|
|
106
|
+
* supplies the sources instead of reading them from disk (see `sources`). What only the browser
|
|
107
|
+
* can compute (JavaScript expressions, runtime-only values) is left for the element to fill in
|
|
108
|
+
* when it starts; `partial: false` makes that an error instead. Throws a `CompileError` on errors.
|
|
109
|
+
*/
|
|
110
|
+
export function render(path, { props = {}, slots = {}, slotSource = 'html', partial = true, files } = {}) {
|
|
111
|
+
const absolute = resolve(path);
|
|
112
|
+
const all = files ?? sources(absolute);
|
|
113
|
+
const html = request({ emit: 'html', path: absolute, files: all, props, slots, slotSource, partial });
|
|
114
|
+
if (!html.ok) throw new CompileError(html);
|
|
115
|
+
const css = request({ emit: 'css', path: absolute, files: all });
|
|
116
|
+
if (!css.ok) throw new CompileError(css);
|
|
117
|
+
return { html: html.output, css: css.output, warnings: html.warnings };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The component's server-rendered HTML (see `render`). */
|
|
121
|
+
export function renderToString(path, options) {
|
|
122
|
+
return render(path, options).html;
|
|
123
|
+
}
|
package/library.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface LibraryOptions {
|
|
2
|
+
/** The output directory (replaced). Default: `dist`. */
|
|
3
|
+
out?: string;
|
|
4
|
+
/** The package's name and version; default: the current directory's package.json. */
|
|
5
|
+
name?: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
/** The runtime module the elements import. Default: `@zubra/runtime`. */
|
|
8
|
+
runtime?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LibraryComponent {
|
|
12
|
+
tag: string;
|
|
13
|
+
/** The element's class name, as exported from the package's index. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** The module path, without extension, relative to the output directory. */
|
|
16
|
+
module: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Compile every component under `dir` into a publishable package (see library.js). */
|
|
20
|
+
export declare function buildLibrary(dir: string, options?: LibraryOptions): { components: LibraryComponent[]; warnings: string[] };
|
package/library.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Library mode: compile a folder of components into a package anyone can install and use, with
|
|
2
|
+
// no bundler or zubra tooling on their side.
|
|
3
|
+
//
|
|
4
|
+
// import { buildLibrary } from '@zubra/compiler/library';
|
|
5
|
+
// buildLibrary('src/components', { out: 'dist', name: '@acme/ui', version: '1.0.0' });
|
|
6
|
+
//
|
|
7
|
+
// (or `npx zubra build --lib src/components`). The output:
|
|
8
|
+
//
|
|
9
|
+
// <name>.js, <name>.d.ts each element: an ES module importing @zubra/runtime
|
|
10
|
+
// index.js, index.d.ts every element, by its class name
|
|
11
|
+
// react/…, vue/… each element as a React or Vue component, typed
|
|
12
|
+
// custom-elements.json the Custom Elements Manifest (Storybook, IDEs, docs tools)
|
|
13
|
+
// skills/using-<name>/SKILL.md how to use the components, for coding agents, from the manifest
|
|
14
|
+
// package.json exports for all of the above
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { compileFile } from './index.js';
|
|
18
|
+
|
|
19
|
+
const SOURCE = /\.zb(?:\.[jt]sx)?$/;
|
|
20
|
+
const { version: compilerVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
21
|
+
|
|
22
|
+
const walk = (dir) => readdirSync(dir).sort().flatMap((entry) => {
|
|
23
|
+
const path = join(dir, entry);
|
|
24
|
+
if (statSync(path).isDirectory()) return entry === 'node_modules' || entry.startsWith('.') ? [] : walk(path);
|
|
25
|
+
return SOURCE.test(entry) ? [path] : [];
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const pascal = (tag) => tag.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join('');
|
|
29
|
+
const posix = (path) => path.split(sep).join('/');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compile every component under `dir` into a package in `out`. Returns
|
|
33
|
+
* `{ components: [{ tag, name, module }], warnings }`; throws on the first compile error.
|
|
34
|
+
*/
|
|
35
|
+
export function buildLibrary(dir, options = {}) {
|
|
36
|
+
const root = resolve(dir);
|
|
37
|
+
const target = resolve(options.out ?? 'dist');
|
|
38
|
+
// Built next to the target and swapped in at the end, so a compile error leaves the last good
|
|
39
|
+
// build in place (`--watch`).
|
|
40
|
+
const out = `${target}.building`;
|
|
41
|
+
const runtime = options.runtime ?? '@zubra/runtime';
|
|
42
|
+
const sources = walk(root);
|
|
43
|
+
if (!sources.length) throw new Error(`zubra build --lib: no components (.zb, .zb.jsx, .zb.tsx) in ${dir}`);
|
|
44
|
+
if (target === root || root.startsWith(target + sep)) throw new Error('zubra build --lib: the output directory must not contain the components');
|
|
45
|
+
rmSync(out, { recursive: true, force: true });
|
|
46
|
+
try {
|
|
47
|
+
return finish(build());
|
|
48
|
+
} catch (error) {
|
|
49
|
+
rmSync(out, { recursive: true, force: true });
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
function finish(result) {
|
|
53
|
+
rmSync(target, { recursive: true, force: true });
|
|
54
|
+
renameSync(out, target);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
function build() {
|
|
58
|
+
|
|
59
|
+
const write = (path, text) => {
|
|
60
|
+
mkdirSync(dirname(join(out, path)), { recursive: true });
|
|
61
|
+
writeFileSync(join(out, path), text);
|
|
62
|
+
};
|
|
63
|
+
const compile = (path, options) => {
|
|
64
|
+
const response = compileFile(path, { runtime, ...options });
|
|
65
|
+
if (!response.ok) throw new Error(response.error);
|
|
66
|
+
return response;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const components = [];
|
|
70
|
+
const warnings = [];
|
|
71
|
+
const modules = [];
|
|
72
|
+
for (const path of sources) {
|
|
73
|
+
const module = posix(relative(root, path)).replace(SOURCE, '');
|
|
74
|
+
const js = compile(path, { emit: 'js' });
|
|
75
|
+
warnings.push(...js.warnings);
|
|
76
|
+
const manifest = JSON.parse(compile(path, { emit: 'manifest', modulePath: `${module}.js` }).output);
|
|
77
|
+
const declaration = manifest.modules[0].declarations.find((item) => item.customElement);
|
|
78
|
+
const tag = declaration.tagName;
|
|
79
|
+
const name = declaration.name ?? pascal(tag);
|
|
80
|
+
modules.push(...manifest.modules);
|
|
81
|
+
const face = JSON.parse(compile(path, { emit: 'interface' }).output);
|
|
82
|
+
components.push({ tag, name, module, declaration, interface: face });
|
|
83
|
+
|
|
84
|
+
// The element, also exported by its class name (as its typings declare).
|
|
85
|
+
write(`${module}.js`, `${js.output.replace(/^export default \$define\(/m, 'const $element = $define(')}
|
|
86
|
+
export default $element;
|
|
87
|
+
export { $element as ${name} };
|
|
88
|
+
`);
|
|
89
|
+
write(`${module}.d.ts`, compile(path, { emit: 'dts', framework: 'none' }).output);
|
|
90
|
+
const up = '../'.repeat(module.split('/').length);
|
|
91
|
+
for (const framework of ['react', 'vue']) {
|
|
92
|
+
// React's entries are client components (they use hooks): React Server Components need
|
|
93
|
+
// the directive to import them from a server component.
|
|
94
|
+
const directive = framework === 'react' ? "'use client';\n" : '';
|
|
95
|
+
write(`${framework}/${module}.js`, `${directive}import Element from ${JSON.stringify(`${up}${module}.js`)};
|
|
96
|
+
import { wrap } from ${JSON.stringify(`${runtime}/${framework}`)};
|
|
97
|
+
|
|
98
|
+
export * from ${JSON.stringify(`${up}${module}.js`)};
|
|
99
|
+
export default wrap(Element);
|
|
100
|
+
`);
|
|
101
|
+
write(`${framework}/${module}.d.ts`, compile(path, { emit: 'dts', framework }).output);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const tags = new Map();
|
|
105
|
+
for (const component of components) {
|
|
106
|
+
if (tags.has(component.tag)) throw new Error(`zubra build --lib: <${component.tag}> is defined by both ${tags.get(component.tag)} and ${component.module}`);
|
|
107
|
+
tags.set(component.tag, component.module);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const index = () => components.map(({ name, module }) => `export { default as ${name} } from ${JSON.stringify(`./${module}.js`)};`).join('\n') + '\n';
|
|
111
|
+
write('index.js', index());
|
|
112
|
+
write('index.d.ts', index());
|
|
113
|
+
for (const framework of ['react', 'vue']) {
|
|
114
|
+
write(`${framework}/index.js`, (framework === 'react' ? "'use client';\n" : '') + index());
|
|
115
|
+
write(`${framework}/index.d.ts`, index());
|
|
116
|
+
}
|
|
117
|
+
write('custom-elements.json', JSON.stringify({ schemaVersion: '2.1.0', readme: '', modules }, null, 2) + '\n');
|
|
118
|
+
|
|
119
|
+
// package.json: what's given, then the project's own package.json, then defaults.
|
|
120
|
+
const project = existsSync('package.json') ? JSON.parse(readFileSync('package.json', 'utf8')) : {};
|
|
121
|
+
const runtimeRange = `^${compilerVersion.replace(/^(\d+\.\d+)\..*/, '$1.0')}`;
|
|
122
|
+
// npm packages the components import (a Zag machine, say), as peers: the app installs them
|
|
123
|
+
// once. The range is the project's own, where it lists the package.
|
|
124
|
+
const ranges = { ...project.peerDependencies, ...project.devDependencies, ...project.dependencies };
|
|
125
|
+
const imported = [...new Set(readdirSync(out, { recursive: true }).filter((file) => file.endsWith('.js') && !/^(react|vue)[\\/]/.test(file)).flatMap((file) =>
|
|
126
|
+
[...readFileSync(join(out, file), 'utf8').matchAll(/(?:^|\n)\s*(?:import|export)\b[^'"]*?\bfrom\s*['"]((?:@[\w.-]+\/)?[\w.-]+)[^'"]*['"]/g)].map((match) => match[1])))]
|
|
127
|
+
.filter((name) => name !== runtime && !name.startsWith('.')).sort();
|
|
128
|
+
if (imported.some((name) => name.startsWith('@zag-js/')) && !imported.includes('@zag-js/vanilla')) imported.push('@zag-js/vanilla');
|
|
129
|
+
const pkg = {
|
|
130
|
+
name: options.name ?? project.name ?? 'zubra-components',
|
|
131
|
+
version: options.version ?? project.version ?? '0.1.0',
|
|
132
|
+
...(project.description ? { description: project.description } : {}),
|
|
133
|
+
...(project.license ? { license: project.license } : {}),
|
|
134
|
+
type: 'module',
|
|
135
|
+
main: './index.js',
|
|
136
|
+
types: './index.d.ts',
|
|
137
|
+
customElements: './custom-elements.json',
|
|
138
|
+
// Importing a module defines its element.
|
|
139
|
+
sideEffects: ['./*.js', './**/*.js'],
|
|
140
|
+
exports: {
|
|
141
|
+
'.': { types: './index.d.ts', default: './index.js' },
|
|
142
|
+
'./react': { types: './react/index.d.ts', default: './react/index.js' },
|
|
143
|
+
'./vue': { types: './vue/index.d.ts', default: './vue/index.js' },
|
|
144
|
+
'./custom-elements.json': './custom-elements.json',
|
|
145
|
+
'./package.json': './package.json',
|
|
146
|
+
...Object.fromEntries(components.flatMap(({ module }) => [
|
|
147
|
+
[`./${module}`, { types: `./${module}.d.ts`, default: `./${module}.js` }],
|
|
148
|
+
[`./react/${module}`, { types: `./react/${module}.d.ts`, default: `./react/${module}.js` }],
|
|
149
|
+
[`./vue/${module}`, { types: `./vue/${module}.d.ts`, default: `./vue/${module}.js` }],
|
|
150
|
+
])),
|
|
151
|
+
},
|
|
152
|
+
peerDependencies: { [runtime]: runtimeRange, ...Object.fromEntries(imported.map((name) => [name, ranges[name] ?? '*'])), react: '>=18', vue: '>=3.3' },
|
|
153
|
+
peerDependenciesMeta: { react: { optional: true }, vue: { optional: true } },
|
|
154
|
+
};
|
|
155
|
+
write('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
156
|
+
// A skill for coding agents: how to use this library's components, from the manifest, so an
|
|
157
|
+
// agent needn't read the modules to learn the props, events and slots.
|
|
158
|
+
write(`skills/${skillName(pkg.name)}/SKILL.md`, skill(pkg.name, components));
|
|
159
|
+
return { components: components.map(({ tag, name, module }) => ({ tag, name, module })), warnings };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const skillName = (name) => `using-${name.replace(/^@/, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase()}`;
|
|
164
|
+
|
|
165
|
+
/** The library's skill: one section per component, from its manifest declaration and interface. */
|
|
166
|
+
function skill(name, components) {
|
|
167
|
+
const code = (text) => `\`${text}\``;
|
|
168
|
+
const sections = components.map(({ tag, name: className, module, declaration, interface: face }) => {
|
|
169
|
+
const props = face.props ?? [];
|
|
170
|
+
const events = declaration.events ?? [];
|
|
171
|
+
const slots = (declaration.slots ?? []).map((slot) => slot.name || 'default');
|
|
172
|
+
const model = props.filter((prop) => prop.model).map((prop) => prop.name);
|
|
173
|
+
const lines = [`### ${code(`<${tag}>`)}`, ''];
|
|
174
|
+
if (declaration.description) lines.push(declaration.description.replace(/\s*\n\s*/g, ' '), '');
|
|
175
|
+
lines.push(`Import ${code(`${name}/${module}`)} (the element, also exported as ${code(className)}), ${code(`${name}/react/${module}`)} (a React component) or ${code(`${name}/vue/${module}`)} (a Vue component).`, '');
|
|
176
|
+
if (props.length) {
|
|
177
|
+
lines.push('| Prop | Type | Default | |', '|---|---|---|---|');
|
|
178
|
+
for (const prop of props) {
|
|
179
|
+
const note = [prop.required ? 'required' : '', prop.model ? `two-way: fires ${code(`${prop.name}change`)}` : ''].filter(Boolean).join('; ');
|
|
180
|
+
lines.push(`| ${code(prop.name)} | ${code(prop.type ?? 'unknown')} | ${prop.required ? '' : code(JSON.stringify(prop.default))} | ${note} |`);
|
|
181
|
+
}
|
|
182
|
+
lines.push('');
|
|
183
|
+
}
|
|
184
|
+
if (events.length) {
|
|
185
|
+
lines.push('Events (bubbling `CustomEvent`s, data in `detail`):', '');
|
|
186
|
+
for (const event of events) lines.push(`- ${code(event.name)} (${code(event.type?.text ?? 'CustomEvent')})${event.description ? `: ${event.description.replace(/\s*\n\s*/g, ' ')}` : ''}`);
|
|
187
|
+
lines.push('');
|
|
188
|
+
}
|
|
189
|
+
if (slots.length) lines.push(`Slots: ${slots.map((slot) => code(slot)).join(', ')}.`, '');
|
|
190
|
+
if (face.form) lines.push('A form control: give it a `name` and it submits with the form, validates, and resets.', '');
|
|
191
|
+
if (model.length) lines.push(`Two-way binding: ${model.map((prop) => `${code(prop)} with ${code(`${prop}change`)}`).join(', ')} (Vue ${code('v-model')}, Svelte ${code('bind:')}, React controlled, Angular ${code('zubraModel')}).`, '');
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
});
|
|
194
|
+
return `---
|
|
195
|
+
name: ${skillName(name)}
|
|
196
|
+
description: Use the ${name} web components (${components.map((c) => `<${c.tag}>`).join(', ')}) from React, Vue, Svelte, Solid, Angular, plain HTML or a server-rendered page. Covers each component's props, events, slots and two-way binding.
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
${name} is a library of zubra web components. Each is a standard custom element: **properties in, DOM events out, children as slots.** It works in every framework without integration code, and ${code(`${name}/react`)} and ${code(`${name}/vue`)} export ready-made wrappers.
|
|
200
|
+
|
|
201
|
+
## Steps
|
|
202
|
+
|
|
203
|
+
1. Import the component the way the app's framework reads imports: React and Vue apps import from ${code(`${name}/react`)} or ${code(`${name}/vue`)} (or the per-component path); everything else imports ${code(`${name}/<component>`)}, which defines the element, and writes the tag.
|
|
204
|
+
2. Pass objects and arrays as properties (every framework binding does; in raw HTML, JSON in the attribute). Listen for the event names below, lowercase, with ${code('event.detail')}.
|
|
205
|
+
3. Done when the component renders, a typo'd prop is a type error (typings ship with the package), and the handler receives ${code('detail')}.
|
|
206
|
+
|
|
207
|
+
| Framework | Props | Events | Two-way |
|
|
208
|
+
|---|---|---|---|
|
|
209
|
+
| React | ${code('<Tag value={v} items={list} />')} | ${code('onValueChange={(e) => e.detail}')} | ${code('value')} + ${code('onValueChange')} |
|
|
210
|
+
| Vue | ${code(':items="list"')} | ${code('@valuechange="…"')} | ${code('v-model')} / ${code('v-model:prop')} |
|
|
211
|
+
| Svelte | ${code('{value} items={list}')} | ${code('onvaluechange={…}')} | ${code('bind:value')} |
|
|
212
|
+
| Solid | ${code('prop:items={list}')} | ${code('on:valuechange={…}')} | ${code('prop:value')} + ${code('on:valuechange')} |
|
|
213
|
+
| Angular (${code('CUSTOM_ELEMENTS_SCHEMA')}) | ${code('[items]="list"')} | ${code('(valuechange)="…"')} | ${code('zubraModel [(ngModel)]')} from ${code('@zubra/runtime/angular')} |
|
|
214
|
+
| Plain HTML | ${code('el.items = list')} or a JSON attribute | ${code("addEventListener('valuechange', …)")} | the pair by hand |
|
|
215
|
+
|
|
216
|
+
Server rendering: the elements hydrate server HTML in place; inside Nuxt, SvelteKit, React Router, SolidStart or Next.js, use ${code('@zubra/server')}'s ${code('injectShadowRoots()')} (see https://github.com/journey-to-code-org/zubra/blob/main/docs/server-rendering.md).
|
|
217
|
+
|
|
218
|
+
## Components
|
|
219
|
+
|
|
220
|
+
${sections.join('\n\n')}
|
|
221
|
+
|
|
222
|
+
The full manifest is ${code('custom-elements.json')} in this package.
|
|
223
|
+
`;
|
|
224
|
+
}
|