@botfather/units 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Units Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/incremental.js ADDED
@@ -0,0 +1,64 @@
1
+ // Incremental parsing sketch using node start/end offsets.
2
+ // This is a reference strategy; it falls back to full parse today.
3
+
4
+ import { parseUnits } from "./units-parser.js";
5
+
6
+ export function findChangedRange(prev, next) {
7
+ const prevLen = prev.length;
8
+ const nextLen = next.length;
9
+
10
+ let start = 0;
11
+ while (start < prevLen && start < nextLen && prev[start] === next[start]) start++;
12
+
13
+ if (start === prevLen && start === nextLen) {
14
+ return { start, endPrev: start, endNext: start };
15
+ }
16
+
17
+ let endPrev = prevLen - 1;
18
+ let endNext = nextLen - 1;
19
+ while (endPrev >= start && endNext >= start && prev[endPrev] === next[endNext]) {
20
+ endPrev--;
21
+ endNext--;
22
+ }
23
+
24
+ return { start, endPrev: endPrev + 1, endNext: endNext + 1 };
25
+ }
26
+
27
+ export function findSmallestEnclosingNode(ast, start, end) {
28
+ let best = null;
29
+
30
+ function visit(node) {
31
+ if (!node || node.start == null || node.end == null) return;
32
+ if (node.start <= start && node.end >= end) {
33
+ if (!best || (node.end - node.start) < (best.end - best.start)) best = node;
34
+ if (node.children) node.children.forEach(visit);
35
+ if (node.body) node.body.forEach(visit);
36
+ }
37
+ }
38
+
39
+ visit(ast);
40
+ return best;
41
+ }
42
+
43
+ export function incrementalParse(prevAst, prevSource, nextSource) {
44
+ const change = findChangedRange(prevSource, nextSource);
45
+
46
+ // If no change, return previous AST.
47
+ if (change.start === change.endPrev && change.start === change.endNext) return prevAst;
48
+
49
+ // Strategy sketch:
50
+ // 1) Find smallest node in prevAst enclosing the change range.
51
+ // 2) Reparse just that slice of text into a temporary AST.
52
+ // 3) Splice the new subtree into the previous AST, adjusting offsets by delta.
53
+ // 4) Recalculate parent node end offsets above the splice point.
54
+ //
55
+ // This yields near-O(changed-region) for edits localized to a small subtree.
56
+ const enclosing = findSmallestEnclosingNode(prevAst, change.start, change.endPrev);
57
+
58
+ if (!enclosing) {
59
+ return parseUnits(nextSource);
60
+ }
61
+
62
+ // TODO: Implement subtree reparse and splice. For now, fall back.
63
+ return parseUnits(nextSource);
64
+ }
package/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function parseUnits(input: string): any;
2
+ export function formatUnits(source: string): string;
3
+ export function createUnitsEvaluator(): (raw: string, scope: any, locals?: any) => any;
4
+ export function renderUnits(ast: any, scope: any, options?: any): any;
5
+ export function createUnitsRenderer(host: any): { render: (ast: any, scope: any, options?: any) => any };
6
+ export function findChangedRange(prev: string, next: string): { start: number; endPrev: number; endNext: number };
7
+ export function findSmallestEnclosingNode(ast: any, start: number, end: number): any;
8
+ export function incrementalParse(prevAst: any, prevSource: string, nextSource: string): any;
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { parseUnits } from "./units-parser.js";
2
+ export { formatUnits } from "./units-print.js";
3
+ export { createUnitsEvaluator, renderUnits } from "./units-runtime.js";
4
+ export { createUnitsRenderer } from "./units-custom-renderer.js";
5
+ export {
6
+ findChangedRange,
7
+ findSmallestEnclosingNode,
8
+ incrementalParse,
9
+ } from "./incremental.js";
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@botfather/units",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight DSL for building interactive UIs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Botfather/units.git",
10
+ "directory": "packages/units"
11
+ },
12
+ "homepage": "https://github.com/Botfather/units/tree/main/packages/units",
13
+ "bugs": {
14
+ "url": "https://github.com/Botfather/units/issues"
15
+ },
16
+ "main": "./index.js",
17
+ "types": "./index.d.ts",
18
+ "exports": {
19
+ ".": "./index.js",
20
+ "./parser": "./units-parser.js",
21
+ "./print": "./units-print.js",
22
+ "./runtime": "./units-runtime.js",
23
+ "./custom-renderer": "./units-custom-renderer.js",
24
+ "./incremental": "./incremental.js",
25
+ "./ui": {
26
+ "types": "./ui.d.ts",
27
+ "default": "./ui.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "*.js",
32
+ "*.d.ts"
33
+ ],
34
+ "peerDependencies": {
35
+ "react": ">=18"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
package/ui.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ declare module "*.ui" {
2
+ export const source: string;
3
+ export const ast: {
4
+ type: "document" | "tag" | "text" | "expr" | "directive";
5
+ [key: string]: unknown;
6
+ };
7
+ const _default: typeof ast;
8
+ export default _default;
9
+ }
10
+
11
+ declare module "*.ui?format" {
12
+ const formatted: string;
13
+ export default formatted;
14
+ }
15
+
16
+ declare module "*.ui?tokens" {
17
+ const tokens: Array<{ type: string; value: string }>;
18
+ export default tokens;
19
+ }
20
+
21
+ declare module "*.ui?highlight" {
22
+ const html: string;
23
+ export default html;
24
+ }
package/ui.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ // Custom renderer skeleton for Units AST.
2
+ // Provide host callbacks to build your own UI tree.
3
+
4
+ export function createUnitsRenderer(host) {
5
+ const evalExpr = host.evalExpr || ((raw, scope, locals) => {
6
+ const normalized = raw.replace(/@([A-Za-z_$][\\w.$]*)/g, "$1");
7
+ const fn = new Function("scope", "locals", `with(scope){with(locals||{}){return (${normalized});}}`);
8
+ return fn(scope, locals || {});
9
+ });
10
+
11
+ function splitInterpolations(value) {
12
+ const text = String(value ?? "");
13
+ const parts = [];
14
+ if (!text.includes("@{")) return [{ type: "text", value: text }];
15
+ let i = 0;
16
+ while (i < text.length) {
17
+ const idx = text.indexOf("@{", i);
18
+ if (idx === -1) {
19
+ if (i < text.length) parts.push({ type: "text", value: text.slice(i) });
20
+ break;
21
+ }
22
+ if (idx > i) parts.push({ type: "text", value: text.slice(i, idx) });
23
+ let j = idx + 2;
24
+ let depth = 1;
25
+ while (j < text.length) {
26
+ const ch = text[j];
27
+ if (ch === "'" || ch === "\"") {
28
+ const quote = ch;
29
+ j++;
30
+ while (j < text.length) {
31
+ const qch = text[j];
32
+ if (qch === "\\") {
33
+ j += 2;
34
+ continue;
35
+ }
36
+ if (qch === quote) {
37
+ j++;
38
+ break;
39
+ }
40
+ j++;
41
+ }
42
+ continue;
43
+ }
44
+ if (ch === "{") depth++;
45
+ else if (ch === "}") depth--;
46
+ if (depth === 0) break;
47
+ j++;
48
+ }
49
+ if (depth !== 0) {
50
+ parts.push({ type: "text", value: text.slice(idx) });
51
+ break;
52
+ }
53
+ const expr = text.slice(idx + 2, j).trim();
54
+ parts.push({ type: "expr", value: expr });
55
+ i = j + 1;
56
+ }
57
+ return parts;
58
+ }
59
+
60
+ function renderText(value, locals) {
61
+ const parts = splitInterpolations(value);
62
+ if (parts.length === 1 && parts[0].type === "text") return host.text(parts[0].value);
63
+ return host.fragment(
64
+ parts.map((part) => {
65
+ if (part.type === "text") return host.text(part.value);
66
+ return host.text(String(evalExpr(part.value, scope, locals)));
67
+ }),
68
+ );
69
+ }
70
+
71
+ function pushChild(out, value) {
72
+ if (Array.isArray(value)) out.push(...value);
73
+ else out.push(value);
74
+ }
75
+
76
+ function render(ast, scope, options = {}) {
77
+ const slots = options.slots || {};
78
+ const set = options.set || scope?.set || (() => {});
79
+
80
+ function renderNode(node, locals) {
81
+ if (!node) return null;
82
+ if (node.type === "text") return renderText(node.value, locals);
83
+ if (node.type === "expr") return host.text(String(evalExpr(node.value.raw, scope, locals)));
84
+ if (node.type === "directive") return renderDirective(node, locals);
85
+ if (node.type === "tag") return renderTag(node, locals);
86
+ return null;
87
+ }
88
+
89
+ function renderDirective(node, locals) {
90
+ const name = node.name;
91
+ const args = node.args || "";
92
+ if (name === "if") {
93
+ const cond = evalExpr(args, scope, locals);
94
+ if (!cond) return null;
95
+ return renderChildren(node.children, locals);
96
+ }
97
+ if (name === "for") {
98
+ const m = args.match(/^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s+in\s+(.+)$/);
99
+ if (!m) return null;
100
+ const itemName = m[1];
101
+ const idxName = m[2] || "index";
102
+ const listExpr = m[3].trim();
103
+ const list = evalExpr(listExpr, scope, locals) || [];
104
+ const out = [];
105
+ for (let idx = 0; idx < list.length; idx++) {
106
+ const childLocals = { ...locals, [itemName]: list[idx], [idxName]: idx };
107
+ out.push(renderChildren(node.children, childLocals));
108
+ }
109
+ return out;
110
+ }
111
+ if (name === "slot") {
112
+ const slotName = args.trim();
113
+ const slot = slots[slotName];
114
+ if (slot == null) return null;
115
+ return typeof slot === "function" ? slot(locals) : slot;
116
+ }
117
+ return renderChildren(node.children, locals);
118
+ }
119
+
120
+ function renderTag(node, locals) {
121
+ const props = {};
122
+ const events = {};
123
+ for (const prop of node.props || []) {
124
+ if (prop.kind === "value") props[prop.key] = prop.value;
125
+ else if (prop.kind === "expr") props[prop.key] = evalExpr(prop.expr.raw, scope, locals);
126
+ else if (prop.kind === "bool") {
127
+ const v = evalExpr(prop.expr.raw, scope, locals);
128
+ if (v) props[prop.key] = true;
129
+ } else if (prop.kind === "event") {
130
+ const eventName = prop.key.startsWith("on:") ? prop.key.slice(3) : prop.key;
131
+ events[eventName] = (event) => {
132
+ const handlerLocals = { ...locals, event, set };
133
+ return evalExpr(prop.expr.raw, scope, handlerLocals);
134
+ };
135
+ }
136
+ }
137
+
138
+ const children = renderChildren(node.children, locals);
139
+ return host.element(node.name, props, events, children);
140
+ }
141
+
142
+ function renderChildren(children, locals) {
143
+ const out = [];
144
+ for (let idx = 0; idx < (children || []).length; idx++) {
145
+ const child = children[idx];
146
+ pushChild(out, renderNode(child, locals));
147
+ }
148
+ return host.fragment(out);
149
+ }
150
+
151
+ if (ast.type === "document") return renderChildren(ast.body, {});
152
+ return renderNode(ast, {});
153
+ }
154
+
155
+ return { render };
156
+ }
157
+
158
+ /*
159
+ Host interface example:
160
+ const host = {
161
+ element: (name, props, events, children) => ({ type: name, props, events, children }),
162
+ text: (value) => ({ type: "text", value }),
163
+ fragment: (children) => children,
164
+ };
165
+ */
@@ -0,0 +1,389 @@
1
+ // Minimal, dependency-free parser for Units.
2
+ // Single-pass, O(n) over input size.
3
+
4
+ export function parseUnits(input) {
5
+ const s = String(input ?? "");
6
+ const len = s.length;
7
+ let i = 0;
8
+
9
+ function isWS(ch) {
10
+ return ch === " " || ch === "\n" || ch === "\t" || ch === "\r";
11
+ }
12
+
13
+ function isIdentStart(ch) {
14
+ return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_";
15
+ }
16
+
17
+ function isIdent(ch) {
18
+ return isIdentStart(ch) || (ch >= "0" && ch <= "9") || ch === ":" || ch === "." || ch === "-";
19
+ }
20
+
21
+ function skipWS() {
22
+ while (i < len) {
23
+ const ch = s[i];
24
+ if (isWS(ch)) {
25
+ i++;
26
+ continue;
27
+ }
28
+ // line comments: //...
29
+ if (ch === "/" && s[i + 1] === "/") {
30
+ i += 2;
31
+ while (i < len && s[i] !== "\n") i++;
32
+ continue;
33
+ }
34
+ break;
35
+ }
36
+ }
37
+
38
+ function error(msg) {
39
+ const snippet = s.slice(Math.max(0, i - 20), Math.min(len, i + 20));
40
+ throw new Error(`${msg} at ${i}: ...${snippet}...`);
41
+ }
42
+
43
+ function readIdent() {
44
+ if (!isIdentStart(s[i])) error("Expected identifier");
45
+ const start = i;
46
+ i++;
47
+ while (i < len && isIdent(s[i])) i++;
48
+ return s.slice(start, i);
49
+ }
50
+
51
+ function isValueStart(ch, idx) {
52
+ if (!ch) return false;
53
+ if (ch === "'" || ch === "@" || ch === "{" || ch === "-" || (ch >= "0" && ch <= "9")) return true;
54
+ if (s.startsWith("true", idx)) return true;
55
+ if (s.startsWith("false", idx)) return true;
56
+ if (s.startsWith("null", idx)) return true;
57
+ return false;
58
+ }
59
+
60
+ function readPropKey() {
61
+ if (!isIdentStart(s[i])) error("Expected identifier");
62
+ const start = i;
63
+ i++;
64
+ while (i < len) {
65
+ const ch = s[i];
66
+ if (!isIdent(ch)) break;
67
+ if (ch === ":") {
68
+ // If ":" is followed by a value (after optional whitespace), treat as separator.
69
+ let j = i + 1;
70
+ while (j < len && isWS(s[j])) j++;
71
+ if (isValueStart(s[j], j)) break;
72
+ }
73
+ i++;
74
+ }
75
+ return s.slice(start, i);
76
+ }
77
+
78
+ function readString() {
79
+ if (s[i] !== "'") error("Expected string");
80
+ i++; // skip '
81
+ let out = "";
82
+ while (i < len) {
83
+ const ch = s[i];
84
+ if (ch === "'") {
85
+ i++;
86
+ return out;
87
+ }
88
+ if (ch === "\\") {
89
+ const next = s[i + 1];
90
+ if (next === "'" || next === "\\") {
91
+ out += next;
92
+ i += 2;
93
+ continue;
94
+ }
95
+ }
96
+ out += ch;
97
+ i++;
98
+ }
99
+ error("Unterminated string");
100
+ }
101
+
102
+ function readNumber() {
103
+ const start = i;
104
+ if (s[i] === "-") i++;
105
+ while (i < len && s[i] >= "0" && s[i] <= "9") i++;
106
+ if (s[i] === ".") {
107
+ i++;
108
+ while (i < len && s[i] >= "0" && s[i] <= "9") i++;
109
+ }
110
+ const raw = s.slice(start, i);
111
+ const n = Number(raw);
112
+ if (Number.isNaN(n)) error("Invalid number");
113
+ return n;
114
+ }
115
+
116
+ function readExprUntil(delims) {
117
+ // Read raw expression until encountering a delimiter (one of delims) at depth 0.
118
+ // This keeps parsing fast and leaves expression semantics to runtime.
119
+ let depthParen = 0;
120
+ let depthBrack = 0;
121
+ let depthBrace = 0;
122
+ let hadParen = false;
123
+ const start = i;
124
+ while (i < len) {
125
+ const ch = s[i];
126
+ if (ch === "'" ) {
127
+ // skip strings inside expressions
128
+ i++; while (i < len && s[i] !== "'") {
129
+ if (s[i] === "\\" && i + 1 < len) i += 2; else i++;
130
+ }
131
+ if (s[i] === "'") i++;
132
+ continue;
133
+ }
134
+ if (ch === "(" ) {
135
+ depthParen++;
136
+ hadParen = true;
137
+ } else if (ch === ")") {
138
+ if (depthParen > 0) {
139
+ depthParen--;
140
+ if (depthParen === 0 && hadParen) {
141
+ i++;
142
+ continue;
143
+ }
144
+ }
145
+ }
146
+ else if (ch === "[") depthBrack++;
147
+ else if (ch === "]") depthBrack = Math.max(0, depthBrack - 1);
148
+ else if (ch === "{") depthBrace++;
149
+ else if (ch === "}") depthBrace = Math.max(0, depthBrace - 1);
150
+
151
+ if (depthParen === 0 && depthBrack === 0 && depthBrace === 0) {
152
+ if (delims.includes(ch)) {
153
+ if (ch === ")") {
154
+ // If followed by chaining, treat ')' as part of the expression.
155
+ let j = i + 1;
156
+ while (j < len && isWS(s[j])) j++;
157
+ const next = s[j];
158
+ if (next === "." || next === "?" || next === "[" || next === "(") {
159
+ i++;
160
+ continue;
161
+ }
162
+ }
163
+ break;
164
+ }
165
+ }
166
+ i++;
167
+ }
168
+ return s.slice(start, i).trim();
169
+ }
170
+
171
+ function readBracedRaw() {
172
+ if (s[i] !== "{") error("Expected '{' block");
173
+ i++; // skip '{'
174
+ const start = i;
175
+ let depth = 1;
176
+ while (i < len) {
177
+ const ch = s[i];
178
+ if (ch === "'") {
179
+ i++; while (i < len && s[i] !== "'") {
180
+ if (s[i] === "\\" && i + 1 < len) i += 2; else i++;
181
+ }
182
+ if (s[i] === "'") i++;
183
+ continue;
184
+ }
185
+ if (ch === "{") depth++;
186
+ else if (ch === "}") depth--;
187
+ if (depth === 0) {
188
+ const raw = s.slice(start, i).trim();
189
+ i++; // consume '}'
190
+ return raw;
191
+ }
192
+ i++;
193
+ }
194
+ error("Unterminated '{' block");
195
+ }
196
+
197
+ function parseValue() {
198
+ skipWS();
199
+ const ch = s[i];
200
+ if (ch === "'") return { kind: "value", value: readString() };
201
+ if (ch === "-" || (ch >= "0" && ch <= "9")) return { kind: "value", value: readNumber() };
202
+ if (ch === "@") {
203
+ i++;
204
+ return { kind: "expr", expr: { raw: readExprUntil([",", ")", "}"]) } };
205
+ }
206
+ if (s.startsWith("true", i)) { i += 4; return { kind: "value", value: true }; }
207
+ if (s.startsWith("false", i)) { i += 5; return { kind: "value", value: false }; }
208
+ if (s.startsWith("null", i)) { i += 4; return { kind: "value", value: null }; }
209
+ error("Unexpected value");
210
+ }
211
+
212
+ function parseProp(delims) {
213
+ skipWS();
214
+ const exprDelims = delims.includes(",") ? delims : [...delims, ","];
215
+ if (s[i] === "!") {
216
+ i++;
217
+ const eventName = readIdent();
218
+ skipWS();
219
+ const body = readBracedRaw();
220
+ return { kind: "event", key: eventName, expr: { raw: body } };
221
+ }
222
+ const key = readPropKey();
223
+ skipWS();
224
+ if (s[i] === "?" && s[i + 1] === "=") {
225
+ i += 2;
226
+ const raw = readExprUntil(exprDelims);
227
+ return { kind: "bool", key, expr: { raw } };
228
+ }
229
+ if (s[i] === "=") {
230
+ i++;
231
+ skipWS();
232
+ if (s[i] === "{" && key.startsWith("on:")) {
233
+ const body = readBracedRaw();
234
+ return { kind: "event", key, expr: { raw: body } };
235
+ }
236
+ const raw = readExprUntil(exprDelims);
237
+ return { kind: "expr", key, expr: { raw } };
238
+ }
239
+ if (s[i] === ":") {
240
+ i++;
241
+ skipWS();
242
+ if (s[i] === "{") {
243
+ const body = readBracedRaw();
244
+ return { kind: "event", key, expr: { raw: body } };
245
+ }
246
+ const val = parseValue();
247
+ return { kind: val.kind, key, value: val.value, expr: val.expr };
248
+ }
249
+ // bare boolean true prop (present implies true)
250
+ return { kind: "value", key, value: true };
251
+ }
252
+
253
+ function parsePropsInline(stopChars) {
254
+ const props = [];
255
+ while (i < len) {
256
+ skipWS();
257
+ if (i >= len) break;
258
+ // DEBUG: uncomment to trace
259
+ // console.log("parsePropsInline at", i, JSON.stringify(s.slice(i, i + 20)));
260
+ const ch = s[i];
261
+ if (stopChars.includes(ch)) break;
262
+ // Props must start with an identifier or event shorthand.
263
+ if (!(isIdentStart(ch) || ch === "!")) break;
264
+ props.push(parseProp(stopChars));
265
+ skipWS();
266
+ if (s[i] === ",") i++;
267
+ }
268
+ return props;
269
+ }
270
+
271
+ function parsePropsParen() {
272
+ if (s[i] !== "(") error("Expected '('");
273
+ i++;
274
+ const props = parsePropsInline([")"]);
275
+ if (s[i] !== ")") error("Expected ')'");
276
+ i++;
277
+ return props;
278
+ }
279
+
280
+ function parseChildren() {
281
+ if (s[i] !== "{") error("Expected '{'");
282
+ i++;
283
+ const children = [];
284
+ while (i < len) {
285
+ skipWS();
286
+ if (s[i] === "}") { i++; break; }
287
+ children.push(parseNode());
288
+ }
289
+ return children;
290
+ }
291
+
292
+ function parseDirective() {
293
+ if (s[i] !== "#") error("Expected '#'");
294
+ const start = i;
295
+ i++;
296
+ const name = readIdent();
297
+ skipWS();
298
+ let args = "";
299
+ if (s[i] === "(") {
300
+ i++;
301
+ const start = i;
302
+ let depth = 1;
303
+ while (i < len && depth > 0) {
304
+ const ch = s[i];
305
+ if (ch === "'") {
306
+ i++; while (i < len && s[i] !== "'") {
307
+ if (s[i] === "\\" && i + 1 < len) i += 2; else i++;
308
+ }
309
+ if (s[i] === "'") i++;
310
+ continue;
311
+ }
312
+ if (ch === "(") depth++;
313
+ else if (ch === ")") depth--;
314
+ if (depth === 0) break;
315
+ i++;
316
+ }
317
+ args = s.slice(start, i).trim();
318
+ if (s[i] === ")") i++;
319
+ }
320
+ skipWS();
321
+ let children = [];
322
+ if (s[i] === "{") children = parseChildren();
323
+ return { type: "directive", name, args, children, start, end: i };
324
+ }
325
+
326
+ function parseTextNode() {
327
+ const start = i;
328
+ const ident = readIdent();
329
+ if (ident !== "text") error("Unknown keyword");
330
+ skipWS();
331
+ const value = readString();
332
+ return { type: "text", value, start, end: i };
333
+ }
334
+
335
+ function parseExprNode() {
336
+ if (s[i] !== "@") error("Expected '@'");
337
+ const start = i;
338
+ i++;
339
+ const raw = readExprUntil(["}", "\n"]);
340
+ return { type: "expr", value: { raw }, start, end: i };
341
+ }
342
+
343
+ function parseTagNode() {
344
+ const start = i;
345
+ const name = readIdent();
346
+ skipWS();
347
+ let props = [];
348
+ if (s[i] === "(") {
349
+ props = parsePropsParen();
350
+ skipWS();
351
+ } else if (s[i] !== "{" && s[i] !== "}" && i < len) {
352
+ // inline props until '{' or '}' or end
353
+ props = parsePropsInline(["{", "}"]);
354
+ skipWS();
355
+ }
356
+ let children = [];
357
+ if (s[i] === "{") children = parseChildren();
358
+ return { type: "tag", name, props, children, start, end: i };
359
+ }
360
+
361
+ function parseNode() {
362
+ skipWS();
363
+ const ch = s[i];
364
+ if (ch === "#") return parseDirective();
365
+ if (ch === "@") return parseExprNode();
366
+ if (isIdentStart(ch)) {
367
+ // 'text' keyword or tag
368
+ const save = i;
369
+ const ident = readIdent();
370
+ i = save;
371
+ if (ident === "text") return parseTextNode();
372
+ return parseTagNode();
373
+ }
374
+ error("Unexpected token");
375
+ }
376
+
377
+ const body = [];
378
+ while (i < len) {
379
+ skipWS();
380
+ if (i >= len) break;
381
+ body.push(parseNode());
382
+ }
383
+
384
+ return { type: "document", body, start: 0, end: len };
385
+ }
386
+
387
+ export function parseUnitsOrThrow(input) {
388
+ return parseUnits(input);
389
+ }
package/units-print.js ADDED
@@ -0,0 +1,63 @@
1
+ import { parseUnits } from "./units-parser.js";
2
+
3
+ function escapeString(value) {
4
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
5
+ }
6
+
7
+ function printProp(prop) {
8
+ if (prop.kind === "event") {
9
+ if (prop.key.startsWith("on:")) return `${prop.key}={ ${prop.expr.raw} }`;
10
+ return `!${prop.key} { ${prop.expr.raw} }`;
11
+ }
12
+ if (prop.kind === "bool") {
13
+ const raw = prop.expr.raw.startsWith("@") ? prop.expr.raw.slice(1) : prop.expr.raw;
14
+ return `${prop.key}?=@${raw}`;
15
+ }
16
+ if (prop.kind === "expr") {
17
+ const raw = prop.expr.raw.startsWith("@") ? prop.expr.raw.slice(1) : prop.expr.raw;
18
+ return `${prop.key}=@${raw}`;
19
+ }
20
+ if (prop.kind === "value") {
21
+ const v = prop.value;
22
+ if (typeof v === "string") return `${prop.key}:'${escapeString(v)}'`;
23
+ if (v === null) return `${prop.key}:null`;
24
+ return `${prop.key}:${String(v)}`;
25
+ }
26
+ return "";
27
+ }
28
+
29
+ const PRINT_WIDTH = Number(process.env.UNITS_PRINT_WIDTH || process.env.RDL_PRINT_WIDTH || 100);
30
+
31
+ function printNode(node, indent) {
32
+ const pad = " ".repeat(indent);
33
+ if (node.type === "text") return `${pad}text '${escapeString(node.value)}'`;
34
+ if (node.type === "expr") return `${pad}@${node.value.raw}`;
35
+ if (node.type === "directive") {
36
+ const args = node.args ? ` (${node.args})` : "";
37
+ if (!node.children || node.children.length === 0) return `${pad}#${node.name}${args}`;
38
+ const inner = node.children.map((n) => printNode(n, indent + 1)).join("\n");
39
+ return `${pad}#${node.name}${args} {\n${inner}\n${pad}}`;
40
+ }
41
+ if (node.type === "tag") {
42
+ let props = "";
43
+ if (node.props && node.props.length) {
44
+ const parts = node.props.map(printProp);
45
+ const inline = ` (${parts.join(", ")})`;
46
+ if ((pad.length + node.name.length + inline.length) > PRINT_WIDTH && parts.length > 1) {
47
+ const inner = parts.map((p) => `${pad} ${p}`).join(",\n");
48
+ props = ` (\n${inner}\n${pad})`;
49
+ } else {
50
+ props = inline;
51
+ }
52
+ }
53
+ if (!node.children || node.children.length === 0) return `${pad}${node.name}${props}`;
54
+ const inner = node.children.map((n) => printNode(n, indent + 1)).join("\n");
55
+ return `${pad}${node.name}${props} {\n${inner}\n${pad}}`;
56
+ }
57
+ return "";
58
+ }
59
+
60
+ export function formatUnits(source) {
61
+ const ast = parseUnits(source);
62
+ return ast.body.map((n) => printNode(n, 0)).join("\n") + "\n";
63
+ }
@@ -0,0 +1,247 @@
1
+ // React renderer for Units AST.
2
+ // Requires React as a peer dependency.
3
+
4
+ import React from "react";
5
+
6
+ export function createUnitsEvaluator() {
7
+ const cache = new Map();
8
+ return function evalExpr(raw, scope, locals) {
9
+ const key = raw;
10
+ let fn = cache.get(key);
11
+ if (!fn) {
12
+ // Allow a simple := transform inside set(...) calls.
13
+ let normalized = raw.replace(/@\(/g, "(");
14
+ normalized = normalized.replace(/@([A-Za-z_$][\w.$]*)/g, "$1");
15
+ const transformed = normalized.replace(
16
+ /set\s*\(\s*([A-Za-z_$][\w.$]*)\s*:=/g,
17
+ "set('$1',",
18
+ );
19
+ fn = new Function(
20
+ "scope",
21
+ "locals",
22
+ "event",
23
+ "set",
24
+ `with(scope){with(locals||{}){return (${transformed});}}`,
25
+ );
26
+ cache.set(key, fn);
27
+ }
28
+ return fn(scope, locals || {}, locals?.event, locals?.set || scope?.set);
29
+ };
30
+ }
31
+
32
+ export function renderUnits(ast, scope, options = {}) {
33
+ const evalExpr = options.evalExpr || createUnitsEvaluator();
34
+ const components = options.components || {};
35
+ const slots = options.slots || {};
36
+ const set = options.set || scope?.set || (() => {});
37
+
38
+ function splitInterpolations(value) {
39
+ const text = String(value ?? "");
40
+ const parts = [];
41
+ if (!text.includes("@{")) return [{ type: "text", value: text }];
42
+ let i = 0;
43
+ while (i < text.length) {
44
+ const idx = text.indexOf("@{", i);
45
+ if (idx === -1) {
46
+ if (i < text.length) parts.push({ type: "text", value: text.slice(i) });
47
+ break;
48
+ }
49
+ if (idx > i) parts.push({ type: "text", value: text.slice(i, idx) });
50
+ let j = idx + 2;
51
+ let depth = 1;
52
+ while (j < text.length) {
53
+ const ch = text[j];
54
+ if (ch === "'" || ch === "\"") {
55
+ const quote = ch;
56
+ j++;
57
+ while (j < text.length) {
58
+ const qch = text[j];
59
+ if (qch === "\\") {
60
+ j += 2;
61
+ continue;
62
+ }
63
+ if (qch === quote) {
64
+ j++;
65
+ break;
66
+ }
67
+ j++;
68
+ }
69
+ continue;
70
+ }
71
+ if (ch === "{") depth++;
72
+ else if (ch === "}") depth--;
73
+ if (depth === 0) break;
74
+ j++;
75
+ }
76
+ if (depth !== 0) {
77
+ parts.push({ type: "text", value: text.slice(idx) });
78
+ break;
79
+ }
80
+ const expr = text.slice(idx + 2, j).trim();
81
+ parts.push({ type: "expr", value: expr });
82
+ i = j + 1;
83
+ }
84
+ return parts;
85
+ }
86
+
87
+ function renderText(value, locals) {
88
+ const parts = splitInterpolations(value);
89
+ if (parts.length === 1 && parts[0].type === "text") return parts[0].value;
90
+ return parts.map((part) => {
91
+ if (part.type === "text") return part.value;
92
+ return String(evalExpr(part.value, scope, locals));
93
+ });
94
+ }
95
+
96
+ function pushChild(out, value) {
97
+ if (Array.isArray(value)) out.push(...value);
98
+ else out.push(value);
99
+ }
100
+
101
+ function renderNode(node, locals) {
102
+ if (!node) return null;
103
+ if (node.type === "text") return renderText(node.value, locals);
104
+ if (node.type === "expr") return evalExpr(node.value.raw, scope, locals);
105
+ if (node.type === "directive") return renderDirective(node, locals);
106
+ if (node.type === "tag") return renderTag(node, locals);
107
+ return null;
108
+ }
109
+
110
+ function renderDirective(node, locals) {
111
+ const name = node.name;
112
+ const args = node.args || "";
113
+ if (name === "for") {
114
+ const m = args.match(
115
+ /^\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s+in\s+(.+)$/,
116
+ );
117
+ if (!m) return null;
118
+ const itemName = m[1];
119
+ const idxName = m[2] || "index";
120
+ const listExpr = m[3].trim();
121
+ const list = evalExpr(listExpr, scope, locals) || [];
122
+ const out = [];
123
+ for (let idx = 0; idx < list.length; idx++) {
124
+ const childLocals = {
125
+ ...locals,
126
+ [itemName]: list[idx],
127
+ [idxName]: idx,
128
+ };
129
+ out.push(renderChildren(node.children, childLocals));
130
+ }
131
+ return out;
132
+ }
133
+ if (name === "slot") {
134
+ const slotName = args.trim();
135
+ const slot = slots[slotName];
136
+ if (slot == null) return null;
137
+ return typeof slot === "function" ? slot(locals) : slot;
138
+ }
139
+ if (name === "key" || name === "elif" || name === "else") {
140
+ return null;
141
+ }
142
+ return renderChildren(node.children, locals);
143
+ }
144
+
145
+ function renderTag(node, locals) {
146
+ const props = {};
147
+ const eventHandlers = {};
148
+ for (const prop of node.props || []) {
149
+ if (prop.kind === "value") props[prop.key] = prop.value;
150
+ else if (prop.kind === "expr")
151
+ props[prop.key] = evalExpr(prop.expr.raw, scope, locals);
152
+ else if (prop.kind === "bool") {
153
+ const v = evalExpr(prop.expr.raw, scope, locals);
154
+ if (v) props[prop.key] = true;
155
+ } else if (prop.kind === "event") {
156
+ const eventName = prop.key.startsWith("on:")
157
+ ? prop.key.slice(3)
158
+ : prop.key;
159
+ const handler = (event) => {
160
+ const handlerLocals = { ...locals, event, set };
161
+ return evalExpr(prop.expr.raw, scope, handlerLocals);
162
+ };
163
+ eventHandlers[`on${eventName[0].toUpperCase()}${eventName.slice(1)}`] =
164
+ handler;
165
+ }
166
+ }
167
+
168
+ const children = renderChildren(node.children, locals);
169
+ const Component = components[node.name] || node.name;
170
+ const elProps = { ...props, ...eventHandlers };
171
+ if (typeof Component !== "string" && elProps.__scope == null) {
172
+ elProps.__scope = scope;
173
+ }
174
+ return React.createElement(
175
+ Component,
176
+ elProps,
177
+ ...(Array.isArray(children) ? children : [children]),
178
+ );
179
+ }
180
+
181
+ function renderChildren(children, locals) {
182
+ const out = [];
183
+ for (let idx = 0; idx < (children || []).length; idx++) {
184
+ const child = children[idx];
185
+ if (child.type === "directive" && child.name === "key") {
186
+ const next = children[idx + 1];
187
+ if (next && next.type === "tag") {
188
+ const key = evalExpr(child.args || "", scope, locals);
189
+ const rendered = renderNode(
190
+ {
191
+ ...next,
192
+ props: [
193
+ ...(next.props || []),
194
+ { kind: "value", key: "key", value: key },
195
+ ],
196
+ },
197
+ locals,
198
+ );
199
+ out.push(rendered);
200
+ idx++;
201
+ continue;
202
+ }
203
+ }
204
+ if (child.type === "directive" && child.name === "if") {
205
+ let matched = false;
206
+ if (evalExpr(child.args || "", scope, locals)) {
207
+ pushChild(out, renderChildren(child.children, locals));
208
+ matched = true;
209
+ }
210
+
211
+ let next = idx + 1;
212
+ while (next < children.length && children[next].type === "directive") {
213
+ if (children[next].name === "elif") {
214
+ if (
215
+ !matched &&
216
+ evalExpr(children[next].args || "", scope, locals)
217
+ ) {
218
+ pushChild(out, renderChildren(children[next].children, locals));
219
+ matched = true;
220
+ }
221
+ next++;
222
+ continue;
223
+ } else if (children[next].name === "else") {
224
+ if (!matched) {
225
+ pushChild(out, renderChildren(children[next].children, locals));
226
+ matched = true;
227
+ }
228
+ next++;
229
+ break;
230
+ } else {
231
+ break;
232
+ }
233
+ }
234
+ if (!matched) {
235
+ out.push(null);
236
+ }
237
+ idx = next - 1;
238
+ continue;
239
+ }
240
+ pushChild(out, renderNode(child, locals));
241
+ }
242
+ return out;
243
+ }
244
+
245
+ if (ast.type === "document") return renderChildren(ast.body, {});
246
+ return renderNode(ast, {});
247
+ }