babel-plugin-solarite 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * babel-plugin-solarite (Tier 1b).
3
+ *
4
+ * Transforms JSX/TSX into the same "precompile" contract Deno emits, so esbuild/Vite/Babel/tsc
5
+ * users get Tier 1 performance (hoisted module-level static arrays => stable identity => Solarite's
6
+ * Shell cache and NodeGroup reuse): each element subtree becomes a hoisted `const _tpl = [...]` plus
7
+ * `jsxTemplate(_tpl, jsxAttr(name, value), jsxEscape(child), ...)`. Components and elements with a
8
+ * spread attribute become `jsx(tag, props, key)` calls, routed to the same runtime's Tier 2 factory.
9
+ *
10
+ * The plugin needs no HTML parser: @babel/parser already parses JSX, so the job is the reverse —
11
+ * serialize that AST back into static html strings with holes punched at the dynamic points.
12
+ *
13
+ * @param {{types: import('@babel/types')}} babel
14
+ * @param {{importSource?: string}} options */
15
+ import syntaxJsx from '@babel/plugin-syntax-jsx';
16
+
17
+ export default function solariteJsx({ types: t }, options = {}) {
18
+ const importSource = options.importSource || 'solarite/jsx-runtime';
19
+
20
+ // HTML void elements never get a closing tag.
21
+ const VOID = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']);
22
+
23
+ const escapeText = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
24
+ const escapeAttr = s => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
25
+
26
+ // JSX whitespace cleaning (same rules React/Babel use for literal text children).
27
+ function cleanJSXText(raw) {
28
+ const lines = raw.split(/\r\n|\n|\r/);
29
+ let lastNonEmpty = 0;
30
+ for (let i = 0; i < lines.length; i++)
31
+ if (/[^ \t]/.test(lines[i])) lastNonEmpty = i;
32
+ let str = '';
33
+ for (let i = 0; i < lines.length; i++) {
34
+ let line = lines[i].replace(/\t/g, ' ');
35
+ if (i !== 0) line = line.replace(/^ +/, '');
36
+ if (i !== lines.length - 1) line = line.replace(/ +$/, '');
37
+ if (line) {
38
+ if (i !== lastNonEmpty) line += ' ';
39
+ str += line;
40
+ }
41
+ }
42
+ return str;
43
+ }
44
+
45
+ function tagName(node) {
46
+ let n = node.name || node.openingElement?.name;
47
+ if (t.isJSXIdentifier(n)) return n.name;
48
+ if (t.isJSXNamespacedName(n)) return n.namespace.name + ':' + n.name.name;
49
+ return null; // member expression => component
50
+ }
51
+
52
+ // A capitalized identifier or a member expression (Foo.Bar) is a component; lowercase/dashed
53
+ // names (including custom-element tags like `my-button`) are intrinsic strings.
54
+ function isComponent(node) {
55
+ let n = node.openingElement.name;
56
+ if (t.isJSXMemberExpression(n)) return true;
57
+ if (t.isJSXIdentifier(n)) return /^[A-Z]/.test(n.name);
58
+ return false;
59
+ }
60
+
61
+ function componentExpr(name) {
62
+ if (t.isJSXMemberExpression(name))
63
+ return t.memberExpression(componentExpr(name.object), t.identifier(name.property.name));
64
+ if (t.isJSXNamespacedName(name))
65
+ return t.stringLiteral(name.namespace.name + ':' + name.name.name);
66
+ return t.identifier(name.name); // capitalized => references the imported component
67
+ }
68
+
69
+ function attrName(attr) {
70
+ let n = attr.name;
71
+ return t.isJSXNamespacedName(n) ? n.namespace.name + ':' + n.name.name : n.name;
72
+ }
73
+
74
+ function attrValueExpr(attr) {
75
+ if (attr.value == null) return t.booleanLiteral(true); // bare boolean attribute
76
+ if (t.isStringLiteral(attr.value)) return attr.value; // name="literal"
77
+ if (t.isJSXExpressionContainer(attr.value)) return attr.value.expression;
78
+ return attr.value;
79
+ }
80
+
81
+ // ---- Serializer state for one transformed root ----
82
+ // strings: accumulated static html chunks; exprs: the hole expressions (jsxAttr/jsxEscape/jsx).
83
+ function makeBuilder(state) {
84
+ const strings = [];
85
+ let current = '';
86
+ const exprs = [];
87
+ const used = state.used;
88
+
89
+ const helper = name => { used.add(name); return t.cloneNode(state.ids[name]); };
90
+ const pushHole = expr => { strings.push(current); current = ''; exprs.push(expr); };
91
+
92
+ const api = {
93
+ text: s => { current += s; },
94
+ jsxAttr: (name, valueNode) => pushHole(t.callExpression(helper('jsxAttr'), [t.stringLiteral(name), valueNode])),
95
+ jsxEscape: valueNode => pushHole(t.callExpression(helper('jsxEscape'), [valueNode])),
96
+ finish() {
97
+ strings.push(current);
98
+ return { strings, exprs };
99
+ },
100
+ helper,
101
+ };
102
+ return api;
103
+ }
104
+
105
+ // Serialize one element's children (already inside the open tag) into the builder.
106
+ function buildChildren(children, b, state) {
107
+ for (const child of children) {
108
+ if (t.isJSXText(child)) {
109
+ const txt = cleanJSXText(child.value);
110
+ if (txt) b.text(escapeText(txt));
111
+ }
112
+ else if (t.isJSXExpressionContainer(child)) {
113
+ if (t.isJSXEmptyExpression(child.expression)) continue; // a {/* comment */}
114
+ b.jsxEscape(child.expression); // dynamic child; inner JSX is transformed on Babel's re-visit
115
+ }
116
+ else if (t.isJSXSpreadChild(child)) {
117
+ b.jsxEscape(child.expression);
118
+ }
119
+ else if (t.isJSXElement(child) || t.isJSXFragment(child)) {
120
+ if (t.isJSXElement(child) && (isComponent(child) || hasSpread(child)))
121
+ b.jsxEscape(buildNode(child, state)); // component/spread child => its own jsx()/jsxTemplate hole
122
+ else
123
+ inlineElement(child, b, state); // intrinsic child => merged into the parent statics
124
+ }
125
+ }
126
+ }
127
+
128
+ function hasSpread(node) {
129
+ return node.openingElement.attributes.some(a => t.isJSXSpreadAttribute(a));
130
+ }
131
+
132
+ // Inline an intrinsic element (and its subtree) directly into the parent's static strings.
133
+ function inlineElement(node, b, state) {
134
+ if (t.isJSXFragment(node)) {
135
+ buildChildren(node.children, b, state);
136
+ return;
137
+ }
138
+ const tag = tagName(node);
139
+ b.text('<' + tag);
140
+ for (const attr of node.openingElement.attributes) {
141
+ const name = attrName(attr);
142
+ const value = attr.value;
143
+ if (name === 'key') { // key is always a hole; the runtime lifts it onto template.key.
144
+ b.text(' ');
145
+ b.jsxAttr('key', attrValueExpr(attr));
146
+ continue;
147
+ }
148
+ if (value == null) // bare boolean attribute
149
+ b.text(' ' + name);
150
+ else if (t.isStringLiteral(value)) // static value
151
+ b.text(' ' + name + '="' + escapeAttr(value.value) + '"');
152
+ else { // dynamic value => whole-attribute hole
153
+ b.text(' ');
154
+ b.jsxAttr(name, attrValueExpr(attr));
155
+ }
156
+ }
157
+ b.text('>');
158
+ if (!VOID.has(tag.toLowerCase())) {
159
+ buildChildren(node.children, b, state);
160
+ b.text('</' + tag + '>');
161
+ }
162
+ }
163
+
164
+ // Build the expression a JSX node becomes (jsxTemplate(...) for intrinsic/fragment, jsx(...) for
165
+ // components or spread elements). Hoists the statics array to module scope.
166
+ function buildNode(node, state) {
167
+ if (t.isJSXElement(node) && (isComponent(node) || hasSpread(node)))
168
+ return buildComponent(node, state);
169
+
170
+ const b = makeBuilder(state);
171
+ inlineElement(node, b, state);
172
+ const { strings, exprs } = b.finish();
173
+
174
+ // Hoist `const _tpl = [ ...static strings ]` to the top of the module.
175
+ const id = t.identifier(state.scope.generateUid('tpl'));
176
+ state.hoist.push(t.variableDeclaration('const', [
177
+ t.variableDeclarator(id, t.arrayExpression(strings.map(s => t.stringLiteral(s)))),
178
+ ]));
179
+ state.used.add('jsxTemplate');
180
+ return t.callExpression(t.cloneNode(state.ids.jsxTemplate), [id, ...exprs]);
181
+ }
182
+
183
+ // Build a jsx(tag, props, key?) call for a component or spread element.
184
+ function buildComponent(node, state) {
185
+ const opening = node.openingElement;
186
+ const props = [];
187
+ let keyExpr = null;
188
+
189
+ for (const attr of opening.attributes) {
190
+ if (t.isJSXSpreadAttribute(attr)) {
191
+ props.push(t.spreadElement(attr.argument));
192
+ continue;
193
+ }
194
+ const name = attrName(attr);
195
+ if (name === 'key') { keyExpr = attrValueExpr(attr); continue; }
196
+ props.push(t.objectProperty(
197
+ /^[a-z][\w$]*$/i.test(name) ? t.identifier(name) : t.stringLiteral(name),
198
+ attrValueExpr(attr)));
199
+ }
200
+
201
+ // Children -> props.children (single child or array), each transformed.
202
+ const kids = childExprs(node.children, state);
203
+ if (kids.length === 1)
204
+ props.push(t.objectProperty(t.identifier('children'), kids[0]));
205
+ else if (kids.length > 1)
206
+ props.push(t.objectProperty(t.identifier('children'), t.arrayExpression(kids)));
207
+
208
+ const tagExpr = isComponent(node) ? componentExpr(opening.name) : t.stringLiteral(tagName(node));
209
+ state.used.add('jsx');
210
+ const args = [tagExpr, t.objectExpression(props)];
211
+ if (keyExpr) args.push(keyExpr);
212
+ return t.callExpression(t.cloneNode(state.ids.jsx), args);
213
+ }
214
+
215
+ function childExprs(children, state) {
216
+ const out = [];
217
+ for (const child of children) {
218
+ if (t.isJSXText(child)) {
219
+ const txt = cleanJSXText(child.value);
220
+ if (txt) out.push(t.stringLiteral(txt));
221
+ }
222
+ else if (t.isJSXExpressionContainer(child)) {
223
+ if (!t.isJSXEmptyExpression(child.expression)) out.push(child.expression);
224
+ }
225
+ else if (t.isJSXElement(child) || t.isJSXFragment(child))
226
+ out.push(buildNode(child, state));
227
+ }
228
+ return out;
229
+ }
230
+
231
+ return {
232
+ name: 'babel-plugin-solarite',
233
+ // Enable JSX parsing so a bare `{ "plugins": ["babel-plugin-solarite"] }` config works on its own.
234
+ inherits: syntaxJsx,
235
+ visitor: {
236
+ Program: {
237
+ enter(path) {
238
+ const helpers = ['jsxTemplate', 'jsxAttr', 'jsxEscape', 'jsx', 'jsxs', 'Fragment'];
239
+ const ids = {};
240
+ for (const h of helpers)
241
+ ids[h] = t.identifier(path.scope.generateUid('_' + h));
242
+ this.solarite = { ids, used: new Set(), hoist: [], scope: path.scope };
243
+ },
244
+ exit(path) {
245
+ const s = this.solarite;
246
+ if (!s.used.size) return;
247
+ // Import only the helpers that were used.
248
+ const specifiers = [...s.used].map(name =>
249
+ t.importSpecifier(t.cloneNode(s.ids[name]), t.identifier(name)));
250
+ path.unshiftContainer('body', t.importDeclaration(specifiers, t.stringLiteral(importSource)));
251
+ // Hoist the static-template consts just after the import.
252
+ path.node.body.splice(1, 0, ...s.hoist);
253
+ },
254
+ },
255
+ JSXElement(path) {
256
+ // Only roots: direct intrinsic JSX children are inlined by their ancestor's serializer.
257
+ if (path.parentPath.isJSXElement() || path.parentPath.isJSXFragment()) return;
258
+ path.replaceWith(buildNode(path.node, this.solarite));
259
+ },
260
+ JSXFragment(path) {
261
+ if (path.parentPath.isJSXElement() || path.parentPath.isJSXFragment()) return;
262
+ path.replaceWith(buildNode(path.node, this.solarite));
263
+ },
264
+ },
265
+ };
266
+ }
package/cli.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /** Standalone CLI: `solarite-jsx <file.tsx>` prints Tier 1 precompile JS to stdout (TS types
3
+ * stripped). Lets non-Node-bundler hosts — e.g. a PHP/Apache wrapper mapping .tsx/.jsx requests —
4
+ * shell out for Tier 1 output, which esbuild's binary CLI can't produce. */
5
+ import fs from 'node:fs';
6
+ import { transform } from './transform.js';
7
+
8
+ const args = process.argv.slice(2);
9
+ const file = args.find(a => !a.startsWith('-'));
10
+ if (!file) {
11
+ process.stderr.write('usage: solarite-jsx <file.jsx|.tsx> [--import-source=solarite/jsx-runtime]\n');
12
+ process.exit(1);
13
+ }
14
+ const importSourceArg = args.find(a => a.startsWith('--import-source='));
15
+ const source = fs.readFileSync(file, 'utf8');
16
+ const { code } = transform(source, {
17
+ filename: file,
18
+ importSource: importSourceArg ? importSourceArg.split('=')[1] : undefined,
19
+ });
20
+ process.stdout.write(code);
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /** babel-plugin-solarite — compiles JSX/TSX to Solarite precompile output.
2
+ *
3
+ * The default export is the Babel plugin, so `{ "plugins": ["babel-plugin-solarite"] }` works in a
4
+ * Babel config. The named `transform` runs Babel for you (it adds JSX/TS parsing and returns the
5
+ * compiled code); vite-plugin-solarite and esbuild-plugin-solarite both build on it. */
6
+ export { default } from './babel-plugin.js';
7
+ export { transform } from './transform.js';
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "babel-plugin-solarite",
3
+ "version": "0.7.0",
4
+ "description": "Babel plugin that compiles JSX/TSX to Solarite precompile output (jsxTemplate/jsxAttr/jsxEscape), giving JSX the same runtime speed as h tagged templates. Also the shared engine behind vite-plugin-solarite and esbuild-plugin-solarite.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Eric Frost",
8
+ "main": "./index.js",
9
+ "exports": {
10
+ ".": "./index.js"
11
+ },
12
+ "bin": {
13
+ "solarite-jsx": "./cli.js"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "babel-plugin.js",
18
+ "transform.js",
19
+ "cli.js",
20
+ "readme.md"
21
+ ],
22
+ "keywords": [
23
+ "solarite",
24
+ "jsx",
25
+ "tsx",
26
+ "babel",
27
+ "babel-plugin",
28
+ "precompile"
29
+ ],
30
+ "dependencies": {
31
+ "@babel/core": "^8.0.1",
32
+ "@babel/plugin-syntax-jsx": "^8.0.1",
33
+ "@babel/preset-typescript": "^8.0.1"
34
+ },
35
+ "peerDependencies": {
36
+ "solarite": "^0.7.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "solarite": { "optional": true }
40
+ },
41
+ "scripts": {
42
+ "test": "node test.js"
43
+ }
44
+ }
package/readme.md ADDED
@@ -0,0 +1,54 @@
1
+ # babel-plugin-solarite
2
+
3
+ Compiles JSX/TSX into **Solarite precompile output** — the static HTML of each element is hoisted to
4
+ a module-level array and emitted as `jsxTemplate`/`jsxAttr`/`jsxEscape` calls (the same contract
5
+ Deno's `jsx: "precompile"` produces). That gives JSX the **same runtime speed as `h` tagged
6
+ templates**. Components and elements with a spread become `jsx(tag, props, key)` calls, handled by
7
+ Solarite's runtime.
8
+
9
+ This package is also the shared engine for [`vite-plugin-solarite`](../vite-plugin-solarite) and
10
+ [`esbuild-plugin-solarite`](../esbuild-plugin-solarite). Use those if you're on Vite or esbuild; use
11
+ this package directly for Babel, or programmatically.
12
+
13
+ It runs only at build time and is never shipped to the browser. The JSX runtime it targets lives in
14
+ the core `solarite` package (`solarite/jsx-runtime`).
15
+
16
+ ## Babel
17
+
18
+ ```sh
19
+ npm install --save-dev babel-plugin-solarite
20
+ ```
21
+
22
+ ```jsonc
23
+ // babel.config.json (add @babel/preset-typescript if you use .tsx)
24
+ { "plugins": ["babel-plugin-solarite"] }
25
+ ```
26
+
27
+ The plugin enables JSX parsing itself, so no separate `@babel/plugin-syntax-jsx` is needed. Babel's
28
+ shorthand also works: `{ "plugins": ["solarite"] }`.
29
+
30
+ ## Programmatic
31
+
32
+ ```js
33
+ import { transform } from 'babel-plugin-solarite';
34
+ const { code, map } = transform(src, { filename: 'app.tsx' });
35
+ ```
36
+
37
+ ## CLI
38
+
39
+ Prints compiled JS to stdout (TypeScript types stripped) — handy for non-Node hosts shelling out:
40
+
41
+ ```sh
42
+ npx solarite-jsx app.tsx > app.js
43
+ ```
44
+
45
+ ## Options
46
+
47
+ - `importSource` — runtime module specifier (default `"solarite/jsx-runtime"`).
48
+
49
+ ## Notes
50
+
51
+ - Use native HTML attribute/event names (`class`, `onclick`), not React's `className`/`onClick`.
52
+ - `style={{…}}` objects are serialized to CSS text by the runtime.
53
+ - `key={…}` becomes the keyed-list key, never a rendered attribute.
54
+ - Static `id`/`data-id` stay in the static HTML so Solarite's `this.x` element references resolve.
package/transform.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Core transform: JSX/TSX source -> Tier 1 precompile JS, using the babel-plugin-solarite plugin.
3
+ * Strips TypeScript types too, so .tsx works without a separate tsc pass. */
4
+ import { transformSync } from '@babel/core';
5
+ import solariteJsx from './babel-plugin.js';
6
+
7
+ /**
8
+ * @param {string} code Source code.
9
+ * @param {object} [opts]
10
+ * @param {string} [opts.filename] Used to pick ts/tsx parsing and for sourcemaps.
11
+ * @param {string} [opts.importSource] Runtime module specifier (default "solarite/jsx-runtime").
12
+ * @param {boolean} [opts.sourceMaps] Emit a sourcemap.
13
+ * @returns {{code: string, map: object|null}} */
14
+ export function transform(code, opts = {}) {
15
+ const filename = opts.filename || 'input.tsx';
16
+ const isTs = /\.(ts|tsx|mts|cts)$/.test(filename);
17
+
18
+ // preset-typescript strips TS types (parsing alone keeps them); it also sets up JSX parsing.
19
+ // For plain .jsx we add the jsx parser plugin ourselves.
20
+ // preset-typescript strips TS types (parsing alone keeps them). We always enable the jsx parser
21
+ // plugin so both .jsx and .tsx parse JSX; the preset removes the TypeScript syntax for .ts/.tsx.
22
+ const presets = isTs ? ['@babel/preset-typescript'] : [];
23
+
24
+ const result = transformSync(code, {
25
+ filename,
26
+ babelrc: false,
27
+ configFile: false,
28
+ sourceMaps: opts.sourceMaps || false,
29
+ parserOpts: { plugins: ['jsx'] },
30
+ presets,
31
+ plugins: [[solariteJsx, { importSource: opts.importSource }]],
32
+ });
33
+ return { code: result.code, map: result.map || null };
34
+ }
35
+
36
+ export default transform;