tsquare 0.1.0 → 0.2.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/src/text.ts DELETED
@@ -1,333 +0,0 @@
1
- /**
2
- * Text syntax for wireframes. Compiles to the same flat json-render spec.
3
- *
4
- * board "Notes app"
5
- * screen phone "Sign in" padding=28
6
- * heading "Welcome back"
7
- * input "Email" placeholder="you@example.com"
8
- * stack row justify=between
9
- * checkbox "Remember me" checked
10
- * button "Forgot?" ghost sm
11
- * button "Sign in" primary lg fullWidth
12
- *
13
- * Line = component name, then arguments, children indented below.
14
- * Arguments:
15
- * "quoted" → the component's main text prop (label, title, text…)
16
- * bare word → an enum value (phone, primary, row, sm…) or a boolean prop set to true (checked)
17
- * key=value → any prop; value is "string", number, true/false, word, [list] or {key=value …}
18
- * list items are separated by commas only, so [Ana Torres, Admin] is two items;
19
- * quote an item that contains a comma: ["$1,200", "Smith, J"]
20
- * # comment
21
- */
22
- import type { Spec } from "@json-render/core";
23
- import { z } from "zod";
24
- import { componentDefinitions } from "./catalog";
25
- import { unknownComponentMessage } from "./suggest";
26
-
27
- /** Which prop a quoted string fills, per component. */
28
- export const PRIMARY_PROP: Record<string, string> = {
29
- Board: "title",
30
- Screen: "name",
31
- Note: "text",
32
- Card: "title",
33
- Heading: "text",
34
- Text: "text",
35
- Image: "label",
36
- Icon: "name",
37
- Avatar: "initials",
38
- Badge: "label",
39
- Button: "label",
40
- Input: "label",
41
- Checkbox: "label",
42
- Radio: "label",
43
- Toggle: "label",
44
- Select: "label",
45
- NavBar: "title",
46
- ListItem: "title",
47
- Modal: "title",
48
- Drawer: "title",
49
- };
50
-
51
- // ── Introspect the Zod schemas so the syntax follows the catalog ────────
52
-
53
- function unwrap(t: any): any {
54
- let cur = t;
55
- while (cur && ["optional", "nullable", "default"].includes(cur.def?.type)) cur = cur.def.innerType;
56
- return cur;
57
- }
58
-
59
- /**
60
- * Numbers written where the schema wants text become text: `tabs items=[2023, 2024]`,
61
- * `data=[[Apples, 12]]`. Walks arrays and objects; anything else is left for validation.
62
- */
63
- function textWhereExpected(schema: any, value: unknown): unknown {
64
- const s = unwrap(schema);
65
- switch (s?.def?.type) {
66
- case "string": return typeof value === "number" ? String(value) : value;
67
- case "array": return Array.isArray(value) ? value.map((v) => textWhereExpected(s.def.element, v)) : value;
68
- case "object":
69
- if (!value || typeof value !== "object" || Array.isArray(value)) return value;
70
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, k in s.shape ? textWhereExpected(s.shape[k], v) : v]));
71
- default: return value;
72
- }
73
- }
74
-
75
- interface ComponentInfo {
76
- name: string;
77
- props: Record<string, any>;
78
- enumValues: Map<string, string>; // value → prop
79
- ambiguous: Set<string>; // values shared by two props: must be written key=value
80
- booleans: Set<string>;
81
- }
82
-
83
- const COMPONENTS = new Map<string, ComponentInfo>();
84
- for (const [name, def] of Object.entries(componentDefinitions)) {
85
- const shape = (def.props as z.ZodObject<any>).shape;
86
- const enumValues = new Map<string, string>();
87
- const ambiguous = new Set<string>();
88
- const booleans = new Set<string>();
89
- for (const [prop, schema] of Object.entries(shape)) {
90
- const inner = unwrap(schema);
91
- const type = inner?.def?.type;
92
- if (type === "enum") {
93
- for (const v of inner.options as string[]) {
94
- if (enumValues.has(v)) ambiguous.add(v);
95
- else enumValues.set(v, prop);
96
- }
97
- } else if (type === "boolean") {
98
- booleans.add(prop);
99
- }
100
- }
101
- for (const v of ambiguous) enumValues.delete(v); // must be written key=value
102
- COMPONENTS.set(name.toLowerCase(), { name, props: shape, enumValues, ambiguous, booleans });
103
- }
104
-
105
- /** What a component accepts as bare words, for docs: option values (with their prop), ambiguous values, and boolean props. */
106
- export function bareWords(component: string) {
107
- const info = COMPONENTS.get(component.toLowerCase());
108
- if (!info) throw new Error(`unknown component ${component}`);
109
- return {
110
- options: [...info.enumValues].map(([value, prop]) => ({ value, prop })),
111
- ambiguous: [...info.ambiguous],
112
- booleans: [...info.booleans],
113
- wordPrimary: WORD_PRIMARY.has(info.name),
114
- };
115
- }
116
-
117
- const normalizeType = (s: string) => s.toLowerCase().replace(/[-_]/g, "");
118
-
119
- /** Components whose main text is usually a single word, so it may be written unquoted. */
120
- const WORD_PRIMARY = new Set(["Icon", "Avatar"]);
121
-
122
- /** Words that switch a boolean prop off. */
123
- const ANTONYMS: Record<string, string> = { off: "on", unchecked: "checked" };
124
-
125
- // ── Tokenizer for one line's arguments ──────────────────────────────────
126
-
127
- // start/end are offsets in the line, so an unquoted list item can keep its exact text.
128
- type Tok = { kind: "str" | "word" | "num" | "sym"; value: string; start: number; end: number };
129
-
130
- function tokenize(src: string): Tok[] {
131
- const out: Tok[] = [];
132
- let i = 0;
133
- while (i < src.length) {
134
- const c = src[i];
135
- if (c === " " || c === "\t") { i++; continue; }
136
- if (c === "#" && !(i > 0 && "=:".includes(src[i - 1]))) break; // comment, unless it's a value: accent=#1a73e8
137
- if (c === '"' || c === "'") {
138
- let j = i + 1, s = "";
139
- while (j < src.length && src[j] !== c) {
140
- if (src[j] === "\\" && j + 1 < src.length) { s += src[j + 1]; j += 2; continue; }
141
- s += src[j++];
142
- }
143
- if (j >= src.length) throw new Error("unterminated string");
144
- out.push({ kind: "str", value: s, start: i, end: j + 1 });
145
- i = j + 1;
146
- continue;
147
- }
148
- if ("=[]{}:,".includes(c)) { out.push({ kind: "sym", value: c, start: i, end: i + 1 }); i++; continue; }
149
- let j = c === "#" ? i + 1 : i; // a # value like #1a73e8
150
- // an apostrophe inside a word is part of it (Don't); only a leading ' opens a string
151
- while (j < src.length && !` \t,=[]{}#":`.includes(src[j])) j++;
152
- const w = src.slice(i, j);
153
- out.push({ kind: /^-?\d+(\.\d+)?$/.test(w) ? "num" : "word", value: w, start: i, end: j });
154
- i = j;
155
- }
156
- return out;
157
- }
158
-
159
- class Cursor {
160
- i = 0;
161
- constructor(public toks: Tok[], public src: string) {}
162
- peek(o = 0) { return this.toks[this.i + o]; }
163
- next() { return this.toks[this.i++]; }
164
- done() { return this.i >= this.toks.length; }
165
- isSym(v: string, o = 0) { const t = this.peek(o); return t?.kind === "sym" && t.value === v; }
166
- /** Commas only separate list items; elsewhere (between arguments, between {…} fields) they're ignored. */
167
- skipCommas() { while (this.isSym(",")) this.i++; }
168
- }
169
-
170
- const scalar = (t: Tok) =>
171
- t.kind === "num" ? Number(t.value)
172
- : t.kind === "word" && t.value === "true" ? true
173
- : t.kind === "word" && t.value === "false" ? false
174
- : t.kind === "word" && t.value === "null" ? null
175
- : t.value;
176
-
177
- /**
178
- * One list item. Items are separated by commas, not spaces, so an unquoted item
179
- * runs to the next comma or ] and keeps its spaces: [Ana Torres, Admin].
180
- */
181
- function parseListItem(c: Cursor): unknown {
182
- const first = c.peek();
183
- if (!first) throw new Error("unclosed [");
184
- if (first.kind === "str" || c.isSym("[") || c.isSym("{")) {
185
- const v = parseValue(c);
186
- if (!c.done() && !c.isSym(",") && !c.isSym("]")) {
187
- throw new Error(`expected , or ] after ${JSON.stringify(v)} in a list (quote the whole item, or separate items with commas)`);
188
- }
189
- return v;
190
- }
191
- const toks: Tok[] = [];
192
- while (!c.done() && !c.isSym(",") && !c.isSym("]")) {
193
- const t = c.peek();
194
- if (t.kind === "sym" && "[]{}".includes(t.value)) throw new Error(`unexpected "${t.value}" in a list item; quote the item`);
195
- if (t.kind === "str") throw new Error(`unexpected quote in a list item; quote the whole item`);
196
- toks.push(c.next());
197
- }
198
- if (!toks.length) throw new Error("empty item in a list (two commas in a row?)");
199
- if (toks.length === 1) return scalar(toks[0]);
200
- return c.src.slice(toks[0].start, toks[toks.length - 1].end);
201
- }
202
-
203
- function parseValue(c: Cursor): unknown {
204
- const t = c.next();
205
- if (!t) throw new Error("missing value");
206
- if (t.kind === "str") return t.value;
207
- if (t.kind === "num") return Number(t.value);
208
- if (t.kind === "word") return scalar(t);
209
- if (t.value === "[") {
210
- const arr: unknown[] = [];
211
- while (!c.isSym("]")) {
212
- if (c.done()) throw new Error("unclosed [");
213
- arr.push(parseListItem(c));
214
- if (c.isSym(",")) c.next();
215
- }
216
- c.next();
217
- return arr;
218
- }
219
- if (t.value === "{") {
220
- const obj: Record<string, unknown> = {};
221
- while (!c.isSym("}")) {
222
- c.skipCommas();
223
- if (c.isSym("}")) break;
224
- if (c.done()) throw new Error("unclosed {");
225
- const k = c.next();
226
- if (!k || k.kind === "sym") throw new Error("expected key in {…}");
227
- if (!(c.isSym("=") || c.isSym(":"))) throw new Error(`expected = after ${k.value}`);
228
- c.next();
229
- obj[k.value] = parseValue(c);
230
- }
231
- c.next();
232
- return obj;
233
- }
234
- throw new Error(`unexpected "${t.value}"`);
235
- }
236
-
237
- // ── Parser ──────────────────────────────────────────────────────────────
238
-
239
- export interface TextIssue { line: number; message: string }
240
-
241
- export interface ParseResult {
242
- spec: Spec | null;
243
- issues: TextIssue[];
244
- /** element id → source line, for mapping validation errors back */
245
- lines: Record<string, number>;
246
- }
247
-
248
- export function parseWireframeText(source: string): ParseResult {
249
- const issues: TextIssue[] = [];
250
- const elements: Spec["elements"] = {};
251
- const lines: Record<string, number> = {};
252
- const stack: { indent: number; id: string }[] = [];
253
- let root: string | null = null;
254
- let n = 0;
255
-
256
- const srcLines = source.replace(/\r\n?/g, "\n").split("\n");
257
- srcLines.forEach((raw, idx) => {
258
- const lineNo = idx + 1;
259
- const trimmed = raw.trim();
260
- if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("```")) return;
261
- const indent = raw.length - raw.trimStart().length;
262
-
263
- const m = trimmed.match(/^([A-Za-z][\w-]*)(.*)$/);
264
- if (!m) { issues.push({ line: lineNo, message: `expected a component name, got "${trimmed}"` }); return; }
265
- const info = COMPONENTS.get(normalizeType(m[1]));
266
- if (!info) { issues.push({ line: lineNo, message: unknownComponentMessage(m[1], [...COMPONENTS.keys()]) }); return; }
267
-
268
- const props: Record<string, unknown> = {};
269
- try {
270
- const c = new Cursor(tokenize(m[2]), m[2]);
271
- while (!c.done()) {
272
- c.skipCommas();
273
- if (c.done()) break;
274
- const t = c.peek();
275
- if (t.kind !== "sym" && (c.isSym("=", 1) || c.isSym(":", 1))) {
276
- c.next(); c.next();
277
- const key = t.value;
278
- if (!(key in info.props)) {
279
- issues.push({ line: lineNo, message: `${info.name} has no prop "${key}" (its props: ${Object.keys(info.props).join(", ")})` });
280
- }
281
- props[key] = key in info.props ? textWhereExpected(info.props[key], parseValue(c)) : parseValue(c);
282
- continue;
283
- }
284
- c.next();
285
- if (t.kind === "str") {
286
- const p = PRIMARY_PROP[info.name];
287
- if (!p) issues.push({ line: lineNo, message: `${info.name} takes no text; use key="…"` });
288
- else if (p in props) issues.push({ line: lineNo, message: `more than one text for ${info.name}` });
289
- else props[p] = t.value;
290
- } else if (t.kind === "word" && info.enumValues.has(t.value)) {
291
- props[info.enumValues.get(t.value)!] = t.value;
292
- } else if (t.kind === "word" && info.booleans.has(t.value)) {
293
- props[t.value] = true;
294
- } else if (t.kind === "word" && t.value.startsWith("no-") && info.booleans.has(t.value.slice(3))) {
295
- props[t.value.slice(3)] = false;
296
- } else if (t.kind === "word" && ANTONYMS[t.value] && info.booleans.has(ANTONYMS[t.value])) {
297
- props[ANTONYMS[t.value]] = false; // `toggle off`, `checkbox unchecked`
298
- } else if (t.kind === "word" && WORD_PRIMARY.has(info.name) && !(PRIMARY_PROP[info.name] in props)) {
299
- props[PRIMARY_PROP[info.name]] = t.value; // `icon search`, `avatar JD`
300
- } else {
301
- const options = [...info.enumValues.keys(), ...info.booleans];
302
- issues.push({
303
- line: lineNo,
304
- message: `${info.name}: don't know what "${t.value}" is` +
305
- (options.length ? ` (bare words it accepts: ${options.join(", ")})` : "") +
306
- (PRIMARY_PROP[info.name] && !(PRIMARY_PROP[info.name] in props) ? `; quote text like "${t.value}"` : ""),
307
- });
308
- }
309
- }
310
- } catch (e) {
311
- issues.push({ line: lineNo, message: (e as Error).message });
312
- return;
313
- }
314
-
315
- const id = `${info.name.toLowerCase()}-${++n}`;
316
- elements[id] = { type: info.name, props, children: [] };
317
- lines[id] = lineNo;
318
-
319
- while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
320
- const parent = stack[stack.length - 1];
321
- if (parent) {
322
- elements[parent.id].children!.push(id);
323
- } else if (root === null) {
324
- root = id;
325
- } else {
326
- issues.push({ line: lineNo, message: "only one top-level element (the board) is allowed; indent this line" });
327
- }
328
- stack.push({ indent, id });
329
- });
330
-
331
- if (root === null && !issues.length) issues.push({ line: 1, message: "empty wireframe" });
332
- return { spec: root ? { root, elements } : null, issues, lines };
333
- }