@mahiraltinkaya/me-ui 0.1.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 +99 -0
- package/bin/me-ui.js +60 -0
- package/package.json +34 -0
- package/registry/assets/breadcrumb_arrow.svg +3 -0
- package/registry/components/stepper-provider.tsx +88 -0
- package/registry/components/stepper-view.tsx +40 -0
- package/registry/components/steps/form-input.tsx +68 -0
- package/registry/components/steps/form-select.tsx +42 -0
- package/registry/components/steps/step-field.tsx +43 -0
- package/registry/components/ui/button.tsx +69 -0
- package/registry/components/ui/field-hint.tsx +68 -0
- package/registry/components/ui/input.tsx +87 -0
- package/registry/components/ui/select.tsx +128 -0
- package/registry/components/ui/stepper/index.tsx +24 -0
- package/registry/components/ui/stepper/step-divider.tsx +20 -0
- package/registry/components/ui/stepper/step-indicator.tsx +33 -0
- package/registry/components/ui/stepper/step-label.tsx +29 -0
- package/registry/components/ui/stepper/stepper-progress.tsx +40 -0
- package/registry/components/ui/stepper/stepper-rail.tsx +107 -0
- package/registry/components/ui/stepper/types.ts +26 -0
- package/registry/components/ui/tooltip.tsx +54 -0
- package/registry/lib/normalize.ts +2 -0
- package/registry/lib/quote-schema.ts +45 -0
- package/registry/lib/tckn.ts +31 -0
- package/registry/lib/utils.ts +6 -0
- package/registry.json +252 -0
- package/src/args.js +47 -0
- package/src/commands/add.js +118 -0
- package/src/commands/list.js +20 -0
- package/src/css.js +80 -0
- package/src/deps.js +52 -0
- package/src/jsonc.js +44 -0
- package/src/log.js +32 -0
- package/src/manifest.js +58 -0
- package/src/paths.js +65 -0
- package/src/project.js +100 -0
package/registry.json
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "me-ui",
|
|
3
|
+
"baseTokens": [
|
|
4
|
+
"--background",
|
|
5
|
+
"--foreground",
|
|
6
|
+
"--muted",
|
|
7
|
+
"--muted-foreground",
|
|
8
|
+
"--primary",
|
|
9
|
+
"--primary-foreground",
|
|
10
|
+
"--secondary",
|
|
11
|
+
"--secondary-foreground",
|
|
12
|
+
"--border",
|
|
13
|
+
"--input",
|
|
14
|
+
"--ring",
|
|
15
|
+
"--destructive",
|
|
16
|
+
"--radius"
|
|
17
|
+
],
|
|
18
|
+
"items": [
|
|
19
|
+
{
|
|
20
|
+
"name": "utils",
|
|
21
|
+
"title": "cn",
|
|
22
|
+
"description": "Class name merger — clsx piped through tailwind-merge.",
|
|
23
|
+
"dependencies": ["clsx", "tailwind-merge"],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"source": "src/lib/utils.ts",
|
|
27
|
+
"path": "lib/utils.ts"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "brand-theme",
|
|
33
|
+
"title": "Brand Theme",
|
|
34
|
+
"description": "The --brand token the fields paint their focus, error and accent states with.",
|
|
35
|
+
"cssVars": {
|
|
36
|
+
"theme": {
|
|
37
|
+
"--color-brand": "var(--brand)"
|
|
38
|
+
},
|
|
39
|
+
"light": {
|
|
40
|
+
"--brand": "#870052"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "field-hint",
|
|
46
|
+
"title": "Field Hint",
|
|
47
|
+
"description": "Explanatory panel anchored to a field's trailing info mark. Built on Tooltip so it never steals focus from the field that opened it.",
|
|
48
|
+
"dependencies": ["@base-ui/react", "lucide-react"],
|
|
49
|
+
"cssVars": {
|
|
50
|
+
"theme": {
|
|
51
|
+
"--color-ink": "var(--ink)"
|
|
52
|
+
},
|
|
53
|
+
"light": {
|
|
54
|
+
"--ink": "#0b1324"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"registryDependencies": ["utils", "brand-theme"],
|
|
58
|
+
"files": [
|
|
59
|
+
{
|
|
60
|
+
"source": "src/components/ui/field-hint.tsx",
|
|
61
|
+
"path": "components/ui/field-hint.tsx"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "input",
|
|
67
|
+
"title": "Input",
|
|
68
|
+
"description": "Text field with an optional leading icon and a focus-driven hint panel.",
|
|
69
|
+
"dependencies": ["@base-ui/react"],
|
|
70
|
+
"registryDependencies": ["utils", "field-hint"],
|
|
71
|
+
"files": [
|
|
72
|
+
{
|
|
73
|
+
"source": "src/components/ui/input.tsx",
|
|
74
|
+
"path": "components/ui/input.tsx"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "select",
|
|
80
|
+
"title": "Select",
|
|
81
|
+
"description": "Single-choice dropdown over a string list, with the same icon and hint affordances as Input.",
|
|
82
|
+
"dependencies": ["@base-ui/react", "lucide-react"],
|
|
83
|
+
"registryDependencies": ["utils", "field-hint"],
|
|
84
|
+
"files": [
|
|
85
|
+
{
|
|
86
|
+
"source": "src/components/ui/select.tsx",
|
|
87
|
+
"path": "components/ui/select.tsx"
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"name": "button",
|
|
93
|
+
"title": "Button",
|
|
94
|
+
"description": "Button with variant, size and shape sets built on class-variance-authority.",
|
|
95
|
+
"dependencies": ["@base-ui/react", "class-variance-authority"],
|
|
96
|
+
"registryDependencies": ["utils", "brand-theme"],
|
|
97
|
+
"cssVars": {
|
|
98
|
+
"theme": {
|
|
99
|
+
"--color-cta": "var(--cta)",
|
|
100
|
+
"--color-cta-hover": "var(--cta-hover)"
|
|
101
|
+
},
|
|
102
|
+
"light": {
|
|
103
|
+
"--cta": "#111c3a",
|
|
104
|
+
"--cta-hover": "#1c2b52"
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"files": [
|
|
108
|
+
{
|
|
109
|
+
"source": "src/components/ui/button.tsx",
|
|
110
|
+
"path": "components/ui/button.tsx"
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "tooltip",
|
|
116
|
+
"title": "Tooltip",
|
|
117
|
+
"description": "Tooltip primitives — provider, root, trigger and content.",
|
|
118
|
+
"dependencies": ["@base-ui/react"],
|
|
119
|
+
"registryDependencies": ["utils"],
|
|
120
|
+
"files": [
|
|
121
|
+
{
|
|
122
|
+
"source": "src/components/ui/tooltip.tsx",
|
|
123
|
+
"path": "components/ui/tooltip.tsx"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "stepper",
|
|
129
|
+
"title": "Stepper",
|
|
130
|
+
"description": "Progress rail for multi-step flows: a mobile progress bar plus a clickable desktop rail.",
|
|
131
|
+
"dependencies": ["next"],
|
|
132
|
+
"registryDependencies": ["utils", "brand-theme"],
|
|
133
|
+
"files": [
|
|
134
|
+
{
|
|
135
|
+
"source": "public/assets/images/breadcrumb_arrow.svg",
|
|
136
|
+
"path": "assets/breadcrumb_arrow.svg",
|
|
137
|
+
"target": "public/assets/images/breadcrumb_arrow.svg"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"source": "src/components/ui/stepper/index.tsx",
|
|
141
|
+
"path": "components/ui/stepper/index.tsx"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"source": "src/components/ui/stepper/types.ts",
|
|
145
|
+
"path": "components/ui/stepper/types.ts"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"source": "src/components/ui/stepper/step-divider.tsx",
|
|
149
|
+
"path": "components/ui/stepper/step-divider.tsx"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"source": "src/components/ui/stepper/step-indicator.tsx",
|
|
153
|
+
"path": "components/ui/stepper/step-indicator.tsx"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"source": "src/components/ui/stepper/step-label.tsx",
|
|
157
|
+
"path": "components/ui/stepper/step-label.tsx"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"source": "src/components/ui/stepper/stepper-progress.tsx",
|
|
161
|
+
"path": "components/ui/stepper/stepper-progress.tsx"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"source": "src/components/ui/stepper/stepper-rail.tsx",
|
|
165
|
+
"path": "components/ui/stepper/stepper-rail.tsx"
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"name": "stepper-provider",
|
|
171
|
+
"title": "Stepper Provider",
|
|
172
|
+
"description": "Step state behind a context — current index, next/back guards and a useStepper hook — plus the view that renders the rail and the active step.",
|
|
173
|
+
"registryDependencies": ["utils", "stepper"],
|
|
174
|
+
"files": [
|
|
175
|
+
{
|
|
176
|
+
"source": "src/components/stepper-provider.tsx",
|
|
177
|
+
"path": "components/stepper-provider.tsx"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"source": "src/components/stepper-view.tsx",
|
|
181
|
+
"path": "components/stepper-view.tsx"
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "step-field",
|
|
187
|
+
"title": "Step Field",
|
|
188
|
+
"description": "Label, control and validation message. Layout only — it holds no form state.",
|
|
189
|
+
"dependencies": ["lucide-react"],
|
|
190
|
+
"registryDependencies": ["brand-theme"],
|
|
191
|
+
"files": [
|
|
192
|
+
{
|
|
193
|
+
"source": "src/components/steps/step-field.tsx",
|
|
194
|
+
"path": "components/steps/step-field.tsx"
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "form-fields",
|
|
200
|
+
"title": "Form Fields",
|
|
201
|
+
"description": "FormInput and FormSelect bound to react-hook-form through useController, so a field's error re-renders that field alone.",
|
|
202
|
+
"dependencies": ["react-hook-form"],
|
|
203
|
+
"registryDependencies": ["input", "select", "step-field"],
|
|
204
|
+
"files": [
|
|
205
|
+
{
|
|
206
|
+
"source": "src/components/steps/form-input.tsx",
|
|
207
|
+
"path": "components/steps/form-input.tsx"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"source": "src/components/steps/form-select.tsx",
|
|
211
|
+
"path": "components/steps/form-select.tsx"
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"name": "normalize",
|
|
217
|
+
"title": "normalize",
|
|
218
|
+
"description": "Input normalisers — currently `digits`, which strips everything that is not a digit.",
|
|
219
|
+
"files": [
|
|
220
|
+
{
|
|
221
|
+
"source": "src/lib/normalize.ts",
|
|
222
|
+
"path": "lib/normalize.ts"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "tckn",
|
|
228
|
+
"title": "T.C. Kimlik No",
|
|
229
|
+
"description": "Validates a Turkish national identity number, check digits included — which is what stops a typo from passing as a real identity.",
|
|
230
|
+
"registryDependencies": ["normalize"],
|
|
231
|
+
"files": [
|
|
232
|
+
{
|
|
233
|
+
"source": "src/lib/tckn.ts",
|
|
234
|
+
"path": "lib/tckn.ts"
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "quote-schema",
|
|
240
|
+
"title": "Quote Schema",
|
|
241
|
+
"description": "Zod schemas for the quote flow — TCKN, Turkish mobile number, e-mail and occupation — plus per-step field lists and form defaults.",
|
|
242
|
+
"dependencies": ["zod"],
|
|
243
|
+
"registryDependencies": ["normalize", "tckn"],
|
|
244
|
+
"files": [
|
|
245
|
+
{
|
|
246
|
+
"source": "src/lib/quote-schema.ts",
|
|
247
|
+
"path": "lib/quote-schema.ts"
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Argument parsing. Hand-rolled so the package stays dependency-free. */
|
|
2
|
+
|
|
3
|
+
const FLAGS = {
|
|
4
|
+
overwrite: "overwrite",
|
|
5
|
+
o: "overwrite",
|
|
6
|
+
"dry-run": "dryRun",
|
|
7
|
+
"skip-install": "skipInstall",
|
|
8
|
+
help: "help",
|
|
9
|
+
h: "help",
|
|
10
|
+
version: "version",
|
|
11
|
+
v: "version",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const OPTIONS = { cwd: "cwd", c: "cwd" };
|
|
15
|
+
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const result = { command: undefined, items: [], cwd: process.cwd() };
|
|
18
|
+
|
|
19
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
20
|
+
const arg = argv[index];
|
|
21
|
+
|
|
22
|
+
if (!arg.startsWith("-")) {
|
|
23
|
+
if (result.command === undefined) result.command = arg;
|
|
24
|
+
else result.items.push(arg);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `--cwd=x` and `--cwd x` are both accepted.
|
|
29
|
+
const [name, inlineValue] = arg.replace(/^--?/, "").split(/=(.*)/s);
|
|
30
|
+
|
|
31
|
+
if (OPTIONS[name]) {
|
|
32
|
+
const value = inlineValue ?? argv[++index];
|
|
33
|
+
if (value === undefined) throw new Error(`--${name} needs a value.`);
|
|
34
|
+
result[OPTIONS[name]] = value;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (FLAGS[name]) {
|
|
39
|
+
result[FLAGS[name]] = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(`Unknown option "${arg}".`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** `me-ui add <items…>` — copies items and everything they depend on. */
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { applyCssVars, missingBaseTokens } from "../css.js";
|
|
8
|
+
import { installDependencies, missingDependencies } from "../deps.js";
|
|
9
|
+
import { cssVarsOf, manifest, npmDependenciesOf, registryDir, resolveItems } from "../manifest.js";
|
|
10
|
+
import { displayPathOf, rewriteImports, targetPathOf } from "../paths.js";
|
|
11
|
+
import { loadProject } from "../project.js";
|
|
12
|
+
import { bullet, cyan, dim, green, heading, line, warn, yellow } from "../log.js";
|
|
13
|
+
|
|
14
|
+
/** Decides, per file, between writing it, replacing it, and leaving it alone. */
|
|
15
|
+
function planFiles(items, project) {
|
|
16
|
+
return items
|
|
17
|
+
.flatMap((item) => item.files ?? [])
|
|
18
|
+
.map((file) => {
|
|
19
|
+
const target = targetPathOf(file, project);
|
|
20
|
+
const source = readFileSync(join(registryDir, file.path), "utf8");
|
|
21
|
+
const contents = file.target ? source : rewriteImports(source, project);
|
|
22
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : undefined;
|
|
23
|
+
|
|
24
|
+
const action =
|
|
25
|
+
current === undefined ? "create" : current === contents ? "identical" : "conflict";
|
|
26
|
+
|
|
27
|
+
return { display: displayPathOf(file, project), target, contents, action };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MARKS = {
|
|
32
|
+
create: green("+"),
|
|
33
|
+
overwrite: yellow("~"),
|
|
34
|
+
identical: dim("="),
|
|
35
|
+
conflict: yellow("!"),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function reportPlan(plan, overwrite) {
|
|
39
|
+
heading("Files");
|
|
40
|
+
for (const file of plan) {
|
|
41
|
+
const action = file.action === "conflict" && overwrite ? "overwrite" : file.action;
|
|
42
|
+
const note = { identical: "unchanged", conflict: "exists — pass --overwrite to replace" }[
|
|
43
|
+
action
|
|
44
|
+
];
|
|
45
|
+
bullet(MARKS[action], file.display, note);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function add({ items: names, cwd, overwrite, dryRun, skipInstall }) {
|
|
50
|
+
if (names.length === 0) {
|
|
51
|
+
throw new Error("Name at least one item, for example `me-ui add input`.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const items = resolveItems(names);
|
|
55
|
+
const project = loadProject(cwd);
|
|
56
|
+
|
|
57
|
+
line(
|
|
58
|
+
`${cyan("me-ui")} add ${names.join(" ")}${dryRun ? dim(" (dry run)") : ""}\n` +
|
|
59
|
+
dim(` into ${project.root}`) +
|
|
60
|
+
dim(project.usesComponentsJson ? " — using its components.json aliases" : ""),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const plan = planFiles(items, project);
|
|
64
|
+
const writable = plan.filter(
|
|
65
|
+
(file) => file.action === "create" || (file.action === "conflict" && overwrite),
|
|
66
|
+
);
|
|
67
|
+
reportPlan(plan, overwrite);
|
|
68
|
+
|
|
69
|
+
const dependencies = missingDependencies(project, npmDependenciesOf(items));
|
|
70
|
+
if (dependencies.length > 0) {
|
|
71
|
+
heading("Dependencies");
|
|
72
|
+
dependencies.forEach((name) => bullet(green("+"), name));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cssVars = cssVarsOf(items);
|
|
76
|
+
const css = project.cssFile ? readFileSync(project.cssFile, "utf8") : undefined;
|
|
77
|
+
const styles = css === undefined ? { css, added: [] } : applyCssVars(css, cssVars);
|
|
78
|
+
if (styles.added.length > 0) {
|
|
79
|
+
heading("Styles");
|
|
80
|
+
styles.added.forEach((name) => bullet(green("+"), name));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
line(`\n${dim("Dry run — nothing was written.")}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const file of writable) {
|
|
89
|
+
mkdirSync(dirname(file.target), { recursive: true });
|
|
90
|
+
writeFileSync(file.target, file.contents);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (styles.added.length > 0) writeFileSync(project.cssFile, styles.css);
|
|
94
|
+
|
|
95
|
+
if (dependencies.length > 0 && !skipInstall) {
|
|
96
|
+
line("");
|
|
97
|
+
installDependencies(project, dependencies);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
line(`\n${green("Done.")} ${writable.length} file(s) written.`);
|
|
101
|
+
|
|
102
|
+
if (!project.cssFile && Object.keys(cssVars.light).length > 0) {
|
|
103
|
+
warn("No stylesheet found — add the design tokens by hand:");
|
|
104
|
+
Object.entries(cssVars.light).forEach(([name, value]) => bullet(" ", `${name}: ${value};`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const absent = css === undefined ? [] : missingBaseTokens(css, manifest.baseTokens);
|
|
108
|
+
if (absent.length > 0) {
|
|
109
|
+
warn(
|
|
110
|
+
`These components also style against tokens this project does not define: ${absent.join(", ")}. ` +
|
|
111
|
+
"They come from a shadcn base theme — run `npx shadcn@latest init` if the components look unstyled.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (dependencies.length > 0 && skipInstall) {
|
|
116
|
+
warn(`Skipped install. Add these yourself: ${dependencies.join(" ")}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** `me-ui list` — the catalogue, with what each item drags in behind it. */
|
|
2
|
+
|
|
3
|
+
import { manifest } from "../manifest.js";
|
|
4
|
+
import { bold, cyan, dim, line } from "../log.js";
|
|
5
|
+
|
|
6
|
+
export function list() {
|
|
7
|
+
line(`\n${bold("Items")} ${dim("— me-ui add <name…>")}\n`);
|
|
8
|
+
|
|
9
|
+
const width = Math.max(...manifest.items.map((item) => item.name.length));
|
|
10
|
+
|
|
11
|
+
for (const item of manifest.items) {
|
|
12
|
+
const dependencies = item.registryDependencies ?? [];
|
|
13
|
+
line(` ${cyan(item.name.padEnd(width))} ${item.description}`);
|
|
14
|
+
if (dependencies.length > 0) {
|
|
15
|
+
line(` ${" ".repeat(width)} ${dim(`also installs: ${dependencies.join(", ")}`)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
line("");
|
|
20
|
+
}
|
package/src/css.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes design tokens into the target project's stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Only missing declarations are added — an existing `--brand` is left alone, so
|
|
5
|
+
* a project that has already retuned the colour keeps its value across upgrades.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BLOCKS = [
|
|
9
|
+
{ scope: "theme", selector: "@theme inline" },
|
|
10
|
+
{ scope: "light", selector: ":root" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Body of `selector { … }`, located by counting braces so nested rules survive. */
|
|
14
|
+
function findBlock(css, selector) {
|
|
15
|
+
const opening = new RegExp(
|
|
16
|
+
`(^|})\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*{`,
|
|
17
|
+
"m",
|
|
18
|
+
);
|
|
19
|
+
const match = opening.exec(css);
|
|
20
|
+
if (!match) return undefined;
|
|
21
|
+
|
|
22
|
+
const start = match.index + match[0].length;
|
|
23
|
+
let depth = 1;
|
|
24
|
+
let index = start;
|
|
25
|
+
|
|
26
|
+
while (index < css.length && depth > 0) {
|
|
27
|
+
if (css[index] === "{") depth += 1;
|
|
28
|
+
if (css[index] === "}") depth -= 1;
|
|
29
|
+
index += 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { start, end: index - 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function indentationOf(body) {
|
|
36
|
+
const match = /\n([ \t]+)\S/.exec(body);
|
|
37
|
+
return match ? match[1] : " ";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addToBlock(css, block, declarations) {
|
|
41
|
+
const body = css.slice(block.start, block.end);
|
|
42
|
+
const indent = indentationOf(body);
|
|
43
|
+
const addition = declarations.map(([name, value]) => `${indent}${name}: ${value};`).join("\n");
|
|
44
|
+
const separator = body.trimEnd().endsWith(";") || body.trim() === "" ? "" : "\n";
|
|
45
|
+
return `${css.slice(0, block.end).trimEnd()}\n${separator}${addition}\n${css.slice(block.end)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function appendBlock(css, selector, declarations) {
|
|
49
|
+
const body = declarations.map(([name, value]) => ` ${name}: ${value};`).join("\n");
|
|
50
|
+
return `${css.trimEnd()}\n\n${selector} {\n${body}\n}\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Returns the stylesheet with every variable applied, plus the names it added. */
|
|
54
|
+
export function applyCssVars(css, cssVars) {
|
|
55
|
+
const added = [];
|
|
56
|
+
let output = css;
|
|
57
|
+
|
|
58
|
+
for (const { scope, selector } of BLOCKS) {
|
|
59
|
+
const wanted = Object.entries(cssVars[scope] ?? {});
|
|
60
|
+
if (wanted.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
const block = findBlock(output, selector);
|
|
63
|
+
const declared = block ? output.slice(block.start, block.end) : "";
|
|
64
|
+
const missing = wanted.filter(
|
|
65
|
+
([name]) => !new RegExp(`(^|[;{\\s])${name}\\s*:`).test(declared),
|
|
66
|
+
);
|
|
67
|
+
if (missing.length === 0) continue;
|
|
68
|
+
|
|
69
|
+
output = block ? addToBlock(output, block, missing) : appendBlock(output, selector, missing);
|
|
70
|
+
|
|
71
|
+
added.push(...missing.map(([name]) => `${selector} ${name}`));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { css: output, added };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Base tokens the components style against but do not themselves define. */
|
|
78
|
+
export function missingBaseTokens(css, tokens) {
|
|
79
|
+
return tokens.filter((token) => !new RegExp(`(^|[;{\\s])${token}\\s*:`).test(css));
|
|
80
|
+
}
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** npm dependency detection and installation for the target project. */
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const MANAGERS = [
|
|
8
|
+
{ lockfile: "bun.lock", command: "bun", verb: "add" },
|
|
9
|
+
{ lockfile: "bun.lockb", command: "bun", verb: "add" },
|
|
10
|
+
{ lockfile: "pnpm-lock.yaml", command: "pnpm", verb: "add" },
|
|
11
|
+
{ lockfile: "yarn.lock", command: "yarn", verb: "add" },
|
|
12
|
+
{ lockfile: "package-lock.json", command: "npm", verb: "install" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const NPM = { command: "npm", verb: "install" };
|
|
16
|
+
|
|
17
|
+
// The npm package-name grammar. Names are interpolated into a shell string
|
|
18
|
+
// below, so they are checked against this first: today they only ever come
|
|
19
|
+
// from this package's own registry.json, but that file is data, and data can
|
|
20
|
+
// be edited by someone who does not know it reaches a shell.
|
|
21
|
+
const SAFE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
22
|
+
|
|
23
|
+
export function detectPackageManager(root) {
|
|
24
|
+
return MANAGERS.find(({ lockfile }) => existsSync(join(root, lockfile))) ?? NPM;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function missingDependencies(project, dependencies) {
|
|
28
|
+
return dependencies.filter((name) => !(name in project.installedDependencies));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function installDependencies(project, dependencies) {
|
|
32
|
+
const unsafe = dependencies.filter((name) => !SAFE_NAME.test(name));
|
|
33
|
+
if (unsafe.length > 0) {
|
|
34
|
+
throw new Error(`Refusing to install unexpected package name(s): ${unsafe.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const manager = detectPackageManager(project.root);
|
|
38
|
+
const invocation = `${manager.command} ${manager.verb} ${dependencies.join(" ")}`;
|
|
39
|
+
|
|
40
|
+
// Passed as one string rather than command + args: every package manager is a
|
|
41
|
+
// `.cmd` shim on Windows, which Node refuses to spawn without a shell, and an
|
|
42
|
+
// args array combined with `shell` is deprecated. The names are validated above.
|
|
43
|
+
const result = spawnSync(invocation, { cwd: project.root, stdio: "inherit", shell: true });
|
|
44
|
+
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`\`${invocation}\` failed. Install the packages yourself, or re-run with --skip-install.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return manager.command;
|
|
52
|
+
}
|
package/src/jsonc.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSON-with-comments reader, for `tsconfig.json` — which is routinely
|
|
3
|
+
* written with comments and trailing commas that `JSON.parse` rejects.
|
|
4
|
+
*
|
|
5
|
+
* Comments are stripped by scanning rather than by regex, so a `//` or `/*`
|
|
6
|
+
* sitting inside a string literal (a path, a URL) is left alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function parseJsonc(text) {
|
|
10
|
+
let output = "";
|
|
11
|
+
let index = 0;
|
|
12
|
+
|
|
13
|
+
while (index < text.length) {
|
|
14
|
+
const char = text[index];
|
|
15
|
+
|
|
16
|
+
if (char === '"') {
|
|
17
|
+
const start = index;
|
|
18
|
+
index += 1;
|
|
19
|
+
while (index < text.length && text[index] !== '"') {
|
|
20
|
+
index += text[index] === "\\" ? 2 : 1;
|
|
21
|
+
}
|
|
22
|
+
index += 1;
|
|
23
|
+
output += text.slice(start, index);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (char === "/" && text[index + 1] === "/") {
|
|
28
|
+
while (index < text.length && text[index] !== "\n") index += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (char === "/" && text[index + 1] === "*") {
|
|
33
|
+
index += 2;
|
|
34
|
+
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
|
|
35
|
+
index += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
output += char;
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return JSON.parse(output.replace(/,(\s*[}\]])/g, "$1"));
|
|
44
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Terminal output. Colour is dropped when stdout is piped or NO_COLOR is set. */
|
|
2
|
+
|
|
3
|
+
const ESC = String.fromCharCode(27);
|
|
4
|
+
const enabled = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
5
|
+
const paint = (code) => (text) => (enabled ? `${ESC}[${code}m${text}${ESC}[0m` : String(text));
|
|
6
|
+
|
|
7
|
+
export const bold = paint(1);
|
|
8
|
+
export const dim = paint(2);
|
|
9
|
+
export const red = paint(31);
|
|
10
|
+
export const green = paint(32);
|
|
11
|
+
export const yellow = paint(33);
|
|
12
|
+
export const cyan = paint(36);
|
|
13
|
+
|
|
14
|
+
export function line(text = "") {
|
|
15
|
+
console.log(text);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function heading(text) {
|
|
19
|
+
console.log(`\n${bold(text)}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function bullet(mark, text, note) {
|
|
23
|
+
console.log(` ${mark} ${text}${note ? ` ${dim(note)}` : ""}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function warn(text) {
|
|
27
|
+
console.warn(`${yellow("warning")} ${text}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function fail(text) {
|
|
31
|
+
console.error(`${red("error")} ${text}`);
|
|
32
|
+
}
|