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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsquare",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Wireframes in plain text: easy for LLMs to write, fast to render as SVG or PNG.",
5
5
  "keywords": [
6
6
  "wireframe",
@@ -23,13 +23,26 @@
23
23
  "url": "https://github.com/tsquare-js/tsquare/issues"
24
24
  },
25
25
  "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./playground": {
32
+ "types": "./dist/playground/index.d.ts",
33
+ "import": "./dist/playground/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
26
37
  "bin": {
27
38
  "tsquare": "bin/tsquare.js"
28
39
  },
29
40
  "files": [
30
41
  "bin/",
31
- "src/",
42
+ "dist/",
32
43
  "skill/tsquare/",
44
+ "examples/*.tsq",
45
+ "assets/mark.png",
33
46
  "LICENSE",
34
47
  "README.md"
35
48
  ],
@@ -41,28 +54,31 @@
41
54
  "check": "tsx src/cli.ts check",
42
55
  "fmt": "tsx src/cli.ts fmt",
43
56
  "prompt": "tsx src/cli.ts prompt",
44
- "playground": "tsx playground/server.ts",
57
+ "playground": "tsx src/cli.ts playground",
45
58
  "skill": "tsx skill/build.ts",
46
59
  "docs": "tsx docs/build.ts",
47
60
  "typecheck": "tsc",
48
61
  "examples": "for f in examples/*.tsq; do tsx src/cli.ts render $f -o ${f%.tsq}.png --scale 2; done",
49
62
  "eval:prompts": "tsx eval/prompts.ts",
50
- "eval:score": "tsx eval/score.ts"
63
+ "eval:score": "tsx eval/score.ts",
64
+ "build": "tsx scripts/build.ts",
65
+ "prepare": "npm run build"
51
66
  },
52
67
  "dependencies": {
53
68
  "@fontsource/inter": "5.3.0",
54
69
  "@json-render/core": "^0.21.0",
55
70
  "@json-render/image": "^0.21.0",
56
71
  "@resvg/resvg-js": "^2.6.2",
72
+ "@types/node": ">=20",
73
+ "@types/react": "^19.3.0",
57
74
  "lucide": "1.48.0",
58
75
  "react": "^19.3.0",
59
- "tsx": "^4.23.15",
60
76
  "zod": "^4.6.5"
61
77
  },
62
78
  "devDependencies": {
63
- "@types/node": "^26.6.3",
64
- "@types/react": "^19.3.0",
79
+ "esbuild": "^0.28.2",
65
80
  "gpt-tokenizer": "^4.0.0",
81
+ "tsx": "^4.23.15",
66
82
  "typescript": "^7.0.2"
67
83
  }
68
84
  }
@@ -13,13 +13,13 @@ tsquare is a small text language for UI wireframe boards. You write a `.tsq` fil
13
13
  2. **Check:** `tsquare check <name>.tsq`. It lists problems by line, with the valid options or a "did you mean". Fix every one and check again until it says the file is valid.
14
14
  3. **Render:** `tsquare render <name>.tsq -o <name>.png --scale 2` (use `-o <name>.svg` for docs that take SVG).
15
15
  4. **Look at the PNG** before showing it. The checker can't see layout, so check for:
16
- - content cut off at the bottom of a screen (screens have a fixed height; nothing warns about clipping). Remove content, split it into another screen, or use `device=custom` with a larger `height`.
16
+ - content cut off at the bottom of a screen (screens have a fixed height; nothing warns about clipping). Remove content, split it into another screen, or give the screen a larger `height` (it works on any device).
17
17
  - cramped rows, or text squeezed into narrow columns.
18
18
  - anything the request asked for that's missing.
19
19
  Fix the `.tsq` and render again.
20
20
  5. **Deliver** the image path and the `.tsq` source, so the user can edit it later.
21
21
 
22
- If `tsquare` isn't found, stop and ask the user how it's installed. From a checkout of the repo, `npm link` makes the command available.
22
+ If `tsquare` isn't installed, run it through npx (`npx tsquare check <name>.tsq`); it needs Node 20 or newer. If that fails, stop and ask the user how they want it installed.
23
23
 
24
24
  ## Keep in mind
25
25
 
@@ -35,7 +35,7 @@ A line is the component name in lowercase, followed by arguments separated by sp
35
35
  - a bare word that is one of the component's option values sets that option: phone, desktop, primary, ghost, row, sm, left, bottom, password, …
36
36
  - a bare prop name sets a boolean prop to true: checked, fullWidth, grow, muted. `off` and `unchecked` set on/checked to false
37
37
  - key=value sets any prop. Values: "string", number, true/false, bare word, [list, of, values], {key=value key=value}
38
- - # starts a comment
38
+ - `#` after a space starts a comment that runs to the end of the line
39
39
 
40
40
  Lists: items are separated by commas, and an item can contain spaces without quotes: [All notes, Pinned, Shared]. To put a comma inside an item, quote the item: ["$1,200", "Smith, J"]. Inside quotes, write \" for a quote character. Numbers in a list of text are fine: [2023, 2024].
41
41
 
@@ -15,8 +15,8 @@ Root canvas (artboard). Holds Screens side by side, plus optional Notes. Must be
15
15
  One view of the product in a device frame. Children stack vertically.
16
16
  - name: string (main text) — Label shown above the screen
17
17
  - device: "phone" | "tablet" | "desktop" | "custom"
18
- - width: number — Only for device=custom
19
- - height: number — Only for device=custom
18
+ - width: number — Overrides the device width
19
+ - height: number — Overrides the device height, e.g. height=1400 for a long scrolling page
20
20
  - chrome: boolean — Phone status bar / desktop browser bar. Default true for phone and desktop.
21
21
  - padding: number
22
22
  - gap: number
package/src/catalog.ts DELETED
@@ -1,329 +0,0 @@
1
- import { defineCatalog } from "@json-render/core";
2
- import { z } from "zod";
3
- import { ACCENTS, accentMessage, isAccent } from "./colors";
4
- import { isIcon, unknownIconMessage } from "./icons";
5
- import { wireframeSchema } from "./schema";
6
-
7
- // Every prop is optional so hand-written specs stay short.
8
- const o = <T extends z.ZodType>(t: T) => t.nullish();
9
-
10
- const align = z.enum(["start", "center", "end", "stretch"]);
11
- const justify = z.enum(["start", "center", "end", "between", "around"]);
12
- const iconName = z
13
- .string()
14
- .refine(isIcon, { error: (iss) => unknownIconMessage(String(iss.input)) })
15
- .describe("Lucide icon name in kebab-case, e.g. menu, search, arrow-left, settings, bell, user");
16
-
17
- export const componentDefinitions = {
18
- // ── Canvas ────────────────────────────────────────────────────────────
19
- Board: {
20
- props: z.object({
21
- title: o(z.string()),
22
- layout: o(z.enum(["row", "grid"])).describe("row = all screens side by side; grid = wrap every `columns` screens"),
23
- columns: o(z.number().int().min(1)),
24
- gap: o(z.number()),
25
- padding: o(z.number()),
26
- accent: o(z.string().refine(isAccent, { error: (iss) => accentMessage(String(iss.input)) })).describe(
27
- `The one UI color: primary buttons, solid badges, checked controls, toggles, active tabs, ghost buttons. ${Object.keys(ACCENTS).join(", ")}, or a hex color like #1a73e8. Omit for grayscale.`,
28
- ),
29
- }),
30
- slots: ["default"],
31
- description: "Root canvas (artboard). Holds Screens side by side, plus optional Notes. Must be the root element.",
32
- example: { title: "Checkout flow", layout: "row", gap: 64 },
33
- },
34
- Screen: {
35
- props: z.object({
36
- name: o(z.string()).describe("Label shown above the screen"),
37
- device: o(z.enum(["phone", "tablet", "desktop", "custom"])),
38
- width: o(z.number()).describe("Only for device=custom"),
39
- height: o(z.number()).describe("Only for device=custom"),
40
- chrome: o(z.boolean()).describe("Phone status bar / desktop browser bar. Default true for phone and desktop."),
41
- padding: o(z.number()),
42
- gap: o(z.number()),
43
- }),
44
- slots: ["default"],
45
- description: "One view of the product in a device frame. Children stack vertically.",
46
- example: { name: "Home", device: "phone" },
47
- },
48
- Note: {
49
- props: z.object({
50
- text: z.string(),
51
- color: o(z.enum(["yellow", "blue", "pink", "green"])),
52
- width: o(z.number()),
53
- }),
54
- slots: [],
55
- description: "Sticky-note annotation. Put it on the Board beside screens, or inside a Screen next to what it explains.",
56
- example: { text: "Empty state TBD", color: "yellow" },
57
- },
58
-
59
- // ── Layout ────────────────────────────────────────────────────────────
60
- Stack: {
61
- props: z.object({
62
- direction: o(z.enum(["row", "column"])),
63
- gap: o(z.number()),
64
- padding: o(z.number()),
65
- align: o(align),
66
- justify: o(justify),
67
- wrap: o(z.boolean()),
68
- grow: o(z.boolean()).describe("Fill remaining space in the parent"),
69
- width: o(z.union([z.number(), z.string()])).describe("Fixed width, e.g. 240 for a sidebar"),
70
- border: o(z.enum(["none", "right", "left", "top", "bottom", "all"])),
71
- fill: o(z.boolean()).describe("Light gray background"),
72
- }),
73
- slots: ["default"],
74
- description: "Flex container. The main layout primitive (rows, columns, sidebars, toolbars).",
75
- example: { direction: "row", gap: 12, align: "center" },
76
- },
77
- Grid: {
78
- props: z.object({
79
- columns: z.number().int().min(1),
80
- gap: o(z.number()),
81
- padding: o(z.number()),
82
- }),
83
- slots: ["default"],
84
- description: "Equal-width columns that wrap. Good for card grids and galleries.",
85
- example: { columns: 3, gap: 16 },
86
- },
87
- Card: {
88
- props: z.object({
89
- title: o(z.string()),
90
- padding: o(z.number()),
91
- gap: o(z.number()),
92
- variant: o(z.enum(["outline", "filled"])),
93
- grow: o(z.boolean()),
94
- width: o(z.union([z.number(), z.string()])).describe("Fixed width, e.g. 320. Default fills the space."),
95
- }),
96
- slots: ["default"],
97
- description: "Bordered container with optional title. Children stack vertically.",
98
- example: { title: "Revenue", variant: "outline" },
99
- },
100
- Divider: {
101
- props: z.object({ vertical: o(z.boolean()) }),
102
- slots: [],
103
- description: "Thin separator line.",
104
- example: {},
105
- },
106
- Spacer: {
107
- props: z.object({
108
- size: o(z.number()).describe("Fixed size in px. Omit to fill remaining space."),
109
- }),
110
- slots: [],
111
- description: "Empty space. Without a size it grows to push siblings apart.",
112
- example: { size: 16 },
113
- },
114
-
115
- // ── Content ───────────────────────────────────────────────────────────
116
- Heading: {
117
- props: z.object({
118
- text: z.string(),
119
- level: o(z.union([z.literal(1), z.literal(2), z.literal(3)])),
120
- align: o(z.enum(["left", "center", "right"])),
121
- }),
122
- slots: [],
123
- description: "Heading text, level 1 (largest) to 3.",
124
- example: { text: "Welcome back", level: 1 },
125
- },
126
- Text: {
127
- props: z.object({
128
- text: o(z.string()),
129
- lines: o(z.number().int().min(1)).describe("Draw N placeholder lines instead of real text"),
130
- size: o(z.enum(["sm", "md", "lg"])),
131
- muted: o(z.boolean()),
132
- bold: o(z.boolean()),
133
- align: o(z.enum(["left", "center", "right"])),
134
- }),
135
- slots: [],
136
- description: "Body text, or placeholder lines when `lines` is set and `text` is not.",
137
- example: { lines: 3 },
138
- },
139
- Image: {
140
- props: z.object({
141
- height: o(z.number()),
142
- width: o(z.union([z.number(), z.string()])).describe("Default fills the container width"),
143
- label: o(z.string()),
144
- rounded: o(z.boolean()),
145
- }),
146
- slots: [],
147
- description: "Image placeholder: a box with an X through it.",
148
- example: { height: 180, label: "Hero photo" },
149
- },
150
- Icon: {
151
- props: z.object({
152
- name: o(iconName),
153
- size: o(z.number()),
154
- }),
155
- slots: [],
156
- description: "Line icon from Lucide.",
157
- example: { name: "search", size: 20 },
158
- },
159
- Avatar: {
160
- props: z.object({
161
- initials: o(z.string()),
162
- size: o(z.number()),
163
- }),
164
- slots: [],
165
- description: "Round avatar with initials or a person silhouette.",
166
- example: { initials: "JD", size: 40 },
167
- },
168
- Badge: {
169
- props: z.object({
170
- label: z.string(),
171
- variant: o(z.enum(["solid", "outline"])),
172
- tone: o(z.enum(["neutral", "success", "warning", "danger"])).describe("Status color. Default neutral (the accent for solid badges)"),
173
- }),
174
- slots: [],
175
- description: "Small pill label for counts or statuses.",
176
- example: { label: "New", variant: "solid" },
177
- },
178
-
179
- // ── Controls ──────────────────────────────────────────────────────────
180
- Button: {
181
- props: z.object({
182
- label: o(z.string()),
183
- variant: o(z.enum(["primary", "secondary", "ghost"])),
184
- size: o(z.enum(["sm", "md", "lg"])),
185
- icon: o(iconName),
186
- fullWidth: o(z.boolean()),
187
- }),
188
- slots: [],
189
- description: "Button. Primary is filled, secondary is outlined, ghost is text only.",
190
- example: { label: "Continue", variant: "primary", fullWidth: true },
191
- },
192
- Input: {
193
- props: z.object({
194
- label: o(z.string()),
195
- placeholder: o(z.string()),
196
- value: o(z.string()),
197
- type: o(z.enum(["text", "password", "search", "email"])),
198
- multiline: o(z.number().int().min(1)).describe("Number of rows; 2 or more makes a textarea"),
199
- helper: o(z.string()),
200
- error: o(z.boolean()).describe("Validation error: red border and red helper text"),
201
- grow: o(z.boolean()).describe("Fill the remaining space in a row"),
202
- width: o(z.union([z.number(), z.string()])).describe("Fixed width, e.g. 320. Default fills the space."),
203
- }),
204
- slots: [],
205
- description: "Text field with optional label. Set multiline for a textarea.",
206
- example: { label: "Email", placeholder: "you@example.com" },
207
- },
208
- Checkbox: {
209
- props: z.object({ label: o(z.string()), checked: o(z.boolean()) }),
210
- slots: [],
211
- description: "Checkbox with label.",
212
- example: { label: "Remember me", checked: true },
213
- },
214
- Radio: {
215
- props: z.object({ label: o(z.string()), checked: o(z.boolean()) }),
216
- slots: [],
217
- description: "Radio button with label.",
218
- example: { label: "Monthly", checked: true },
219
- },
220
- Toggle: {
221
- props: z.object({ label: o(z.string()), on: o(z.boolean()) }),
222
- slots: [],
223
- description: "On/off switch with label on the left.",
224
- example: { label: "Notifications", on: true },
225
- },
226
- Select: {
227
- props: z.object({
228
- label: o(z.string()),
229
- value: o(z.string()),
230
- placeholder: o(z.string()),
231
- width: o(z.union([z.number(), z.string()])).describe("Fixed width, e.g. 320. Default fills the space."),
232
- }),
233
- slots: [],
234
- description: "Dropdown field (closed state).",
235
- example: { label: "Country", value: "United States" },
236
- },
237
-
238
- // ── Navigation & data ─────────────────────────────────────────────────
239
- NavBar: {
240
- props: z.object({
241
- title: o(z.string()),
242
- leading: o(z.enum(["none", "menu", "back", "close", "logo"])),
243
- actions: o(z.array(iconName)).describe("Icon names shown on the right"),
244
- align: o(z.enum(["left", "center"])),
245
- }),
246
- slots: [],
247
- description: "Top app bar. Place first in a Screen.",
248
- example: { title: "Inbox", leading: "menu", actions: ["search", "more-vertical"] },
249
- },
250
- TabBar: {
251
- props: z.object({
252
- items: z.array(z.object({ label: z.string(), icon: o(iconName) })),
253
- active: o(z.number().int()),
254
- }),
255
- slots: [],
256
- description: "Bottom tab bar for mobile. Place last in a Screen (before overlays).",
257
- example: { items: [{ label: "Home", icon: "home" }, { label: "Profile", icon: "user" }], active: 0 },
258
- },
259
- Tabs: {
260
- props: z.object({
261
- items: z.array(z.string()),
262
- active: o(z.number().int()),
263
- }),
264
- slots: [],
265
- description: "In-page tabs with an underline on the active one.",
266
- example: { items: ["Overview", "Activity", "Settings"], active: 0 },
267
- },
268
- List: {
269
- props: z.object({ dividers: o(z.boolean()), grow: o(z.boolean()) }),
270
- slots: ["default"],
271
- description: "Vertical list of ListItems.",
272
- example: { dividers: true },
273
- },
274
- ListItem: {
275
- props: z.object({
276
- title: o(z.string()),
277
- subtitle: o(z.string()),
278
- leading: o(z.enum(["none", "icon", "avatar", "image", "checkbox"])),
279
- icon: o(iconName),
280
- trailing: o(z.enum(["none", "chevron", "toggle", "text", "badge", "icon"])),
281
- trailingText: o(z.string()),
282
- trailingIcon: o(iconName),
283
- }),
284
- slots: [],
285
- description: "Row in a List. Omit title for a placeholder bar.",
286
- example: { title: "Account", subtitle: "Email, password", leading: "icon", icon: "user", trailing: "chevron" },
287
- },
288
- Table: {
289
- props: z.object({
290
- columns: z.array(z.string()),
291
- rows: o(z.number().int()).describe("Placeholder rows when `data` is not given"),
292
- data: o(z.array(z.array(z.string()))),
293
- }),
294
- slots: [],
295
- description: "Table with headers. Rows are placeholder bars unless `data` is given.",
296
- example: { columns: ["Name", "Status", "Date"], rows: 5 },
297
- },
298
-
299
- // ── Overlays ──────────────────────────────────────────────────────────
300
- Modal: {
301
- props: z.object({
302
- title: o(z.string()),
303
- width: o(z.number()),
304
- scrim: o(z.boolean()).describe("Dim the screen behind. Default true."),
305
- }),
306
- slots: ["default"],
307
- description: "Centered dialog over the screen. Must be a direct child of a Screen, listed last.",
308
- example: { title: "Delete note?", width: 320 },
309
- },
310
- Drawer: {
311
- props: z.object({
312
- side: o(z.enum(["left", "right", "bottom"])),
313
- title: o(z.string()),
314
- size: o(z.number()).describe("Width for left/right, height for bottom"),
315
- scrim: o(z.boolean()).describe("Dim the screen behind. Default true."),
316
- handle: o(z.boolean()).describe("Grab handle on bottom sheets. Default true for bottom."),
317
- }),
318
- slots: ["default"],
319
- description:
320
- "Panel sliding in from an edge: side nav (left), filters/details (right), or bottom sheet. Must be a direct child of a Screen, listed last.",
321
- example: { side: "left", title: "Menu", size: 300 },
322
- },
323
- };
324
-
325
- export const catalog = defineCatalog(wireframeSchema, {
326
- components: componentDefinitions,
327
- });
328
-
329
- export type ComponentName = keyof typeof componentDefinitions;
package/src/colors.ts DELETED
@@ -1,82 +0,0 @@
1
- /**
2
- * The only colors a wireframe can set: a board-level accent and a few fixed
3
- * status tones. Everything else stays grayscale.
4
- */
5
- import { theme } from "./layout";
6
- import { closeMatches } from "./suggest";
7
-
8
- /** Named accents. Each keeps white text above 4.5:1 contrast. */
9
- export const ACCENTS = {
10
- blue: "#2563eb",
11
- indigo: "#4f46e5",
12
- violet: "#7c3aed",
13
- pink: "#db2777",
14
- red: "#dc2626",
15
- orange: "#c2410c",
16
- green: "#15803d",
17
- teal: "#0f766e",
18
- } as const;
19
-
20
- /** Status tones for badges and input errors. They don't change with the accent. */
21
- export const TONES = {
22
- success: "#15803d",
23
- warning: "#b45309",
24
- danger: "#dc2626",
25
- } as const;
26
-
27
- const HEX = /^#[0-9a-fA-F]{6}$/;
28
-
29
- export const isAccent = (v: string) => v in ACCENTS || HEX.test(v);
30
-
31
- export function accentMessage(v: string) {
32
- const names = Object.keys(ACCENTS);
33
- const close = closeMatches(v, names);
34
- return `unknown accent "${v}"` + (close.length ? ` (did you mean ${close.join(", ")}?)` : "") +
35
- `; use one of ${names.join(", ")}, or a hex color like #1a73e8`;
36
- }
37
-
38
- function luminance(hex: string) {
39
- const [r, g, b] = [1, 3, 5]
40
- .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
41
- .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
42
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
43
- }
44
-
45
- function contrast(a: string, b: string) {
46
- const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
47
- return (hi + 0.05) / (lo + 0.05);
48
- }
49
-
50
- /** Mix a color toward black until it reads on white (for text, underlines, icons). */
51
- function readableOnWhite(hex: string) {
52
- let [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
53
- let out = hex;
54
- for (let k = 0; k < 20 && contrast(out, "#ffffff") < 4.5; k++) {
55
- [r, g, b] = [r, g, b].map((v) => Math.round(v * 0.9));
56
- out = "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("");
57
- }
58
- return out;
59
- }
60
-
61
- export interface Palette {
62
- /** Fills for primary buttons, solid badges, checked controls, toggles that are on. */
63
- accent: string;
64
- /** Text and icons drawn on the accent fill. */
65
- onAccent: string;
66
- /** Accent as text or a line on white: active tabs, the active tab-bar item, ghost buttons. Dark gray without an accent. */
67
- accentText: string;
68
- }
69
-
70
- /** Colors for one board. Without an accent this is exactly the grayscale theme. */
71
- export function paletteFor(accent?: string | null): Palette {
72
- if (!accent || !isAccent(accent)) {
73
- return { accent: theme.primary, onAccent: theme.onPrimary, accentText: theme.ink };
74
- }
75
- const hex = (ACCENTS as Record<string, string>)[accent] ?? accent.toLowerCase();
76
- return {
77
- accent: hex,
78
- // hex accents can be light (a brand yellow): switch to dark text when white wouldn't read
79
- onAccent: contrast(hex, "#ffffff") >= 3 ? "#ffffff" : theme.ink,
80
- accentText: readableOnWhite(hex),
81
- };
82
- }
package/src/compile.ts DELETED
@@ -1,64 +0,0 @@
1
- import type { Spec } from "@json-render/core";
2
- import { checkSpec, renderWireframePng, renderWireframeSvg } from "./render";
3
- import { parseWireframeText, type TextIssue } from "./text";
4
-
5
- export interface CompileResult {
6
- /** The compiled spec, or null if the text couldn't be parsed at all. */
7
- spec: Spec | null;
8
- /** Every problem, tied to a source line. Empty means the wireframe is valid. */
9
- issues: TextIssue[];
10
- }
11
-
12
- /**
13
- * Parse wireframe text and validate it against the catalog, reporting every
14
- * problem by line number so it can be shown to a person or fed back to a model.
15
- */
16
- export function compileWireframe(source: string): CompileResult {
17
- const parsed = parseWireframeText(source);
18
- const issues: TextIssue[] = [...parsed.issues];
19
-
20
- if (parsed.spec) {
21
- for (const msg of checkSpec(parsed.spec)) {
22
- // checkSpec reports "<element id>[.props…]: message"; map ids back to lines
23
- const m = msg.match(/^([\w-]+)(?:\.props[^:]*)?: (.*)$/);
24
- const line = m ? parsed.lines[m[1]] : undefined;
25
- const text = m && line ? (m[2].startsWith(`${parsed.spec.elements[m[1]].type} `) ? m[2] : `${parsed.spec.elements[m[1]].type}: ${m[2]}`) : msg;
26
- // "Stack "stack-4"" → "the Stack on line 4"
27
- const readable = text.replace(/(\w+) "([\w-]+)"/g, (all, type, id) =>
28
- parsed.lines[id] ? `the ${type} on line ${parsed.lines[id]}` : all,
29
- );
30
- issues.push({ line: line ?? 1, message: readable });
31
- }
32
- }
33
-
34
- // The parser and the validator can both flag the same unknown prop.
35
- const seen = new Set<string>();
36
- const unique = issues.filter((i) => {
37
- const key = `${i.line}|${i.message.replace(/^\w+: /, "").replace(/ \(.*$/, "")}`;
38
- if (seen.has(key)) return false;
39
- seen.add(key);
40
- return true;
41
- });
42
- unique.sort((a, b) => a.line - b.line);
43
- return { spec: parsed.spec, issues: unique };
44
- }
45
-
46
- /** One issue per line, e.g. for an error message or a model repair prompt. */
47
- export function formatIssues(issues: TextIssue[]) {
48
- return issues.map((i) => `line ${i.line}: ${i.message}`).join("\n");
49
- }
50
-
51
- export class WireframeError extends Error {
52
- constructor(public issues: TextIssue[]) {
53
- super(`Invalid wireframe:\n${formatIssues(issues)}`);
54
- }
55
- }
56
-
57
- /** Compile and render wireframe text. Throws WireframeError (with line-numbered issues) if it's invalid. */
58
- export async function renderWireframe(source: string, opts: { format?: "svg" | "png"; scale?: number } = {}) {
59
- const { spec, issues } = compileWireframe(source);
60
- if (!spec || issues.length) throw new WireframeError(issues);
61
- return opts.format === "png"
62
- ? renderWireframePng(spec, { skipValidation: true, scale: opts.scale })
63
- : renderWireframeSvg(spec, { skipValidation: true });
64
- }