@blurrow/ui 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cli.js +1477 -0
- package/dist/index.d.ts +631 -0
- package/dist/index.js +5559 -0
- package/dist/index.js.map +1 -0
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +92 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +58 -0
- package/dist/registry/Banner.tsx +89 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +372 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +292 -0
- package/dist/registry/Checkbox.tsx +99 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/CommandPalette.tsx +349 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/Drawer.tsx +164 -0
- package/dist/registry/DropdownMenu.tsx +257 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/Input.tsx +97 -0
- package/dist/registry/Kbd.tsx +24 -0
- package/dist/registry/Modal.tsx +128 -0
- package/dist/registry/Pagination.tsx +163 -0
- package/dist/registry/Popover.tsx +121 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +202 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +290 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +121 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +385 -0
- package/dist/registry/Toggle.tsx +99 -0
- package/dist/registry/Tooltip.tsx +212 -0
- package/dist/registry/styles.css +401 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +401 -0
- package/package.json +56 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cli/index.ts
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import fs3 from "fs";
|
|
6
|
+
import path3 from "path";
|
|
7
|
+
|
|
8
|
+
// cli/term.ts
|
|
9
|
+
import readline from "readline";
|
|
10
|
+
var tty = Boolean(process.stdout.isTTY);
|
|
11
|
+
var colour = tty && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
12
|
+
var paint = (open, close) => (s) => colour ? `\x1B[${open}m${s}\x1B[${close}m` : String(s);
|
|
13
|
+
var c = {
|
|
14
|
+
bold: paint(1, 22),
|
|
15
|
+
dim: paint(2, 22),
|
|
16
|
+
red: paint(31, 39),
|
|
17
|
+
green: paint(32, 39),
|
|
18
|
+
yellow: paint(33, 39),
|
|
19
|
+
cyan: paint(36, 39),
|
|
20
|
+
gray: paint(90, 39)
|
|
21
|
+
};
|
|
22
|
+
var sym = {
|
|
23
|
+
ok: c.green("\u2713"),
|
|
24
|
+
warn: c.yellow("!"),
|
|
25
|
+
fail: c.red("\u2717"),
|
|
26
|
+
info: c.cyan("\u203A"),
|
|
27
|
+
dot: c.gray("\xB7")
|
|
28
|
+
};
|
|
29
|
+
var log = {
|
|
30
|
+
line: (s = "") => console.log(s),
|
|
31
|
+
ok: (s) => console.log(` ${sym.ok} ${s}`),
|
|
32
|
+
warn: (s) => console.log(` ${sym.warn} ${s}`),
|
|
33
|
+
fail: (s) => console.log(` ${sym.fail} ${s}`),
|
|
34
|
+
info: (s) => console.log(` ${sym.info} ${s}`),
|
|
35
|
+
step: (s) => console.log(`
|
|
36
|
+
${c.bold(s)}`),
|
|
37
|
+
hint: (s) => console.log(` ${c.gray(s)}`)
|
|
38
|
+
};
|
|
39
|
+
var interactive = tty && Boolean(process.stdin.isTTY) && !process.env.CI;
|
|
40
|
+
async function confirm(question, fallback = true, assumeYes = false) {
|
|
41
|
+
if (assumeYes) return true;
|
|
42
|
+
if (!interactive) return fallback;
|
|
43
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
44
|
+
const hint = fallback ? "Y/n" : "y/N";
|
|
45
|
+
const answer = await new Promise((r) => rl.question(` ${c.cyan("?")} ${question} ${c.gray(`(${hint})`)} `, r));
|
|
46
|
+
rl.close();
|
|
47
|
+
const a = answer.trim().toLowerCase();
|
|
48
|
+
if (!a) return fallback;
|
|
49
|
+
return a === "y" || a === "yes";
|
|
50
|
+
}
|
|
51
|
+
async function choose(question, options, fallback) {
|
|
52
|
+
if (!interactive) return fallback;
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
const keys = options.map((o) => o.key === fallback ? c.bold(o.label) : o.label).join(c.gray(" / "));
|
|
55
|
+
const answer = await new Promise((r) => rl.question(` ${c.cyan("?")} ${question} ${keys} `, r));
|
|
56
|
+
rl.close();
|
|
57
|
+
const a = answer.trim().toLowerCase();
|
|
58
|
+
return options.find((o) => o.label.toLowerCase().startsWith(a) && a)?.key ?? fallback;
|
|
59
|
+
}
|
|
60
|
+
async function multiselect(title, items) {
|
|
61
|
+
if (!interactive) return [];
|
|
62
|
+
const stdin = process.stdin;
|
|
63
|
+
const out = process.stdout;
|
|
64
|
+
const picked = /* @__PURE__ */ new Set();
|
|
65
|
+
let cursor = 0;
|
|
66
|
+
let filter = "";
|
|
67
|
+
let drawn = 0;
|
|
68
|
+
const rows = Math.max(6, Math.min(14, (out.rows ?? 24) - 6));
|
|
69
|
+
const visible = () => {
|
|
70
|
+
const f = filter.toLowerCase();
|
|
71
|
+
return f ? items.filter((i) => i.label.toLowerCase().includes(f) || i.value.includes(f)) : items;
|
|
72
|
+
};
|
|
73
|
+
const draw = () => {
|
|
74
|
+
const list2 = visible();
|
|
75
|
+
cursor = Math.min(cursor, Math.max(0, list2.length - 1));
|
|
76
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), list2.length - rows));
|
|
77
|
+
const page = list2.slice(start, start + rows);
|
|
78
|
+
const lines = [];
|
|
79
|
+
lines.push(` ${c.cyan("?")} ${c.bold(title)} ${c.gray(filter ? `filter: ${filter}` : "type to filter")}`);
|
|
80
|
+
if (!list2.length) lines.push(` ${c.gray("nothing matches")}`);
|
|
81
|
+
page.forEach((item, i) => {
|
|
82
|
+
const at = start + i === cursor;
|
|
83
|
+
const box = picked.has(item.value) ? c.green("\u25C9") : c.gray("\u25CB");
|
|
84
|
+
const label = at ? c.cyan(item.label) : item.label;
|
|
85
|
+
const group = item.group ? c.gray(` ${item.group}`) : "";
|
|
86
|
+
lines.push(` ${at ? c.cyan("\u276F") : " "} ${box} ${label}${group}${at && item.hint ? c.gray(` ${item.hint}`) : ""}`);
|
|
87
|
+
});
|
|
88
|
+
lines.push(c.gray(` \u2191\u2193 move \xB7 space pick \xB7 ctrl+a all \xB7 enter done \xB7 esc cancel ${picked.size} picked`));
|
|
89
|
+
if (drawn) out.write(`\x1B[${drawn}A\x1B[J`);
|
|
90
|
+
out.write(lines.join("\n") + "\n");
|
|
91
|
+
drawn = lines.length;
|
|
92
|
+
};
|
|
93
|
+
readline.emitKeypressEvents(stdin);
|
|
94
|
+
stdin.setRawMode(true);
|
|
95
|
+
stdin.resume();
|
|
96
|
+
out.write("\x1B[?25l");
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const done = (value) => {
|
|
99
|
+
stdin.off("keypress", onKey);
|
|
100
|
+
stdin.setRawMode(false);
|
|
101
|
+
stdin.pause();
|
|
102
|
+
out.write("\x1B[?25h");
|
|
103
|
+
resolve(value);
|
|
104
|
+
};
|
|
105
|
+
const onKey = (str, key) => {
|
|
106
|
+
const list2 = visible();
|
|
107
|
+
if (key.ctrl && key.name === "c") {
|
|
108
|
+
out.write("\x1B[?25h\n");
|
|
109
|
+
process.exit(130);
|
|
110
|
+
}
|
|
111
|
+
if (key.name === "escape") return done([]);
|
|
112
|
+
if (key.name === "return") return done([...picked]);
|
|
113
|
+
if (key.name === "up" || key.ctrl && key.name === "p") cursor = (cursor - 1 + list2.length) % Math.max(1, list2.length);
|
|
114
|
+
else if (key.name === "down" || key.ctrl && key.name === "n") cursor = (cursor + 1) % Math.max(1, list2.length);
|
|
115
|
+
else if (key.name === "space") {
|
|
116
|
+
const v = list2[cursor]?.value;
|
|
117
|
+
if (v) picked.has(v) ? picked.delete(v) : picked.add(v);
|
|
118
|
+
} else if (key.name === "backspace") filter = filter.slice(0, -1);
|
|
119
|
+
else if (key.ctrl && key.name === "a") {
|
|
120
|
+
const all = list2.every((i) => picked.has(i.value));
|
|
121
|
+
list2.forEach((i) => all ? picked.delete(i.value) : picked.add(i.value));
|
|
122
|
+
} else if (str && /^[a-z0-9 -]$/i.test(str)) {
|
|
123
|
+
filter += str.toLowerCase();
|
|
124
|
+
cursor = 0;
|
|
125
|
+
}
|
|
126
|
+
draw();
|
|
127
|
+
};
|
|
128
|
+
stdin.on("keypress", onKey);
|
|
129
|
+
draw();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// cli/components.ts
|
|
134
|
+
import fs from "fs";
|
|
135
|
+
import path from "path";
|
|
136
|
+
import { fileURLToPath } from "url";
|
|
137
|
+
|
|
138
|
+
// ../../lib/registry.ts
|
|
139
|
+
var categories = [
|
|
140
|
+
"actions",
|
|
141
|
+
"forms",
|
|
142
|
+
"navigation",
|
|
143
|
+
"overlays",
|
|
144
|
+
"feedback",
|
|
145
|
+
"data display",
|
|
146
|
+
"cards",
|
|
147
|
+
"editorial"
|
|
148
|
+
];
|
|
149
|
+
var ex = (name, title, description) => ({
|
|
150
|
+
name,
|
|
151
|
+
title,
|
|
152
|
+
description
|
|
153
|
+
});
|
|
154
|
+
var registry = [
|
|
155
|
+
/* ── Actions ─────────────────────────────────────────────────── */
|
|
156
|
+
{
|
|
157
|
+
slug: "button",
|
|
158
|
+
name: "button",
|
|
159
|
+
category: "actions",
|
|
160
|
+
description: "a raised face sitting in a bezel. the bezel stays put, the face lifts when you hover and sinks when you press.",
|
|
161
|
+
file: "Button",
|
|
162
|
+
usage: `<Button variant="primary">get started</Button>`,
|
|
163
|
+
examples: [
|
|
164
|
+
ex("button-demo", "default"),
|
|
165
|
+
ex("button-variants", "variants", "primary and secondary are layered; outline is a lighter bezel; ghost is a single flat face."),
|
|
166
|
+
ex("button-sizes", "sizes"),
|
|
167
|
+
ex("button-with-icon", "with icon", "a trailing icon leans toward where the button is taking you."),
|
|
168
|
+
ex("button-icon", "icon only", 'use size="icon" for a bezel-less key, or square for a layered one.'),
|
|
169
|
+
ex("button-on-dark", "on a dark surface", `tone="dark" swaps secondary and outline to a dark face so they don't glare.`)
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
slug: "button-group",
|
|
174
|
+
name: "button group",
|
|
175
|
+
category: "actions",
|
|
176
|
+
description: "a few keys sharing one bezel. handy for toolbars and social links.",
|
|
177
|
+
file: "ButtonGroup",
|
|
178
|
+
usage: `<ButtonGroup
|
|
179
|
+
label="text formatting"
|
|
180
|
+
items={[
|
|
181
|
+
{ label: "bold", content: <BoldIcon /> },
|
|
182
|
+
{ label: "italic", content: <ItalicIcon /> },
|
|
183
|
+
]}
|
|
184
|
+
/>`,
|
|
185
|
+
examples: [
|
|
186
|
+
ex("button-group-demo", "default"),
|
|
187
|
+
ex("button-group-wide", "wide keys", "set wide on an item to size it to its content instead of a square.")
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
slug: "close-button",
|
|
192
|
+
name: "close button",
|
|
193
|
+
category: "actions",
|
|
194
|
+
description: "the little key that closes alerts, banners, modals and drawers.",
|
|
195
|
+
file: "CloseButton",
|
|
196
|
+
usage: `<CloseButton onClick={onClose} />`,
|
|
197
|
+
examples: [
|
|
198
|
+
ex("close-button-demo", "default"),
|
|
199
|
+
ex("close-button-dark", "dark tone")
|
|
200
|
+
]
|
|
201
|
+
},
|
|
202
|
+
/* ── Forms ───────────────────────────────────────────────────── */
|
|
203
|
+
{
|
|
204
|
+
slug: "input",
|
|
205
|
+
name: "input",
|
|
206
|
+
category: "forms",
|
|
207
|
+
description: "a text field set into the surface. when you focus it, the well comes up flush and draws its border.",
|
|
208
|
+
file: "Input",
|
|
209
|
+
usage: `<Input label="email" placeholder="you@example.com" />`,
|
|
210
|
+
examples: [
|
|
211
|
+
ex("input-demo", "default"),
|
|
212
|
+
ex("input-with-label", "with label and hint"),
|
|
213
|
+
ex("input-with-icon", "with icon"),
|
|
214
|
+
ex("input-error", "error"),
|
|
215
|
+
ex("input-disabled", "disabled")
|
|
216
|
+
]
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
slug: "textarea",
|
|
220
|
+
name: "textarea",
|
|
221
|
+
category: "forms",
|
|
222
|
+
description: "a field for longer writing. it can grow with what you type if you'd like.",
|
|
223
|
+
file: "Textarea",
|
|
224
|
+
usage: `<Textarea label="bio" placeholder="tell us about yourself\u2026" />`,
|
|
225
|
+
examples: [
|
|
226
|
+
ex("textarea-demo", "default"),
|
|
227
|
+
ex("textarea-auto-resize", "auto resize"),
|
|
228
|
+
ex("textarea-error", "error")
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
slug: "select",
|
|
233
|
+
name: "select",
|
|
234
|
+
category: "forms",
|
|
235
|
+
description: "looks like a field, opens a panel of options. works with the keyboard too.",
|
|
236
|
+
file: "Select",
|
|
237
|
+
usage: `<Select options={options} value={value} onChange={setValue} />`,
|
|
238
|
+
examples: [
|
|
239
|
+
ex("select-demo", "default"),
|
|
240
|
+
ex("select-with-label", "with label"),
|
|
241
|
+
ex("select-disabled", "disabled")
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
slug: "checkbox",
|
|
246
|
+
name: "checkbox",
|
|
247
|
+
category: "forms",
|
|
248
|
+
description: "a small sunken box. the check pops in and draws itself when you tick it.",
|
|
249
|
+
file: "Checkbox",
|
|
250
|
+
usage: `<Checkbox label="weekly digest" checked={checked} onChange={setChecked} />`,
|
|
251
|
+
examples: [
|
|
252
|
+
ex("checkbox-demo", "default"),
|
|
253
|
+
ex("checkbox-with-description", "with description"),
|
|
254
|
+
ex("checkbox-indeterminate", "indeterminate"),
|
|
255
|
+
ex("checkbox-disabled", "disabled")
|
|
256
|
+
]
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
slug: "radio-group",
|
|
260
|
+
name: "radio group",
|
|
261
|
+
category: "forms",
|
|
262
|
+
description: "pick one option from a few. the dot slides over to whatever you choose.",
|
|
263
|
+
file: "Radio",
|
|
264
|
+
usage: `<RadioGroup options={options} value={value} onChange={setValue} />`,
|
|
265
|
+
examples: [
|
|
266
|
+
ex("radio-group-demo", "default"),
|
|
267
|
+
ex("radio-group-disabled", "disabled option")
|
|
268
|
+
]
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
slug: "toggle",
|
|
272
|
+
name: "toggle",
|
|
273
|
+
category: "forms",
|
|
274
|
+
description: "a switch for settings that should take effect right away.",
|
|
275
|
+
file: "Toggle",
|
|
276
|
+
usage: `<Toggle label="email notifications" checked={on} onChange={setOn} />`,
|
|
277
|
+
examples: [
|
|
278
|
+
ex("toggle-demo", "default"),
|
|
279
|
+
ex("toggle-with-description", "with description"),
|
|
280
|
+
ex("toggle-disabled", "disabled")
|
|
281
|
+
]
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
slug: "tag-input",
|
|
285
|
+
name: "tag input",
|
|
286
|
+
category: "forms",
|
|
287
|
+
description: "type a word, hit enter or a comma, and it turns into a tag. backspace takes it away.",
|
|
288
|
+
file: "TagInput",
|
|
289
|
+
usage: `<TagInput value={tags} onChange={setTags} label="tags" />`,
|
|
290
|
+
examples: [
|
|
291
|
+
ex("tag-input-demo", "default"),
|
|
292
|
+
ex("tag-input-max", "limited", "maxtags caps how many tags can be added.")
|
|
293
|
+
]
|
|
294
|
+
},
|
|
295
|
+
/* ── Navigation ──────────────────────────────────────────────── */
|
|
296
|
+
{
|
|
297
|
+
slug: "tabs",
|
|
298
|
+
name: "tabs",
|
|
299
|
+
category: "navigation",
|
|
300
|
+
description: "a sunken well with a raised thumb that slides between tabs. arrow keys work as well.",
|
|
301
|
+
file: "Tabs",
|
|
302
|
+
usage: `<Tabs
|
|
303
|
+
tabs={[
|
|
304
|
+
{ key: "overview", label: "overview", content: <p>\u2026</p> },
|
|
305
|
+
{ key: "posts", label: "posts", content: <p>\u2026</p> },
|
|
306
|
+
]}
|
|
307
|
+
/>`,
|
|
308
|
+
examples: [
|
|
309
|
+
ex("tabs-demo", "default"),
|
|
310
|
+
ex("tabs-segmented", "segmented control", "leave out content and listen to onchange to use the well as a segmented control.")
|
|
311
|
+
]
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
slug: "pagination",
|
|
315
|
+
name: "pagination",
|
|
316
|
+
category: "navigation",
|
|
317
|
+
description: "page numbers in a well, with a thumb that slides to the page you're on.",
|
|
318
|
+
file: "Pagination",
|
|
319
|
+
usage: `<Pagination currentPage={page} totalPages={12} onPageChange={setPage} />`,
|
|
320
|
+
examples: [ex("pagination-demo", "default")]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
slug: "breadcrumb",
|
|
324
|
+
name: "breadcrumb",
|
|
325
|
+
category: "navigation",
|
|
326
|
+
description: "shows where you are, and how you got there.",
|
|
327
|
+
file: "Breadcrumb",
|
|
328
|
+
usage: `<Breadcrumb items={[{ label: "home", href: "/" }, { label: "components" }]} />`,
|
|
329
|
+
examples: [ex("breadcrumb-demo", "default")]
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
slug: "sidebar",
|
|
333
|
+
name: "sidebar",
|
|
334
|
+
category: "navigation",
|
|
335
|
+
description: "grouped navigation in a panel. a dark pill slides to whatever's active.",
|
|
336
|
+
file: "Sidebar",
|
|
337
|
+
usage: `<Sidebar sections={sections} active={active} onSelect={setActive} />`,
|
|
338
|
+
examples: [ex("sidebar-demo", "default")]
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
slug: "steps",
|
|
342
|
+
name: "steps",
|
|
343
|
+
category: "navigation",
|
|
344
|
+
description: "shows how far along someone is in a flow with a few steps.",
|
|
345
|
+
file: "Steps",
|
|
346
|
+
usage: `<Steps current={1} steps={[{ label: "account" }, { label: "profile" }, { label: "done" }]} />`,
|
|
347
|
+
examples: [
|
|
348
|
+
ex("steps-demo", "default"),
|
|
349
|
+
ex("steps-interactive", "interactive")
|
|
350
|
+
]
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
slug: "table-of-contents",
|
|
354
|
+
name: "table of contents",
|
|
355
|
+
category: "navigation",
|
|
356
|
+
description: "a thin track with a marker that follows the heading you're reading. the one on the right of these pages is this component.",
|
|
357
|
+
file: "TableOfContents",
|
|
358
|
+
usage: `<TableOfContents items={headings} activeKey={active} onSelect={scrollTo} />`,
|
|
359
|
+
examples: [ex("table-of-contents-demo", "default")]
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
slug: "command-palette",
|
|
363
|
+
name: "command palette",
|
|
364
|
+
category: "navigation",
|
|
365
|
+
description: "a searchable list of actions in a dialog. press \u2318k on this site to try the one that searches these docs.",
|
|
366
|
+
file: "CommandPalette",
|
|
367
|
+
usage: `<CommandPalette open={open} onClose={() => setOpen(false)} sections={sections} />`,
|
|
368
|
+
examples: [ex("command-palette-demo", "default")]
|
|
369
|
+
},
|
|
370
|
+
/* ── Overlays ────────────────────────────────────────────────── */
|
|
371
|
+
{
|
|
372
|
+
slug: "modal",
|
|
373
|
+
name: "modal",
|
|
374
|
+
category: "overlays",
|
|
375
|
+
description: "a dialog that springs in over the page. escape closes it and focus goes back where it was.",
|
|
376
|
+
file: "Modal",
|
|
377
|
+
usage: `<Modal open={open} onClose={() => setOpen(false)} title="delete post?" actions={actions} />`,
|
|
378
|
+
examples: [
|
|
379
|
+
ex("modal-demo", "default"),
|
|
380
|
+
ex("modal-with-form", "with content")
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
slug: "drawer",
|
|
385
|
+
name: "drawer",
|
|
386
|
+
category: "overlays",
|
|
387
|
+
description: "a panel that slides in from the side, for things that don't need the whole page.",
|
|
388
|
+
file: "Drawer",
|
|
389
|
+
usage: `<Drawer open={open} onClose={() => setOpen(false)} title="edit settings">\u2026</Drawer>`,
|
|
390
|
+
examples: [
|
|
391
|
+
ex("drawer-demo", "default"),
|
|
392
|
+
ex("drawer-left", "left side")
|
|
393
|
+
]
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
slug: "popover",
|
|
397
|
+
name: "popover",
|
|
398
|
+
category: "overlays",
|
|
399
|
+
description: "a bit of content that opens next to whatever you clicked.",
|
|
400
|
+
file: "Popover",
|
|
401
|
+
usage: `<Popover trigger={<Button variant="secondary">open</Button>}>\u2026</Popover>`,
|
|
402
|
+
examples: [
|
|
403
|
+
ex("popover-demo", "default"),
|
|
404
|
+
ex("popover-positions", "positions")
|
|
405
|
+
]
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
slug: "tooltip",
|
|
409
|
+
name: "tooltip",
|
|
410
|
+
category: "overlays",
|
|
411
|
+
description: "a short label that shows up when you hover or focus something.",
|
|
412
|
+
file: "Tooltip",
|
|
413
|
+
usage: `<Tooltip content="edit post"><Button>edit</Button></Tooltip>`,
|
|
414
|
+
examples: [
|
|
415
|
+
ex("tooltip-demo", "default"),
|
|
416
|
+
ex("tooltip-positions", "positions")
|
|
417
|
+
]
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
slug: "dropdown-menu",
|
|
421
|
+
name: "dropdown menu",
|
|
422
|
+
category: "overlays",
|
|
423
|
+
description: "a menu of actions under a trigger, with shortcuts, dividers and a red one for deleting.",
|
|
424
|
+
file: "DropdownMenu",
|
|
425
|
+
usage: `<DropdownMenu trigger={<Button variant="secondary">options</Button>} items={items} onSelect={handle} />`,
|
|
426
|
+
examples: [
|
|
427
|
+
ex("dropdown-menu-demo", "default"),
|
|
428
|
+
ex("dropdown-menu-with-icons", "with icons")
|
|
429
|
+
]
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
slug: "toast",
|
|
433
|
+
name: "toast",
|
|
434
|
+
category: "overlays",
|
|
435
|
+
description: "little notes that stack up in the corner and fan out when you hover. wrap your app in ToastProvider, then call toast() from anywhere.",
|
|
436
|
+
file: "Toast",
|
|
437
|
+
usage: `// app/layout.tsx
|
|
438
|
+
<ToastProvider>{children}</ToastProvider>
|
|
439
|
+
|
|
440
|
+
// anywhere below it
|
|
441
|
+
const { toast } = useToast();
|
|
442
|
+
toast({ title: "post saved", variant: "success" });`,
|
|
443
|
+
examples: [
|
|
444
|
+
ex("toast-demo", "default"),
|
|
445
|
+
ex("toast-variants", "variants")
|
|
446
|
+
]
|
|
447
|
+
},
|
|
448
|
+
/* ── Feedback ────────────────────────────────────────────────── */
|
|
449
|
+
{
|
|
450
|
+
slug: "alert",
|
|
451
|
+
name: "alert",
|
|
452
|
+
category: "feedback",
|
|
453
|
+
description: "a message that sits in the page, with an icon tile that changes per variant.",
|
|
454
|
+
file: "Alert",
|
|
455
|
+
usage: `<Alert variant="success" title="published" description="your post is live." />`,
|
|
456
|
+
examples: [
|
|
457
|
+
ex("alert-demo", "default"),
|
|
458
|
+
ex("alert-variants", "variants"),
|
|
459
|
+
ex("alert-dismissible", "dismissible")
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
slug: "banner",
|
|
464
|
+
name: "banner",
|
|
465
|
+
category: "feedback",
|
|
466
|
+
description: "a wide announcement strip, built just like a primary button.",
|
|
467
|
+
file: "Banner",
|
|
468
|
+
usage: `<Banner dismissible>blurrow v1.0 is out.</Banner>`,
|
|
469
|
+
examples: [
|
|
470
|
+
ex("banner-demo", "default"),
|
|
471
|
+
ex("banner-variants", "variants"),
|
|
472
|
+
ex("banner-with-action", "with action")
|
|
473
|
+
]
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
slug: "progress",
|
|
477
|
+
name: "progress",
|
|
478
|
+
category: "feedback",
|
|
479
|
+
description: "a track that fills up as something gets done.",
|
|
480
|
+
file: "Progress",
|
|
481
|
+
usage: `<Progress label="uploading" value={64} showValue />`,
|
|
482
|
+
examples: [
|
|
483
|
+
ex("progress-demo", "default"),
|
|
484
|
+
ex("progress-stacked", "stacked")
|
|
485
|
+
]
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
slug: "skeleton",
|
|
489
|
+
name: "skeleton",
|
|
490
|
+
category: "feedback",
|
|
491
|
+
description: "placeholders while things load. one band of light sweeps across all of them together.",
|
|
492
|
+
file: "Skeleton",
|
|
493
|
+
usage: `<Skeleton variant="text" lines={3} />`,
|
|
494
|
+
examples: [
|
|
495
|
+
ex("skeleton-demo", "default"),
|
|
496
|
+
ex("skeleton-card", "cards")
|
|
497
|
+
]
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
slug: "empty-state",
|
|
501
|
+
name: "empty state",
|
|
502
|
+
category: "feedback",
|
|
503
|
+
description: "what to show when a list or page has nothing in it yet, plus something to do about it.",
|
|
504
|
+
file: "EmptyState",
|
|
505
|
+
usage: `<EmptyState title="no posts yet" description="\u2026" action={<Button>create post</Button>} />`,
|
|
506
|
+
examples: [ex("empty-state-demo", "default")]
|
|
507
|
+
},
|
|
508
|
+
/* ── Data display ────────────────────────────────────────────── */
|
|
509
|
+
{
|
|
510
|
+
slug: "badge",
|
|
511
|
+
name: "badge",
|
|
512
|
+
category: "data display",
|
|
513
|
+
description: "a small raised label for status and little bits of info.",
|
|
514
|
+
file: "Badge",
|
|
515
|
+
usage: `<Badge variant="accent">new</Badge>`,
|
|
516
|
+
examples: [
|
|
517
|
+
ex("badge-demo", "default"),
|
|
518
|
+
ex("badge-variants", "variants")
|
|
519
|
+
]
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
slug: "avatar",
|
|
523
|
+
name: "avatar",
|
|
524
|
+
category: "data display",
|
|
525
|
+
description: "someone's photo, or their initials on a raised tile when there isn't one.",
|
|
526
|
+
file: "Avatar",
|
|
527
|
+
usage: `<Avatar name="ada lovelace" />`,
|
|
528
|
+
examples: [
|
|
529
|
+
ex("avatar-demo", "default"),
|
|
530
|
+
ex("avatar-sizes", "sizes"),
|
|
531
|
+
ex("avatar-group", "group", "avatargroup overlaps its children and collapses the rest into a count.")
|
|
532
|
+
]
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
slug: "kbd",
|
|
536
|
+
name: "kbd",
|
|
537
|
+
category: "data display",
|
|
538
|
+
description: "a little keycap for keyboard shortcuts.",
|
|
539
|
+
file: "Kbd",
|
|
540
|
+
usage: `<Kbd>\u2318k</Kbd>`,
|
|
541
|
+
examples: [ex("kbd-demo", "default")]
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
slug: "table",
|
|
545
|
+
name: "table",
|
|
546
|
+
category: "data display",
|
|
547
|
+
description: "rows of data in a panel. you can render cells however you like and make rows clickable.",
|
|
548
|
+
file: "Table",
|
|
549
|
+
usage: `<Table columns={columns} rows={rows} title="recent posts" />`,
|
|
550
|
+
examples: [ex("table-demo", "default")]
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
slug: "data-list",
|
|
554
|
+
name: "data list",
|
|
555
|
+
category: "data display",
|
|
556
|
+
description: "labels and values, for describing one thing in detail.",
|
|
557
|
+
file: "DataList",
|
|
558
|
+
usage: `<DataList items={[{ label: "author", value: "ada" }]} />`,
|
|
559
|
+
examples: [
|
|
560
|
+
ex("data-list-demo", "default"),
|
|
561
|
+
ex("data-list-two-columns", "two columns")
|
|
562
|
+
]
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
slug: "stat",
|
|
566
|
+
name: "stat",
|
|
567
|
+
category: "data display",
|
|
568
|
+
description: "a big number that counts up into place, with a label and a trend.",
|
|
569
|
+
file: "Stat",
|
|
570
|
+
usage: `<StatRow>
|
|
571
|
+
<Stat value={2847} label="total reads" trend="\u2191 12%" />
|
|
572
|
+
</StatRow>`,
|
|
573
|
+
examples: [ex("stat-demo", "default")]
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
slug: "stat-card",
|
|
577
|
+
name: "stat card",
|
|
578
|
+
category: "data display",
|
|
579
|
+
description: "a number on a card, with how it's changed and an optional sparkline.",
|
|
580
|
+
file: "StatCard",
|
|
581
|
+
usage: `<StatCard label="monthly readers" value={24891} delta={{ value: "12.4%", direction: "up" }} />`,
|
|
582
|
+
examples: [
|
|
583
|
+
ex("stat-card-demo", "default"),
|
|
584
|
+
ex("stat-card-dark", "dark")
|
|
585
|
+
]
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
slug: "timeline",
|
|
589
|
+
name: "timeline",
|
|
590
|
+
category: "data display",
|
|
591
|
+
description: "a list of things that happened, newest first.",
|
|
592
|
+
file: "Timeline",
|
|
593
|
+
usage: `<Timeline items={events} />`,
|
|
594
|
+
examples: [ex("timeline-demo", "default")]
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
slug: "code-block",
|
|
598
|
+
name: "code block",
|
|
599
|
+
category: "data display",
|
|
600
|
+
description: "code on a raised face, with the filename up top, light highlighting and a copy key.",
|
|
601
|
+
file: "CodeBlock",
|
|
602
|
+
usage: `<CodeBlock language="tsx" title="app.tsx" code={source} />`,
|
|
603
|
+
examples: [
|
|
604
|
+
ex("code-block-demo", "default"),
|
|
605
|
+
ex("code-block-plain", "plain text", "languages other than js/ts are shown without highlighting.")
|
|
606
|
+
]
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
slug: "accordion",
|
|
610
|
+
name: "accordion",
|
|
611
|
+
category: "data display",
|
|
612
|
+
description: "sections that open up when you click them.",
|
|
613
|
+
file: "Accordion",
|
|
614
|
+
usage: `<Accordion items={[{ key: "1", title: "is it free?", content: "yes." }]} />`,
|
|
615
|
+
examples: [
|
|
616
|
+
ex("accordion-demo", "default"),
|
|
617
|
+
ex("accordion-multiple", "multiple open")
|
|
618
|
+
]
|
|
619
|
+
},
|
|
620
|
+
/* ── Cards ───────────────────────────────────────────────────── */
|
|
621
|
+
{
|
|
622
|
+
slug: "card-shell",
|
|
623
|
+
name: "card shell",
|
|
624
|
+
category: "cards",
|
|
625
|
+
description: "the frame every card is built on: a bezel holding a face. content goes on the face, buttons and context go on the strip below.",
|
|
626
|
+
file: "CardShell",
|
|
627
|
+
usage: `<CardShell rail={<Button size="sm">open</Button>}>\u2026</CardShell>`,
|
|
628
|
+
examples: [
|
|
629
|
+
ex("card-shell-demo", "default"),
|
|
630
|
+
ex("card-shell-text-rail", "text rail", `railkind="text" indents plain context to the face's text edge.`),
|
|
631
|
+
ex("card-shell-dark", "dark")
|
|
632
|
+
]
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
slug: "card",
|
|
636
|
+
name: "card",
|
|
637
|
+
category: "cards",
|
|
638
|
+
description: "ready-made cards in two layouts: split (title and picture side by side) and stack (picture on top).",
|
|
639
|
+
file: "Card",
|
|
640
|
+
usage: `<Card title="newsletter" subtitle="ideas in your inbox" actions={<Button size="sm">subscribe</Button>} />`,
|
|
641
|
+
examples: [
|
|
642
|
+
ex("card-demo", "split"),
|
|
643
|
+
ex("card-stack", "stack"),
|
|
644
|
+
ex("card-grid", "grid", "cardgrid lays cards out two-up and stacks them on small screens.")
|
|
645
|
+
]
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
slug: "quote-card",
|
|
649
|
+
name: "quote card",
|
|
650
|
+
category: "cards",
|
|
651
|
+
description: "a quote, with who said it on the strip below.",
|
|
652
|
+
file: "QuoteCard",
|
|
653
|
+
usage: `<QuoteCard quote="\u2026" author="ada lovelace" role="analyst" />`,
|
|
654
|
+
examples: [
|
|
655
|
+
ex("quote-card-demo", "default"),
|
|
656
|
+
ex("quote-card-dark", "dark")
|
|
657
|
+
]
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
slug: "spotlight-card",
|
|
661
|
+
name: "spotlight card",
|
|
662
|
+
category: "cards",
|
|
663
|
+
description: "a big card for announcements, with room for an eyebrow, an icon and a couple of buttons.",
|
|
664
|
+
file: "SpotlightCard",
|
|
665
|
+
usage: `<SpotlightCard title="realtime collaboration is here." subtitle="\u2026" actions={\u2026} />`,
|
|
666
|
+
examples: [
|
|
667
|
+
ex("spotlight-card-demo", "default"),
|
|
668
|
+
ex("spotlight-card-light", "light")
|
|
669
|
+
]
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
slug: "author-card",
|
|
673
|
+
name: "author card",
|
|
674
|
+
category: "cards",
|
|
675
|
+
description: "an author's avatar, name and bio, with a follow button.",
|
|
676
|
+
file: "AuthorCard",
|
|
677
|
+
usage: `<AuthorCard name="ada lovelace" bio="\u2026" onFollow={follow} />`,
|
|
678
|
+
examples: [ex("author-card-demo", "default")]
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
slug: "carousel",
|
|
682
|
+
name: "carousel",
|
|
683
|
+
category: "cards",
|
|
684
|
+
description: "slides that move on by themselves or when you nudge them, with a thin line showing time left.",
|
|
685
|
+
file: "Carousel",
|
|
686
|
+
usage: `<Carousel autoPlay items={[<CarouselSlide key="a" title="\u2026" />]} />`,
|
|
687
|
+
examples: [ex("carousel-demo", "default")]
|
|
688
|
+
},
|
|
689
|
+
/* ── Editorial ───────────────────────────────────────────────── */
|
|
690
|
+
{
|
|
691
|
+
slug: "reading-progress",
|
|
692
|
+
name: "reading progress",
|
|
693
|
+
category: "editorial",
|
|
694
|
+
description: "a thin line along the top of the window that fills as you read.",
|
|
695
|
+
file: "ReadingProgress",
|
|
696
|
+
usage: `<ReadingProgress />`,
|
|
697
|
+
examples: [ex("reading-progress-demo", "default", "it's mounted on this page. scroll and watch the top edge of the window.")]
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
slug: "reading-time",
|
|
701
|
+
name: "reading time",
|
|
702
|
+
category: "editorial",
|
|
703
|
+
description: "roughly how long an article takes to read.",
|
|
704
|
+
file: "ReadingTime",
|
|
705
|
+
usage: `<ReadingTime minutes={8} />`,
|
|
706
|
+
examples: [ex("reading-time-demo", "default")]
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
slug: "reaction-bar",
|
|
710
|
+
name: "reaction bar",
|
|
711
|
+
category: "editorial",
|
|
712
|
+
description: "emoji reactions that count up when readers tap them.",
|
|
713
|
+
file: "ReactionBar",
|
|
714
|
+
usage: `<ReactionBar />`,
|
|
715
|
+
examples: [ex("reaction-bar-demo", "default")]
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
slug: "share-buttons",
|
|
719
|
+
name: "share buttons",
|
|
720
|
+
category: "editorial",
|
|
721
|
+
description: "share a post to social sites, or copy its link.",
|
|
722
|
+
file: "ShareButtons",
|
|
723
|
+
usage: `<ShareButtons url={url} title={title} />`,
|
|
724
|
+
examples: [ex("share-buttons-demo", "default")]
|
|
725
|
+
}
|
|
726
|
+
];
|
|
727
|
+
|
|
728
|
+
// cli/components.ts
|
|
729
|
+
var SOURCE_DIR = fileURLToPath(new URL("./registry/", import.meta.url));
|
|
730
|
+
var squash = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
731
|
+
function findEntry(name) {
|
|
732
|
+
const k = squash(name);
|
|
733
|
+
return registry.find((e) => squash(e.slug) === k || squash(e.file) === k || squash(e.name) === k);
|
|
734
|
+
}
|
|
735
|
+
function distance(a, b) {
|
|
736
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
737
|
+
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
|
|
738
|
+
for (let i = 1; i <= a.length; i++)
|
|
739
|
+
for (let j = 1; j <= b.length; j++)
|
|
740
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
741
|
+
return dp[a.length][b.length];
|
|
742
|
+
}
|
|
743
|
+
function suggest(name) {
|
|
744
|
+
const k = squash(name);
|
|
745
|
+
return registry.map((e) => ({ slug: e.slug, d: Math.min(distance(k, squash(e.slug)), squash(e.slug).startsWith(k) ? 0 : 99) })).filter((x) => x.d <= Math.max(2, Math.floor(k.length / 3))).sort((a, b) => a.d - b.d).slice(0, 3).map((x) => x.slug);
|
|
746
|
+
}
|
|
747
|
+
function sourceFile(file) {
|
|
748
|
+
for (const ext of [".tsx", ".ts"]) {
|
|
749
|
+
const p = path.join(SOURCE_DIR, file + ext);
|
|
750
|
+
if (fs.existsSync(p)) return { name: file + ext, code: fs.readFileSync(p, "utf8") };
|
|
751
|
+
}
|
|
752
|
+
throw new Error(`source for ${file} is missing from the package`);
|
|
753
|
+
}
|
|
754
|
+
function withDependencies(files) {
|
|
755
|
+
const seen = /* @__PURE__ */ new Map();
|
|
756
|
+
const visit = (file) => {
|
|
757
|
+
if (seen.has(file)) return;
|
|
758
|
+
const src = sourceFile(file);
|
|
759
|
+
seen.set(file, src);
|
|
760
|
+
for (const m of src.code.matchAll(/from\s+["']\.\/([\w-]+)["']/g)) visit(m[1]);
|
|
761
|
+
};
|
|
762
|
+
files.forEach(visit);
|
|
763
|
+
return [...seen.values()];
|
|
764
|
+
}
|
|
765
|
+
function diffLines(a, b) {
|
|
766
|
+
const x = a.split("\n");
|
|
767
|
+
const y = b.split("\n");
|
|
768
|
+
const n = x.length;
|
|
769
|
+
const m = y.length;
|
|
770
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
771
|
+
for (let i2 = n - 1; i2 >= 0; i2--)
|
|
772
|
+
for (let j2 = m - 1; j2 >= 0; j2--) lcs[i2][j2] = x[i2] === y[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
773
|
+
const out = [];
|
|
774
|
+
let i = 0;
|
|
775
|
+
let j = 0;
|
|
776
|
+
while (i < n && j < m) {
|
|
777
|
+
if (x[i] === y[j]) {
|
|
778
|
+
out.push({ kind: " ", text: x[i] });
|
|
779
|
+
i++;
|
|
780
|
+
j++;
|
|
781
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ kind: "-", text: x[i++] });
|
|
782
|
+
else out.push({ kind: "+", text: y[j++] });
|
|
783
|
+
}
|
|
784
|
+
while (i < n) out.push({ kind: "-", text: x[i++] });
|
|
785
|
+
while (j < m) out.push({ kind: "+", text: y[j++] });
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
function hunks(lines, context = 3) {
|
|
789
|
+
const keep = /* @__PURE__ */ new Set();
|
|
790
|
+
lines.forEach((l, i) => {
|
|
791
|
+
if (l.kind !== " ") for (let k = i - context; k <= i + context; k++) keep.add(k);
|
|
792
|
+
});
|
|
793
|
+
const out = [];
|
|
794
|
+
let last = -2;
|
|
795
|
+
lines.forEach((l, i) => {
|
|
796
|
+
if (!keep.has(i)) return;
|
|
797
|
+
if (i !== last + 1 && out.length) out.push(null);
|
|
798
|
+
out.push(l);
|
|
799
|
+
last = i;
|
|
800
|
+
});
|
|
801
|
+
return out;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// cli/project.ts
|
|
805
|
+
import fs2 from "fs";
|
|
806
|
+
import path2 from "path";
|
|
807
|
+
var PKG = "@blurrow/ui";
|
|
808
|
+
var CONFIG_FILE = "blurrow.json";
|
|
809
|
+
function readProject(root) {
|
|
810
|
+
const pkgPath = path2.join(root, "package.json");
|
|
811
|
+
const pkg = fs2.existsSync(pkgPath) ? JSON.parse(fs2.readFileSync(pkgPath, "utf8")) : null;
|
|
812
|
+
const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
813
|
+
const cfgPath = path2.join(root, CONFIG_FILE);
|
|
814
|
+
const config = fs2.existsSync(cfgPath) ? JSON.parse(fs2.readFileSync(cfgPath, "utf8")) : null;
|
|
815
|
+
return { root, pkg, deps, pm: detectPm(root), config };
|
|
816
|
+
}
|
|
817
|
+
function detectPm(root) {
|
|
818
|
+
const has = (f) => fs2.existsSync(path2.join(root, f));
|
|
819
|
+
if (has("pnpm-lock.yaml")) return "pnpm";
|
|
820
|
+
if (has("yarn.lock")) return "yarn";
|
|
821
|
+
if (has("bun.lockb") || has("bun.lock")) return "bun";
|
|
822
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
823
|
+
if (agent.startsWith("pnpm")) return "pnpm";
|
|
824
|
+
if (agent.startsWith("yarn")) return "yarn";
|
|
825
|
+
if (agent.startsWith("bun")) return "bun";
|
|
826
|
+
return "npm";
|
|
827
|
+
}
|
|
828
|
+
function installCommand(pm, pkgs) {
|
|
829
|
+
const verb = pm === "npm" ? "install" : "add";
|
|
830
|
+
return [pm, verb, ...pkgs];
|
|
831
|
+
}
|
|
832
|
+
function majorOf(range) {
|
|
833
|
+
if (!range) return null;
|
|
834
|
+
const m = range.match(/(\d+)/);
|
|
835
|
+
return m ? Number(m[1]) : null;
|
|
836
|
+
}
|
|
837
|
+
function installedMajor(root, name, range) {
|
|
838
|
+
const p = path2.join(root, "node_modules", name, "package.json");
|
|
839
|
+
if (fs2.existsSync(p)) return majorOf(JSON.parse(fs2.readFileSync(p, "utf8")).version);
|
|
840
|
+
return majorOf(range);
|
|
841
|
+
}
|
|
842
|
+
var CSS_CANDIDATES = [
|
|
843
|
+
"app/globals.css",
|
|
844
|
+
"src/app/globals.css",
|
|
845
|
+
"styles/globals.css",
|
|
846
|
+
"src/styles/globals.css",
|
|
847
|
+
"src/index.css",
|
|
848
|
+
"src/styles.css",
|
|
849
|
+
"src/global.css",
|
|
850
|
+
"app/app.css"
|
|
851
|
+
];
|
|
852
|
+
function findCss(p) {
|
|
853
|
+
if (p.config?.css && fs2.existsSync(path2.join(p.root, p.config.css))) return p.config.css;
|
|
854
|
+
const importsTailwind = (rel2) => /@import\s+["']tailwindcss["']/.test(fs2.readFileSync(path2.join(p.root, rel2), "utf8"));
|
|
855
|
+
for (const rel2 of CSS_CANDIDATES) {
|
|
856
|
+
if (fs2.existsSync(path2.join(p.root, rel2)) && importsTailwind(rel2)) return rel2;
|
|
857
|
+
}
|
|
858
|
+
for (const rel2 of walk(p.root, (f) => f.endsWith(".css"), 4)) {
|
|
859
|
+
if (importsTailwind(rel2)) return rel2;
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
var LAYOUT_CANDIDATES = ["app/layout.tsx", "src/app/layout.tsx", "app/layout.jsx", "src/app/layout.jsx", "app/root.tsx", "src/main.tsx", "src/App.tsx"];
|
|
864
|
+
function findLayout(root) {
|
|
865
|
+
return LAYOUT_CANDIDATES.find((rel2) => fs2.existsSync(path2.join(root, rel2))) ?? null;
|
|
866
|
+
}
|
|
867
|
+
function defaultComponentsDir(root) {
|
|
868
|
+
return fs2.existsSync(path2.join(root, "src")) ? "src/components/ui" : "components/ui";
|
|
869
|
+
}
|
|
870
|
+
function walk(root, match, depth = 6) {
|
|
871
|
+
const out = [];
|
|
872
|
+
const skip = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out", ".turbo", ".vercel", "coverage"]);
|
|
873
|
+
const go = (dir, d) => {
|
|
874
|
+
if (d > depth) return;
|
|
875
|
+
let entries = [];
|
|
876
|
+
try {
|
|
877
|
+
entries = fs2.readdirSync(path2.join(root, dir), { withFileTypes: true });
|
|
878
|
+
} catch {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
for (const e of entries) {
|
|
882
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
883
|
+
const rel2 = dir ? `${dir}/${e.name}` : e.name;
|
|
884
|
+
if (e.isDirectory()) {
|
|
885
|
+
if (!skip.has(e.name)) go(rel2, d + 1);
|
|
886
|
+
} else if (match(rel2)) out.push(rel2);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
go("", 0);
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
function sourcePathFor(cssRel) {
|
|
893
|
+
const fromDir = path2.dirname(cssRel);
|
|
894
|
+
const rel2 = path2.relative(fromDir, `node_modules/${PKG}`).split(path2.sep).join("/");
|
|
895
|
+
return rel2.startsWith(".") ? rel2 : `./${rel2}`;
|
|
896
|
+
}
|
|
897
|
+
function inspectCss(root, cssRel) {
|
|
898
|
+
const css = fs2.readFileSync(path2.join(root, cssRel), "utf8");
|
|
899
|
+
const tailwind = /@import\s+["']tailwindcss["']/.test(css);
|
|
900
|
+
const styles = css.includes(`${PKG}/styles.css`) ? "package" : /@import\s+["'][^"']*styles\.css["']/.test(css) ? "local" : null;
|
|
901
|
+
const m = css.match(new RegExp(`@source\\s+["']([^"']*${PKG.replace("/", "\\/")}[^"']*)["']`));
|
|
902
|
+
const source = m ? { line: m[1], ok: fs2.existsSync(path2.resolve(root, path2.dirname(cssRel), m[1])) } : null;
|
|
903
|
+
return { tailwind, styles, source };
|
|
904
|
+
}
|
|
905
|
+
function patchCss(text, cssRel, mode, localStylesRel) {
|
|
906
|
+
let out = text;
|
|
907
|
+
const tailwindLine = out.match(/@import\s+["']tailwindcss["'][^;]*;\n?/);
|
|
908
|
+
const insertAfterTailwind = (line) => {
|
|
909
|
+
if (tailwindLine) {
|
|
910
|
+
const at = out.indexOf(tailwindLine[0]) + tailwindLine[0].length;
|
|
911
|
+
out = out.slice(0, at) + (tailwindLine[0].endsWith("\n") ? "" : "\n") + line + "\n" + out.slice(at);
|
|
912
|
+
} else {
|
|
913
|
+
out = `@import "tailwindcss";
|
|
914
|
+
${line}
|
|
915
|
+
` + out;
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
if (mode === "package") {
|
|
919
|
+
if (!out.includes(`${PKG}/styles.css`)) insertAfterTailwind(`@import "${PKG}/styles.css";`);
|
|
920
|
+
const want = sourcePathFor(cssRel);
|
|
921
|
+
const existing = out.match(new RegExp(`@source\\s+["']([^"']*${PKG.replace("/", "\\/")}[^"']*)["'];?`));
|
|
922
|
+
if (!existing) {
|
|
923
|
+
const anchor = out.match(new RegExp(`@import\\s+["']${PKG.replace("/", "\\/")}\\/styles\\.css["'];?\\n?`));
|
|
924
|
+
const at = anchor ? out.indexOf(anchor[0]) + anchor[0].length : 0;
|
|
925
|
+
out = out.slice(0, at) + `
|
|
926
|
+
/* let tailwind see the classes ${PKG} uses */
|
|
927
|
+
@source "${want}";
|
|
928
|
+
` + out.slice(at);
|
|
929
|
+
} else if (existing[1] !== want) {
|
|
930
|
+
out = out.replace(existing[0], `@source "${want}";`);
|
|
931
|
+
}
|
|
932
|
+
} else if (localStylesRel) {
|
|
933
|
+
const rel2 = path2.relative(path2.dirname(cssRel), localStylesRel).split(path2.sep).join("/");
|
|
934
|
+
const spec = rel2.startsWith(".") ? rel2 : `./${rel2}`;
|
|
935
|
+
if (!out.includes(spec)) insertAfterTailwind(`@import "${spec}";`);
|
|
936
|
+
}
|
|
937
|
+
return out === text ? null : out;
|
|
938
|
+
}
|
|
939
|
+
function writeFile(root, rel2, text, dryRun) {
|
|
940
|
+
if (dryRun) return;
|
|
941
|
+
const abs = path2.join(root, rel2);
|
|
942
|
+
fs2.mkdirSync(path2.dirname(abs), { recursive: true });
|
|
943
|
+
fs2.writeFileSync(abs, text);
|
|
944
|
+
}
|
|
945
|
+
function patchLayout(text, needFont, needToasts) {
|
|
946
|
+
let out = text;
|
|
947
|
+
const lastImport = [...out.matchAll(/^import[^;]*;[ \t]*$/gm)].pop();
|
|
948
|
+
if (!lastImport) return null;
|
|
949
|
+
const imports = [];
|
|
950
|
+
const decls = [];
|
|
951
|
+
if (needFont) {
|
|
952
|
+
const html = out.match(/<html\b[^>]*>/);
|
|
953
|
+
if (!html) return null;
|
|
954
|
+
let tag = html[0];
|
|
955
|
+
const literal = tag.match(/className="([^"]*)"/);
|
|
956
|
+
const expr = tag.match(/className=\{([^}]*)\}/);
|
|
957
|
+
if (literal) tag = tag.replace(literal[0], `className={\`\${archivo.variable} ${literal[1]}\`}`);
|
|
958
|
+
else if (expr) tag = tag.replace(expr[0], `className={\`\${archivo.variable} \${${expr[1].trim()}}\`}`);
|
|
959
|
+
else tag = tag.replace(/<html\b/, "<html className={archivo.variable}");
|
|
960
|
+
out = out.replace(html[0], tag);
|
|
961
|
+
if (!/from\s+["']next\/font\/google["']/.test(out)) imports.push(`import { Archivo } from "next/font/google";`);
|
|
962
|
+
else if (!/\bArchivo\b/.test(out)) return null;
|
|
963
|
+
decls.push(`const archivo = Archivo({ subsets: ["latin"], variable: "--font-archivo" });`);
|
|
964
|
+
}
|
|
965
|
+
if (needToasts) {
|
|
966
|
+
const body = out.match(/<body\b[^>]*>([\s\S]*?)<\/body>/);
|
|
967
|
+
if (!body || !body[1].includes("{children}")) return null;
|
|
968
|
+
out = out.replace(body[0], body[0].replace("{children}", "<ToastProvider>{children}</ToastProvider>"));
|
|
969
|
+
imports.push(`import { ToastProvider } from "${PKG}";`);
|
|
970
|
+
}
|
|
971
|
+
if (!imports.length && !decls.length && out === text) return null;
|
|
972
|
+
const at = out.indexOf(lastImport[0]) + lastImport[0].length;
|
|
973
|
+
const head = imports.length ? "\n" + imports.join("\n") : "";
|
|
974
|
+
const tail = decls.length ? "\n\n" + decls.join("\n") : "";
|
|
975
|
+
out = out.slice(0, at) + head + tail + out.slice(at);
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// cli/index.ts
|
|
980
|
+
function parse(argv) {
|
|
981
|
+
const flags = { yes: false, dryRun: false, overwrite: false, fix: false, install: true, cwd: process.cwd() };
|
|
982
|
+
const rest = [];
|
|
983
|
+
for (let i = 0; i < argv.length; i++) {
|
|
984
|
+
const a = argv[i];
|
|
985
|
+
if (a === "-y" || a === "--yes") flags.yes = true;
|
|
986
|
+
else if (a === "--dry-run") flags.dryRun = true;
|
|
987
|
+
else if (a === "--overwrite") flags.overwrite = true;
|
|
988
|
+
else if (a === "--fix") flags.fix = true;
|
|
989
|
+
else if (a === "--no-install") flags.install = false;
|
|
990
|
+
else if (a === "--cwd") flags.cwd = path3.resolve(argv[++i] ?? ".");
|
|
991
|
+
else if (a.startsWith("--cwd=")) flags.cwd = path3.resolve(a.slice(6));
|
|
992
|
+
else if (a === "--dir") flags.dir = argv[++i];
|
|
993
|
+
else if (a.startsWith("--dir=")) flags.dir = a.slice(6);
|
|
994
|
+
else rest.push(a);
|
|
995
|
+
}
|
|
996
|
+
return { command: rest[0], args: rest.slice(1), flags };
|
|
997
|
+
}
|
|
998
|
+
var HELP = `
|
|
999
|
+
${c.bold("blurrow")} ${c.gray(`v${"0.2.0"}`)} ${c.gray("tactile react components")}
|
|
1000
|
+
|
|
1001
|
+
${c.bold("usage")}
|
|
1002
|
+
npx @blurrow/ui ${c.cyan("<command>")} [options] ${c.gray("or, once installed: npx blurrow <command>")}
|
|
1003
|
+
|
|
1004
|
+
${c.bold("commands")}
|
|
1005
|
+
${c.cyan("init")} set up this project: install, styles, font, config
|
|
1006
|
+
${c.cyan("add")} [names\u2026] copy components' source into your project ${c.gray("(picker if no names)")}
|
|
1007
|
+
${c.cyan("list")} every component, and which ones you've copied
|
|
1008
|
+
${c.cyan("diff")} [name] what you've changed in copied components
|
|
1009
|
+
${c.cyan("doctor")} check the setup and say how to fix anything off
|
|
1010
|
+
|
|
1011
|
+
${c.bold("options")}
|
|
1012
|
+
-y, --yes accept the defaults, don't ask
|
|
1013
|
+
--dry-run show what would change, write nothing
|
|
1014
|
+
--cwd <path> run as if from another folder
|
|
1015
|
+
--dir <path> where copied components go ${c.gray("(default: components/ui)")}
|
|
1016
|
+
--overwrite replace copied files even if you've changed them
|
|
1017
|
+
--fix (doctor) repair the stylesheet setup
|
|
1018
|
+
--no-install (init) don't install anything
|
|
1019
|
+
-v, --version print the version
|
|
1020
|
+
|
|
1021
|
+
${c.bold("examples")}
|
|
1022
|
+
npx @blurrow/ui init
|
|
1023
|
+
npx @blurrow/ui add button tabs toast
|
|
1024
|
+
npx @blurrow/ui diff
|
|
1025
|
+
`;
|
|
1026
|
+
function header(title) {
|
|
1027
|
+
log.line(`
|
|
1028
|
+
${c.bold("blurrow")} ${c.gray("\xB7")} ${title}${c.gray(` v${"0.2.0"}`)}`);
|
|
1029
|
+
}
|
|
1030
|
+
function requireProject(flags) {
|
|
1031
|
+
const p = useProject(readProject(flags.cwd));
|
|
1032
|
+
if (!p.pkg) {
|
|
1033
|
+
log.line();
|
|
1034
|
+
log.fail(`no package.json in ${c.bold(rel(flags.cwd))}`);
|
|
1035
|
+
log.hint("run this inside your app, or point at it with --cwd <path>");
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
return p;
|
|
1039
|
+
}
|
|
1040
|
+
var rel = (abs) => path3.relative(process.cwd(), abs) || ".";
|
|
1041
|
+
var bin = `npx ${PKG}`;
|
|
1042
|
+
var useProject = (p) => {
|
|
1043
|
+
bin = p.deps[PKG] && fs3.existsSync(path3.join(p.root, "node_modules", PKG)) ? "npx blurrow" : `npx ${PKG}`;
|
|
1044
|
+
return p;
|
|
1045
|
+
};
|
|
1046
|
+
function componentsDir(p, flags) {
|
|
1047
|
+
return flags.dir ?? p.config?.componentsDir ?? defaultComponentsDir(p.root);
|
|
1048
|
+
}
|
|
1049
|
+
function run(cmd, flags, cwd) {
|
|
1050
|
+
log.info(c.gray(cmd.join(" ")));
|
|
1051
|
+
if (flags.dryRun) return true;
|
|
1052
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
1053
|
+
return r.status === 0;
|
|
1054
|
+
}
|
|
1055
|
+
function printHunks(lines) {
|
|
1056
|
+
for (const l of lines) {
|
|
1057
|
+
if (!l) log.line(c.gray(" \u22EF"));
|
|
1058
|
+
else if (l.kind === "-") log.line(c.red(` - ${l.text}`));
|
|
1059
|
+
else if (l.kind === "+") log.line(c.green(` + ${l.text}`));
|
|
1060
|
+
else log.line(c.gray(` ${l.text}`));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function importPathFor(p, dir, file) {
|
|
1064
|
+
try {
|
|
1065
|
+
const raw = fs3.readFileSync(path3.join(p.root, "tsconfig.json"), "utf8").replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
|
|
1066
|
+
const paths = JSON.parse(raw)?.compilerOptions?.paths ?? {};
|
|
1067
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
1068
|
+
const prefix = alias.replace(/\*$/, "");
|
|
1069
|
+
const target = (targets[0] ?? "").replace(/^\.\//, "").replace(/\*$/, "");
|
|
1070
|
+
if (dir.startsWith(target)) return `${prefix}${dir.slice(target.length)}/${file}`.replace(/\/+/g, "/");
|
|
1071
|
+
}
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
return `./${dir}/${file}`;
|
|
1075
|
+
}
|
|
1076
|
+
var LAYOUT_SNIPPET = (isNext) => isNext ? `import { Archivo } from "next/font/google";
|
|
1077
|
+
import { ToastProvider } from "${PKG}";
|
|
1078
|
+
|
|
1079
|
+
const archivo = Archivo({ subsets: ["latin"], variable: "--font-archivo" });
|
|
1080
|
+
|
|
1081
|
+
// on <html>: className={archivo.variable}
|
|
1082
|
+
// in <body>: <ToastProvider>{children}</ToastProvider> (only if you use toasts)` : `<!-- in your html <head> -->
|
|
1083
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&display=swap" />
|
|
1084
|
+
<style>:root { --font-archivo: "Archivo"; }</style>
|
|
1085
|
+
|
|
1086
|
+
// around your app, if you use toasts:
|
|
1087
|
+
import { ToastProvider } from "${PKG}";
|
|
1088
|
+
<ToastProvider><App /></ToastProvider>`;
|
|
1089
|
+
async function init(flags) {
|
|
1090
|
+
header("init");
|
|
1091
|
+
const p = requireProject(flags);
|
|
1092
|
+
const isNext = Boolean(p.deps.next);
|
|
1093
|
+
const ask = (q, d = true) => confirm(q, d, flags.yes);
|
|
1094
|
+
log.step("project");
|
|
1095
|
+
log.ok(`${c.bold(String(p.pkg?.name ?? path3.basename(p.root)))} ${c.gray(`\xB7 ${isNext ? "next.js" : "react"} \xB7 ${p.pm}`)}`);
|
|
1096
|
+
const react = installedMajor(p.root, "react", p.deps.react);
|
|
1097
|
+
if (react === null) log.warn("react isn't installed here");
|
|
1098
|
+
else if (react < 19) log.warn(`react ${react} found; ${PKG} is built for react 19`);
|
|
1099
|
+
else log.ok(`react ${react}`);
|
|
1100
|
+
const tw = installedMajor(p.root, "tailwindcss", p.deps.tailwindcss);
|
|
1101
|
+
if (tw !== null && tw >= 4) log.ok(`tailwind css ${tw}`);
|
|
1102
|
+
else {
|
|
1103
|
+
log.warn(tw === null ? "tailwind css isn't installed" : `tailwind css ${tw} found; v4 is needed`);
|
|
1104
|
+
const pkgs = isNext ? ["tailwindcss@4", "@tailwindcss/postcss@4"] : ["tailwindcss@4"];
|
|
1105
|
+
if (flags.install && await ask("install tailwind css v4 now?")) {
|
|
1106
|
+
if (!run(installCommand(p.pm, pkgs), flags, p.root)) log.fail("couldn't install tailwind; try it yourself and run init again");
|
|
1107
|
+
} else log.hint(`later: ${installCommand(p.pm, pkgs).join(" ")}`);
|
|
1108
|
+
}
|
|
1109
|
+
if (p.deps[PKG]) log.ok(`${PKG} ${c.gray(p.deps[PKG])}`);
|
|
1110
|
+
else if (flags.install && await ask(`install ${PKG}?`)) {
|
|
1111
|
+
if (!run(installCommand(p.pm, [PKG]), flags, p.root)) {
|
|
1112
|
+
log.fail(`install failed; run ${installCommand(p.pm, [PKG]).join(" ")} and try again`);
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
log.ok(`installed ${PKG}`);
|
|
1116
|
+
} else log.hint(`later: ${installCommand(p.pm, [PKG]).join(" ")}`);
|
|
1117
|
+
log.step("styles");
|
|
1118
|
+
const css = findCss(p);
|
|
1119
|
+
if (!css) {
|
|
1120
|
+
log.warn("couldn't find the stylesheet that imports tailwind");
|
|
1121
|
+
log.hint(`add these lines to it yourself:`);
|
|
1122
|
+
log.line(c.cyan(`
|
|
1123
|
+
@import "tailwindcss";
|
|
1124
|
+
@import "${PKG}/styles.css";
|
|
1125
|
+
@source "<path to>/node_modules/${PKG}";
|
|
1126
|
+
`));
|
|
1127
|
+
} else {
|
|
1128
|
+
const before = fs3.readFileSync(path3.join(p.root, css), "utf8");
|
|
1129
|
+
const after = patchCss(before, css, "package");
|
|
1130
|
+
if (!after) log.ok(`${css} is already set up`);
|
|
1131
|
+
else {
|
|
1132
|
+
log.info(`${c.bold(css)} needs:`);
|
|
1133
|
+
printHunks(hunks(diffLines(before, after), 1));
|
|
1134
|
+
if (await ask(`update ${css}?`)) {
|
|
1135
|
+
writeFile(p.root, css, after, flags.dryRun);
|
|
1136
|
+
log.ok(`updated ${css}`);
|
|
1137
|
+
} else log.hint(`skipped; run ${bin} doctor --fix whenever you like`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
log.step("font & toasts");
|
|
1141
|
+
const layout = findLayout(p.root);
|
|
1142
|
+
const layoutText = layout ? fs3.readFileSync(path3.join(p.root, layout), "utf8") : "";
|
|
1143
|
+
const hasFont = layoutText.includes("--font-archivo");
|
|
1144
|
+
const hasToasts = layoutText.includes("ToastProvider");
|
|
1145
|
+
if (hasFont) log.ok(`archivo is loaded in ${layout}`);
|
|
1146
|
+
if (hasToasts) log.ok(`ToastProvider is in ${layout}`);
|
|
1147
|
+
let layoutDone = hasFont && hasToasts;
|
|
1148
|
+
if (!layoutDone && layout && isNext) {
|
|
1149
|
+
const patched = patchLayout(layoutText, !hasFont, !hasToasts);
|
|
1150
|
+
if (patched) {
|
|
1151
|
+
log.info(`${c.bold(layout)} needs:`);
|
|
1152
|
+
printHunks(hunks(diffLines(layoutText, patched), 1));
|
|
1153
|
+
if (await ask(`update ${layout}?`)) {
|
|
1154
|
+
writeFile(p.root, layout, patched, flags.dryRun);
|
|
1155
|
+
log.ok(`updated ${layout}`);
|
|
1156
|
+
layoutDone = true;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (!layoutDone) {
|
|
1161
|
+
log.info(`add this to ${c.bold(layout ?? "your root layout")}:`);
|
|
1162
|
+
log.line(
|
|
1163
|
+
"\n" + LAYOUT_SNIPPET(isNext).split("\n").map((l) => c.cyan(` ${l}`)).join("\n") + "\n"
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
const config = { componentsDir: componentsDir(p, flags), ...css ? { css } : {} };
|
|
1167
|
+
const cfgText = JSON.stringify(config, null, 2) + "\n";
|
|
1168
|
+
const cfgPath = path3.join(p.root, CONFIG_FILE);
|
|
1169
|
+
if (!fs3.existsSync(cfgPath) || fs3.readFileSync(cfgPath, "utf8") !== cfgText) {
|
|
1170
|
+
writeFile(p.root, CONFIG_FILE, cfgText, flags.dryRun);
|
|
1171
|
+
log.step("config");
|
|
1172
|
+
log.ok(`wrote ${CONFIG_FILE} ${c.gray(`(components go to ${config.componentsDir})`)}`);
|
|
1173
|
+
}
|
|
1174
|
+
log.line(`
|
|
1175
|
+
${c.bold("all set!")} ${flags.dryRun ? c.yellow("(dry run, nothing was written)") : ""}`);
|
|
1176
|
+
log.line(` ${c.gray("use it:")} import { Button } from "${PKG}";`);
|
|
1177
|
+
log.line(` ${c.gray("own it:")} ${bin} add button`);
|
|
1178
|
+
log.line(` ${c.gray("check it:")} ${bin} doctor
|
|
1179
|
+
`);
|
|
1180
|
+
}
|
|
1181
|
+
async function add(names, flags) {
|
|
1182
|
+
header("add");
|
|
1183
|
+
const p = requireProject(flags);
|
|
1184
|
+
const dir = componentsDir(p, flags);
|
|
1185
|
+
if (!names.length) {
|
|
1186
|
+
if (!interactive) {
|
|
1187
|
+
log.fail(`which components? e.g. ${bin} add button tabs`);
|
|
1188
|
+
process.exit(1);
|
|
1189
|
+
}
|
|
1190
|
+
log.line();
|
|
1191
|
+
names = await multiselect(
|
|
1192
|
+
"pick components to copy",
|
|
1193
|
+
registry.map((e) => ({
|
|
1194
|
+
value: e.slug,
|
|
1195
|
+
label: e.slug,
|
|
1196
|
+
group: e.category,
|
|
1197
|
+
hint: e.description
|
|
1198
|
+
}))
|
|
1199
|
+
);
|
|
1200
|
+
if (!names.length) {
|
|
1201
|
+
log.line(c.gray("\n nothing picked, nothing copied.\n"));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const unknown = names.filter((n) => !findEntry(n));
|
|
1206
|
+
if (unknown.length) {
|
|
1207
|
+
log.line();
|
|
1208
|
+
for (const n of unknown) {
|
|
1209
|
+
const s = suggest(n);
|
|
1210
|
+
log.fail(`no component called ${c.bold(n)}${s.length ? c.gray(`. did you mean ${s.map((x) => c.cyan(x)).join(", ")}?`) : ""}`);
|
|
1211
|
+
}
|
|
1212
|
+
log.hint(`see them all with: ${bin} list`);
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
const entries = [...new Set(names.map((n) => findEntry(n)))];
|
|
1216
|
+
const files = withDependencies(entries.map((e) => e.file));
|
|
1217
|
+
const extra = files.length - entries.length;
|
|
1218
|
+
log.line();
|
|
1219
|
+
log.info(
|
|
1220
|
+
`copying ${c.bold(entries.map((e) => e.slug).join(", "))}${extra > 0 ? c.gray(` + ${extra} file${extra > 1 ? "s" : ""} they need`) : ""} into ${c.bold(dir)}`
|
|
1221
|
+
);
|
|
1222
|
+
const result = { added: [], updated: [], same: [], skipped: [] };
|
|
1223
|
+
for (const f of files) {
|
|
1224
|
+
const target = `${dir}/${f.name}`;
|
|
1225
|
+
const abs = path3.join(p.root, target);
|
|
1226
|
+
if (fs3.existsSync(abs)) {
|
|
1227
|
+
const current = fs3.readFileSync(abs, "utf8");
|
|
1228
|
+
if (current === f.code) {
|
|
1229
|
+
result.same.push(f.name);
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
let decision = flags.overwrite ? "overwrite" : "skip";
|
|
1233
|
+
while (!flags.overwrite && interactive && !flags.yes) {
|
|
1234
|
+
decision = await choose(`${c.bold(target)} has your changes.`, [
|
|
1235
|
+
{ key: "skip", label: "skip" },
|
|
1236
|
+
{ key: "overwrite", label: "overwrite" },
|
|
1237
|
+
{ key: "diff", label: "diff" }
|
|
1238
|
+
], "skip");
|
|
1239
|
+
if (decision !== "diff") break;
|
|
1240
|
+
printHunks(hunks(diffLines(current, f.code)));
|
|
1241
|
+
}
|
|
1242
|
+
if (decision === "overwrite") {
|
|
1243
|
+
writeFile(p.root, target, f.code, flags.dryRun);
|
|
1244
|
+
result.updated.push(f.name);
|
|
1245
|
+
} else result.skipped.push(f.name);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
writeFile(p.root, target, f.code, flags.dryRun);
|
|
1249
|
+
result.added.push(f.name);
|
|
1250
|
+
}
|
|
1251
|
+
const css = findCss(p);
|
|
1252
|
+
const cssState = css ? inspectCss(p.root, css) : null;
|
|
1253
|
+
if (!cssState?.styles) {
|
|
1254
|
+
const stylesRel = `${dir}/styles.css`;
|
|
1255
|
+
if (!fs3.existsSync(path3.join(p.root, stylesRel))) {
|
|
1256
|
+
writeFile(p.root, stylesRel, fs3.readFileSync(path3.join(SOURCE_DIR, "styles.css"), "utf8"), flags.dryRun);
|
|
1257
|
+
result.added.push("styles.css");
|
|
1258
|
+
}
|
|
1259
|
+
if (css) {
|
|
1260
|
+
const patched = patchCss(fs3.readFileSync(path3.join(p.root, css), "utf8"), css, "local", stylesRel);
|
|
1261
|
+
if (patched) {
|
|
1262
|
+
writeFile(p.root, css, patched, flags.dryRun);
|
|
1263
|
+
log.ok(`imported the tokens in ${css}`);
|
|
1264
|
+
}
|
|
1265
|
+
} else log.warn(`import ${stylesRel} in your global stylesheet, right after tailwind`);
|
|
1266
|
+
}
|
|
1267
|
+
log.line();
|
|
1268
|
+
for (const n of result.added) log.ok(`${c.green("added")} ${dir}/${n}`);
|
|
1269
|
+
for (const n of result.updated) log.ok(`${c.yellow("updated")} ${dir}/${n}`);
|
|
1270
|
+
for (const n of result.same) log.line(` ${sym.dot} ${c.gray(`same ${dir}/${n}`)}`);
|
|
1271
|
+
for (const n of result.skipped) log.warn(`${c.yellow("skipped")} ${dir}/${n} ${c.gray("(kept your changes; --overwrite to replace)")}`);
|
|
1272
|
+
const first = entries[0];
|
|
1273
|
+
log.line(`
|
|
1274
|
+
${c.gray("import it:")} import { ${first.file} } from "${importPathFor(p, dir, first.file)}";`);
|
|
1275
|
+
if (flags.dryRun) log.line(` ${c.yellow("dry run, nothing was written")}`);
|
|
1276
|
+
log.line();
|
|
1277
|
+
}
|
|
1278
|
+
function list(flags) {
|
|
1279
|
+
const p = useProject(readProject(flags.cwd));
|
|
1280
|
+
const dir = p.pkg ? componentsDir(p, flags) : null;
|
|
1281
|
+
const copied = (file) => dir && [".tsx", ".ts"].some((ext) => fs3.existsSync(path3.join(p.root, dir, file + ext)));
|
|
1282
|
+
log.line(`
|
|
1283
|
+
${c.bold("blurrow")} ${c.gray(`\xB7 ${registry.length} components`)}
|
|
1284
|
+
`);
|
|
1285
|
+
const width = Math.max(...registry.map((e) => e.slug.length)) + 2;
|
|
1286
|
+
for (const cat of categories) {
|
|
1287
|
+
log.line(` ${c.bold(cat)}`);
|
|
1288
|
+
for (const e of registry.filter((x) => x.category === cat)) {
|
|
1289
|
+
const mark = copied(e.file) ? c.green("\u25CF") : " ";
|
|
1290
|
+
const desc = e.description.length > 64 ? e.description.slice(0, 63) + "\u2026" : e.description;
|
|
1291
|
+
log.line(` ${mark} ${c.cyan(e.slug.padEnd(width))}${c.gray(desc)}`);
|
|
1292
|
+
}
|
|
1293
|
+
log.line();
|
|
1294
|
+
}
|
|
1295
|
+
log.line(c.gray(` ${c.green("\u25CF")} copied into ${dir ?? "your project"} \xB7 ${bin} add <name>
|
|
1296
|
+
`));
|
|
1297
|
+
}
|
|
1298
|
+
function diff(names, flags) {
|
|
1299
|
+
header("diff");
|
|
1300
|
+
const p = requireProject(flags);
|
|
1301
|
+
const dir = componentsDir(p, flags);
|
|
1302
|
+
const copied = registry.filter((e) => [".tsx", ".ts"].some((ext) => fs3.existsSync(path3.join(p.root, dir, e.file + ext))));
|
|
1303
|
+
let targets = copied;
|
|
1304
|
+
if (names.length) {
|
|
1305
|
+
targets = [];
|
|
1306
|
+
for (const n of names) {
|
|
1307
|
+
const e = findEntry(n);
|
|
1308
|
+
if (!e) {
|
|
1309
|
+
const s = suggest(n);
|
|
1310
|
+
log.fail(`no component called ${c.bold(n)}${s.length ? c.gray(`. did you mean ${s.join(", ")}?`) : ""}`);
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
if (!copied.includes(e)) {
|
|
1314
|
+
log.fail(`${e.slug} isn't copied into ${dir}`);
|
|
1315
|
+
log.hint(`copy it with: ${bin} add ${e.slug}`);
|
|
1316
|
+
process.exit(1);
|
|
1317
|
+
}
|
|
1318
|
+
targets.push(e);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (!targets.length) {
|
|
1322
|
+
log.line();
|
|
1323
|
+
log.info(`no copied components in ${dir}`);
|
|
1324
|
+
log.hint(`if you use the package, there's nothing to diff. to own a component: ${bin} add <name>`);
|
|
1325
|
+
log.line();
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
let changed = 0;
|
|
1329
|
+
log.line();
|
|
1330
|
+
for (const e of targets) {
|
|
1331
|
+
const shipped = sourceFile(e.file);
|
|
1332
|
+
const local = fs3.readFileSync(path3.join(p.root, dir, shipped.name), "utf8");
|
|
1333
|
+
if (local === shipped.code) {
|
|
1334
|
+
log.ok(`${e.slug} ${c.gray("matches v0.2.0")}`);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
changed++;
|
|
1338
|
+
const lines = diffLines(shipped.code, local);
|
|
1339
|
+
const plus = lines.filter((l) => l.kind === "+").length;
|
|
1340
|
+
const minus = lines.filter((l) => l.kind === "-").length;
|
|
1341
|
+
log.warn(`${c.bold(e.slug)} ${c.green(`+${plus}`)} ${c.red(`-${minus}`)} ${c.gray(`vs v${"0.2.0"}`)}`);
|
|
1342
|
+
if (names.length) printHunks(hunks(lines));
|
|
1343
|
+
}
|
|
1344
|
+
log.line();
|
|
1345
|
+
if (changed && !names.length) log.hint(`see the changes in one: ${bin} diff <name>`);
|
|
1346
|
+
if (changed) log.hint(`take the shipped version: ${bin} add <name> --overwrite`);
|
|
1347
|
+
log.line();
|
|
1348
|
+
}
|
|
1349
|
+
function doctor(flags) {
|
|
1350
|
+
header("doctor");
|
|
1351
|
+
const p = requireProject(flags);
|
|
1352
|
+
let problems = 0;
|
|
1353
|
+
let warnings = 0;
|
|
1354
|
+
const fail = (msg, fix) => {
|
|
1355
|
+
problems++;
|
|
1356
|
+
log.fail(msg);
|
|
1357
|
+
if (fix) log.hint(fix);
|
|
1358
|
+
};
|
|
1359
|
+
const warn = (msg, fix) => {
|
|
1360
|
+
warnings++;
|
|
1361
|
+
log.warn(msg);
|
|
1362
|
+
if (fix) log.hint(fix);
|
|
1363
|
+
};
|
|
1364
|
+
log.step("dependencies");
|
|
1365
|
+
const react = installedMajor(p.root, "react", p.deps.react);
|
|
1366
|
+
if (react === null) fail("react isn't installed", installCommand(p.pm, ["react", "react-dom"]).join(" "));
|
|
1367
|
+
else if (react < 19) fail(`react ${react}; the kit needs react 19`, installCommand(p.pm, ["react@19", "react-dom@19"]).join(" "));
|
|
1368
|
+
else log.ok(`react ${react}`);
|
|
1369
|
+
const tw = installedMajor(p.root, "tailwindcss", p.deps.tailwindcss);
|
|
1370
|
+
if (tw === null) fail("tailwind css isn't installed", installCommand(p.pm, ["tailwindcss@4"]).join(" "));
|
|
1371
|
+
else if (tw < 4) fail(`tailwind css ${tw}; the kit needs v4`, "see https://tailwindcss.com/docs/upgrade-guide");
|
|
1372
|
+
else log.ok(`tailwind css ${tw}`);
|
|
1373
|
+
const dir = componentsDir(p, flags);
|
|
1374
|
+
const usesPackage = Boolean(p.deps[PKG]);
|
|
1375
|
+
const copiedCount = registry.filter((e) => [".tsx", ".ts"].some((x) => fs3.existsSync(path3.join(p.root, dir, e.file + x)))).length;
|
|
1376
|
+
if (usesPackage) {
|
|
1377
|
+
const installed = fs3.existsSync(path3.join(p.root, "node_modules", PKG));
|
|
1378
|
+
if (installed) log.ok(`${PKG} ${c.gray(p.deps[PKG])}`);
|
|
1379
|
+
else fail(`${PKG} is in package.json but not installed`, `${p.pm} install`);
|
|
1380
|
+
} else if (copiedCount) log.ok(`${copiedCount} copied component${copiedCount > 1 ? "s" : ""} in ${dir}`);
|
|
1381
|
+
else fail(`${PKG} isn't installed and no components are copied`, `npx ${PKG} init`);
|
|
1382
|
+
log.step("styles");
|
|
1383
|
+
const css = findCss(p);
|
|
1384
|
+
let cssProblems = 0;
|
|
1385
|
+
if (!css) fail("no stylesheet imports tailwind", `add @import "tailwindcss"; to your global css, then run doctor again`);
|
|
1386
|
+
else {
|
|
1387
|
+
const s = inspectCss(p.root, css);
|
|
1388
|
+
let fixable = false;
|
|
1389
|
+
log.ok(`tailwind imported in ${css}`);
|
|
1390
|
+
if (s.styles === "package") log.ok(`tokens imported from ${PKG}/styles.css`);
|
|
1391
|
+
else if (s.styles === "local") log.ok("tokens imported from a local styles.css");
|
|
1392
|
+
else {
|
|
1393
|
+
fixable = true;
|
|
1394
|
+
cssProblems++;
|
|
1395
|
+
fail("the kit's tokens aren't imported", `add @import "${PKG}/styles.css"; after tailwind`);
|
|
1396
|
+
}
|
|
1397
|
+
if (usesPackage) {
|
|
1398
|
+
if (!s.source) {
|
|
1399
|
+
fixable = true;
|
|
1400
|
+
cssProblems++;
|
|
1401
|
+
fail("tailwind can't see the package's classes, so components render unstyled", `add @source "${sourcePathFor(css)}";`);
|
|
1402
|
+
} else if (!s.source.ok) {
|
|
1403
|
+
fixable = true;
|
|
1404
|
+
cssProblems++;
|
|
1405
|
+
fail(`@source "${s.source.line}" points at nothing`, `it should be @source "${sourcePathFor(css)}";`);
|
|
1406
|
+
} else log.ok(`tailwind scans ${s.source.line}`);
|
|
1407
|
+
}
|
|
1408
|
+
if (fixable && flags.fix) {
|
|
1409
|
+
const before = fs3.readFileSync(path3.join(p.root, css), "utf8");
|
|
1410
|
+
const mode = usesPackage ? "package" : "local";
|
|
1411
|
+
const after = patchCss(before, css, mode, mode === "local" ? `${dir}/styles.css` : void 0);
|
|
1412
|
+
if (after) {
|
|
1413
|
+
writeFile(p.root, css, after, flags.dryRun);
|
|
1414
|
+
problems -= cssProblems;
|
|
1415
|
+
log.ok(c.green(`fixed ${css}`) + (flags.dryRun ? c.yellow(" (dry run)") : ""));
|
|
1416
|
+
printHunks(hunks(diffLines(before, after), 1));
|
|
1417
|
+
}
|
|
1418
|
+
} else if (fixable) log.hint(`let doctor fix it: ${bin} doctor --fix`);
|
|
1419
|
+
}
|
|
1420
|
+
log.step("font & toasts");
|
|
1421
|
+
const layout = findLayout(p.root);
|
|
1422
|
+
const sources = walk(p.root, (f) => /\.(tsx|jsx|ts|js|html)$/.test(f));
|
|
1423
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1424
|
+
const read = (f) => {
|
|
1425
|
+
if (!cache.has(f)) cache.set(f, fs3.readFileSync(path3.join(p.root, f), "utf8"));
|
|
1426
|
+
return cache.get(f);
|
|
1427
|
+
};
|
|
1428
|
+
const fontFile = sources.find((f) => read(f).includes("--font-archivo"));
|
|
1429
|
+
if (fontFile) log.ok(`archivo loaded in ${fontFile}`);
|
|
1430
|
+
else warn("archivo isn't loaded, so the kit falls back to the system font", `load it as --font-archivo in ${layout ?? "your root layout"} (${bin} init prints the snippet)`);
|
|
1431
|
+
const usesToast = sources.find((f) => /\buseToast\s*\(/.test(read(f)) && !f.includes(dir));
|
|
1432
|
+
const hasProvider = sources.some((f) => /<ToastProvider/.test(read(f)) && !f.includes(dir));
|
|
1433
|
+
if (usesToast && !hasProvider) fail(`${usesToast} calls useToast() but nothing renders <ToastProvider>`, `wrap your app in <ToastProvider> in ${layout ?? "your root layout"}`);
|
|
1434
|
+
else if (hasProvider) log.ok("ToastProvider is set up");
|
|
1435
|
+
else log.line(` ${sym.dot} ${c.gray("no toasts used yet")}`);
|
|
1436
|
+
log.line();
|
|
1437
|
+
if (!problems && !warnings) log.line(`${c.green(c.bold("all good!"))} everything's set up.
|
|
1438
|
+
`);
|
|
1439
|
+
else
|
|
1440
|
+
log.line(
|
|
1441
|
+
`${problems ? c.red(`${problems} problem${problems > 1 ? "s" : ""}`) : c.green("no problems")}${warnings ? c.gray(", ") + c.yellow(`${warnings} warning${warnings > 1 ? "s" : ""}`) : ""}
|
|
1442
|
+
`
|
|
1443
|
+
);
|
|
1444
|
+
if (problems) process.exitCode = 1;
|
|
1445
|
+
}
|
|
1446
|
+
async function main() {
|
|
1447
|
+
const { command, args, flags } = parse(process.argv.slice(2));
|
|
1448
|
+
if (command === "-v" || command === "--version") return log.line("0.2.0");
|
|
1449
|
+
if (!command || command === "help" || command === "-h" || command === "--help") return log.line(HELP);
|
|
1450
|
+
switch (command) {
|
|
1451
|
+
case "init":
|
|
1452
|
+
return init(flags);
|
|
1453
|
+
case "add":
|
|
1454
|
+
return add(args, flags);
|
|
1455
|
+
case "list":
|
|
1456
|
+
case "ls":
|
|
1457
|
+
return list(flags);
|
|
1458
|
+
case "diff":
|
|
1459
|
+
return diff(args, flags);
|
|
1460
|
+
case "doctor":
|
|
1461
|
+
return doctor(flags);
|
|
1462
|
+
default: {
|
|
1463
|
+
const known = ["init", "add", "list", "diff", "doctor"];
|
|
1464
|
+
const guess = known.find((k) => k.startsWith(command.slice(0, 2)));
|
|
1465
|
+
log.line();
|
|
1466
|
+
log.fail(`unknown command ${c.bold(command)}${guess ? c.gray(`. did you mean ${c.cyan(guess)}?`) : ""}`);
|
|
1467
|
+
log.hint(`${bin} --help`);
|
|
1468
|
+
log.line();
|
|
1469
|
+
process.exitCode = 1;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
main().catch((err) => {
|
|
1474
|
+
log.line();
|
|
1475
|
+
log.fail(err instanceof Error ? err.message : String(err));
|
|
1476
|
+
process.exitCode = 1;
|
|
1477
|
+
});
|