emily-css 1.2.8 → 1.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.js CHANGED
@@ -1,997 +1,1056 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const crossSpawn = require("cross-spawn");
4
- const { Select, Input, Confirm } = require("enquirer");
5
- const chalk = require("chalk");
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crossSpawn = require("cross-spawn");
4
+ const { Select, Input, Confirm } = require("enquirer");
5
+ const chalk = require("chalk");
6
6
  const ora = require("ora");
7
7
  const boxen = require("boxen");
8
8
  const { DEFAULT_PURGE_IGNORE, PURGE_EXTENSIONS } = require("./constants.js");
9
-
10
- // ============================================================================
11
- // CONSTANTS
12
- // ============================================================================
13
-
14
- const COLOUR_PRESETS = {
15
- primary: [
16
- { value: "custom", label: "Enter your own hex" },
17
- { value: "#DB2777", label: "Emily Pink" },
18
- { value: "#2563EB", label: "Blue" },
19
- { value: "#028090", label: "Teal" },
20
- { value: "#114B5F", label: "Deep Teal" },
21
- { value: "#15803D", label: "Green" },
22
- { value: "#7C3AED", label: "Purple" },
23
- { value: "#E05C00", label: "Burnt Orange" },
24
- ],
25
- secondary: [
26
- { value: "custom", label: "Enter your own hex" },
27
- { value: "#2563EB", label: "Blue" },
28
- { value: "#028090", label: "Teal" },
29
- { value: "#7C3AED", label: "Purple" },
30
- { value: "#DB2777", label: "Emily Pink" },
31
- { value: "#F59E0B", label: "Amber" },
32
- { value: "#57534E", label: "Warm Grey" },
33
- ],
34
- success: [
35
- { value: "#017F65", label: "Accessible Green (recommended)" },
36
- { value: "#15803D", label: "Forest Green" },
37
- { value: "custom", label: "Enter your own hex" },
38
- ],
39
- warning: [
40
- { value: "#FFC107", label: "Amber (recommended)" },
41
- { value: "#F59E0B", label: "Orange Amber" },
42
- { value: "custom", label: "Enter your own hex" },
43
- ],
44
- error: [
45
- { value: "#B20000", label: "Accessible Red (recommended)" },
46
- { value: "#DC2626", label: "Red" },
47
- { value: "custom", label: "Enter your own hex" },
48
- ],
49
- };
50
-
51
- const FONT_OPTIONS = [
52
- { name: "lexend", message: "Lexend (clear, accessible - recommended)" },
53
- { name: "inter", message: "Inter (clean, widely used)" },
54
- { name: "dm-sans", message: "DM Sans (modern, geometric)" },
55
- { name: "nunito", message: "Nunito (friendly, rounded)" },
56
- { name: "atkinson", message: "Atkinson Hyperlegible (maximum legibility)" },
57
- { name: "system", message: "System sans-serif (no download required)" },
58
- ];
9
+ const {
10
+ ALLOWED_FONT_FAMILIES,
11
+ validateHexColour,
12
+ validateSpacingValue,
13
+ validateFontFamily,
14
+ validateConfigShape,
15
+ } = require("./validate.js");
16
+
17
+ // ============================================================================
18
+ // CONSTANTS
19
+ // ============================================================================
20
+
21
+ const COLOUR_PRESETS = {
22
+ primary: [
23
+ { value: "custom", label: "Enter your own hex" },
24
+ { value: "#DB2777", label: "Emily Pink" },
25
+ { value: "#2563EB", label: "Blue" },
26
+ { value: "#028090", label: "Teal" },
27
+ { value: "#114B5F", label: "Deep Teal" },
28
+ { value: "#15803D", label: "Green" },
29
+ { value: "#7C3AED", label: "Purple" },
30
+ { value: "#E05C00", label: "Burnt Orange" },
31
+ ],
32
+ secondary: [
33
+ { value: "custom", label: "Enter your own hex" },
34
+ { value: "#2563EB", label: "Blue" },
35
+ { value: "#028090", label: "Teal" },
36
+ { value: "#7C3AED", label: "Purple" },
37
+ { value: "#DB2777", label: "Emily Pink" },
38
+ { value: "#F59E0B", label: "Amber" },
39
+ { value: "#57534E", label: "Warm Grey" },
40
+ ],
41
+ success: [
42
+ { value: "#017F65", label: "Accessible Green (recommended)" },
43
+ { value: "#15803D", label: "Forest Green" },
44
+ { value: "custom", label: "Enter your own hex" },
45
+ ],
46
+ warning: [
47
+ { value: "#FFC107", label: "Amber (recommended)" },
48
+ { value: "#F59E0B", label: "Orange Amber" },
49
+ { value: "custom", label: "Enter your own hex" },
50
+ ],
51
+ error: [
52
+ { value: "#B20000", label: "Accessible Red (recommended)" },
53
+ { value: "#DC2626", label: "Red" },
54
+ { value: "custom", label: "Enter your own hex" },
55
+ ],
56
+ };
57
+
59
58
  const CORE_COLOUR_KEYS = new Set([
60
- "brand",
61
- "accent",
62
- "btn-primary",
63
- "btn-secondary",
64
- "success",
65
- "warning",
66
- "error",
67
- "neutral",
68
- ]);
69
-
70
- // ============================================================================
71
- // HELPERS
72
- // ============================================================================
73
-
74
- function isValidHex(hex) {
75
- return /^#[0-9A-F]{6}$/i.test(hex);
76
- }
77
-
59
+ "brand",
60
+ "accent",
61
+ "btn-primary",
62
+ "btn-secondary",
63
+ "success",
64
+ "warning",
65
+ "error",
66
+ "neutral",
67
+ ]);
68
+
69
+ // ============================================================================
70
+ // HELPERS
71
+ // ============================================================================
72
+
78
73
  function isPlainObject(value) {
79
74
  return (
80
75
  value !== null &&
81
- typeof value === "object" &&
82
- !Array.isArray(value)
83
- );
76
+ typeof value === "object" &&
77
+ !Array.isArray(value)
78
+ );
79
+ }
80
+
81
+ function mergeWithDefaults(defaults, existing) {
82
+ if (!isPlainObject(defaults)) {
83
+ return existing === undefined ? defaults : existing;
84
+ }
85
+
86
+ const output = { ...defaults };
87
+
88
+ if (!isPlainObject(existing)) {
89
+ return output;
90
+ }
91
+
92
+ Object.keys(existing).forEach((key) => {
93
+ if (isPlainObject(defaults[key]) && isPlainObject(existing[key])) {
94
+ output[key] = mergeWithDefaults(defaults[key], existing[key]);
95
+ return;
96
+ }
97
+
98
+ output[key] = existing[key];
99
+ });
100
+
101
+ return output;
102
+ }
103
+
104
+ function colourSwatch(hex) {
105
+ return chalk.hex(hex)("■");
106
+ }
107
+
108
+ function normaliseHex(value) {
109
+ if (typeof value !== "string") return null;
110
+ const trimmed = value.trim();
111
+ const result = validateHexColour(trimmed);
112
+ return result.valid ? trimmed.toUpperCase() : null;
84
113
  }
85
114
 
86
- function mergeWithDefaults(defaults, existing) {
87
- if (!isPlainObject(defaults)) {
88
- return existing === undefined ? defaults : existing;
89
- }
90
-
91
- const output = { ...defaults };
92
-
93
- if (!isPlainObject(existing)) {
94
- return output;
95
- }
115
+ function formatValueForMessage(value) {
116
+ if (value === null) return "null";
117
+ if (value === undefined) return "undefined";
118
+ return "'" + String(value) + "'";
119
+ }
96
120
 
97
- Object.keys(existing).forEach((key) => {
98
- if (isPlainObject(defaults[key]) && isPlainObject(existing[key])) {
99
- output[key] = mergeWithDefaults(defaults[key], existing[key]);
100
- return;
101
- }
121
+ async function askValidatedInput({
122
+ promptName,
123
+ message,
124
+ initial,
125
+ validator,
126
+ normalise,
127
+ }) {
128
+ let nextInitial = initial;
102
129
 
103
- output[key] = existing[key];
104
- });
130
+ while (true) {
131
+ const raw = await new Input({
132
+ name: promptName,
133
+ message,
134
+ initial: nextInitial,
135
+ }).run();
105
136
 
106
- return output;
107
- }
137
+ const value = typeof raw === "string" ? raw.trim() : raw;
138
+ const result = validator(value);
108
139
 
109
- function colourSwatch(hex) {
110
- return chalk.hex(hex)("");
111
- }
140
+ if (result.valid) {
141
+ console.log(chalk.green("✓ Valid"));
142
+ return typeof normalise === "function" ? normalise(value) : value;
143
+ }
112
144
 
113
- function normaliseHex(value) {
114
- return typeof value === "string" && isValidHex(value)
115
- ? value.toUpperCase()
116
- : null;
145
+ console.log(
146
+ chalk.red(
147
+ "✗ Invalid: " + result.reason + " (got " + formatValueForMessage(raw) + ")",
148
+ ),
149
+ );
150
+ nextInitial = value || initial;
151
+ }
117
152
  }
118
153
 
119
154
  async function askHex(promptName, message, initial) {
120
- const value = await new Input({
121
- name: promptName,
155
+ return askValidatedInput({
156
+ promptName,
122
157
  message,
123
158
  initial: initial || "#000000",
124
- validate(value) {
125
- return isValidHex(value)
126
- ? true
127
- : "Enter a valid hex colour, e.g. #0077B6";
159
+ validator: validateHexColour,
160
+ normalise: function (value) {
161
+ return String(value).toUpperCase();
128
162
  },
129
- }).run();
130
-
131
- return value.toUpperCase();
132
- }
133
-
134
- async function askColourFromPresets(label, presets, defaultHex, currentHex) {
135
- const defaultHexValue = normaliseHex(defaultHex);
136
- const currentHexValue = normaliseHex(currentHex);
137
-
138
- const choices = presets.map(function (opt) {
139
- if (opt.value === "custom") {
140
- return { name: "custom", message: "Enter your own hex" };
141
- }
142
-
143
- const upperHex = String(opt.value).toUpperCase();
144
- return {
145
- name: upperHex,
146
- message:
147
- colourSwatch(upperHex) + " " + opt.label + " " + chalk.gray(upperHex),
148
- };
149
163
  });
150
-
151
- let initial = Math.max(
152
- 0,
153
- choices.findIndex((choice) => choice.name === "custom"),
154
- );
155
-
156
- if (currentHexValue) {
157
- const currentIndex = choices.findIndex(
158
- (choice) => choice.name === currentHexValue,
159
- );
160
-
161
- if (currentIndex !== -1) {
162
- initial = currentIndex;
163
- } else {
164
- choices.unshift({
165
- name: "__current__",
166
- message:
167
- "Keep current " +
168
- label +
169
- " " +
170
- colourSwatch(currentHexValue) +
171
- " " +
172
- chalk.gray(currentHexValue),
173
- });
174
- initial = 0;
175
- }
176
- } else if (defaultHexValue) {
177
- const defaultIndex = choices.findIndex(
178
- (choice) => choice.name === defaultHexValue,
179
- );
180
- if (defaultIndex !== -1) {
181
- initial = defaultIndex;
182
- }
183
- }
184
-
185
- const selected = await new Select({
186
- name: label,
187
- message: label + " colour",
188
- choices,
189
- initial,
190
- }).run();
191
-
192
- if (selected === "__current__" && currentHexValue) return currentHexValue;
193
- if (selected !== "custom") return selected.toUpperCase();
194
-
195
- const fallbackHex = currentHexValue || defaultHexValue || "#000000";
196
- return askHex(label + "Custom", "Enter " + label + " hex", fallbackHex);
197
- }
198
-
199
- function hasFile(fileName) {
200
- return fs.existsSync(path.join(process.cwd(), fileName));
201
164
  }
202
-
203
- function readPackageJson() {
204
- const packagePath = path.join(process.cwd(), "package.json");
205
-
206
- if (!fs.existsSync(packagePath)) return null;
207
-
208
- try {
209
- return JSON.parse(fs.readFileSync(packagePath, "utf8"));
210
- } catch {
211
- return null;
212
- }
213
- }
214
-
215
- function readExistingConfig() {
216
- const configPath = path.join(process.cwd(), "emily.config.json");
217
-
218
- if (!fs.existsSync(configPath)) return null;
219
-
220
- try {
221
- return JSON.parse(fs.readFileSync(configPath, "utf8"));
222
- } catch {
223
- return null;
224
- }
225
- }
226
-
227
- function getFontInitialIndex(fontKey, fallbackIndex) {
228
- if (!fontKey || typeof fontKey !== "string") return fallbackIndex;
229
- const normalised = fontKey.toLowerCase();
230
- const index = FONT_OPTIONS.findIndex((option) => option.name === normalised);
231
- return index === -1 ? fallbackIndex : index;
232
- }
233
-
165
+
166
+ async function askColourFromPresets(label, presets, defaultHex, currentHex) {
167
+ const defaultHexValue = normaliseHex(defaultHex);
168
+ const currentHexValue = normaliseHex(currentHex);
169
+
170
+ const choices = presets.map(function (opt) {
171
+ if (opt.value === "custom") {
172
+ return { name: "custom", message: "Enter your own hex" };
173
+ }
174
+
175
+ const upperHex = String(opt.value).toUpperCase();
176
+ return {
177
+ name: upperHex,
178
+ message:
179
+ colourSwatch(upperHex) + " " + opt.label + " " + chalk.gray(upperHex),
180
+ };
181
+ });
182
+
183
+ let initial = Math.max(
184
+ 0,
185
+ choices.findIndex((choice) => choice.name === "custom"),
186
+ );
187
+
188
+ if (currentHexValue) {
189
+ const currentIndex = choices.findIndex(
190
+ (choice) => choice.name === currentHexValue,
191
+ );
192
+
193
+ if (currentIndex !== -1) {
194
+ initial = currentIndex;
195
+ } else {
196
+ choices.unshift({
197
+ name: "__current__",
198
+ message:
199
+ "Keep current " +
200
+ label +
201
+ " " +
202
+ colourSwatch(currentHexValue) +
203
+ " " +
204
+ chalk.gray(currentHexValue),
205
+ });
206
+ initial = 0;
207
+ }
208
+ } else if (defaultHexValue) {
209
+ const defaultIndex = choices.findIndex(
210
+ (choice) => choice.name === defaultHexValue,
211
+ );
212
+ if (defaultIndex !== -1) {
213
+ initial = defaultIndex;
214
+ }
215
+ }
216
+
217
+ const selected = await new Select({
218
+ name: label,
219
+ message: label + " colour",
220
+ choices,
221
+ initial,
222
+ }).run();
223
+
224
+ if (selected === "__current__" && currentHexValue) return currentHexValue;
225
+ if (selected !== "custom") return selected.toUpperCase();
226
+
227
+ const fallbackHex = currentHexValue || defaultHexValue || "#000000";
228
+ return askHex(label + "Custom", "Enter " + label + " hex", fallbackHex);
229
+ }
230
+
231
+ function hasFile(fileName) {
232
+ return fs.existsSync(path.join(process.cwd(), fileName));
233
+ }
234
+
235
+ function readPackageJson() {
236
+ const packagePath = path.join(process.cwd(), "package.json");
237
+
238
+ if (!fs.existsSync(packagePath)) return null;
239
+
240
+ try {
241
+ return JSON.parse(fs.readFileSync(packagePath, "utf8"));
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function readExistingConfig() {
248
+ const configPath = path.join(process.cwd(), "emily.config.json");
249
+
250
+ if (!fs.existsSync(configPath)) return null;
251
+
252
+ try {
253
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
234
259
  function getExistingAdditionalColours(existingColours) {
235
- if (!isPlainObject(existingColours)) return {};
236
-
237
- const additional = {};
238
- Object.entries(existingColours).forEach(([name, value]) => {
239
- if (CORE_COLOUR_KEYS.has(name)) return;
240
- if (!/^[a-z][a-z0-9-]*$/.test(name)) return;
241
-
242
- const upperHex = normaliseHex(value);
243
- if (!upperHex) return;
244
- additional[name] = upperHex;
245
- });
246
-
247
- return additional;
248
- }
249
-
260
+ if (!isPlainObject(existingColours)) return {};
261
+
262
+ const additional = {};
263
+ Object.entries(existingColours).forEach(([name, value]) => {
264
+ if (CORE_COLOUR_KEYS.has(name)) return;
265
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) return;
266
+
267
+ const upperHex = normaliseHex(value);
268
+ if (!upperHex) return;
269
+ additional[name] = upperHex;
270
+ });
271
+
272
+ return additional;
273
+ }
274
+
250
275
  function getBaseUnitInitial(config) {
251
276
  const rawBaseUnit = config && typeof config.baseUnit === "string"
252
- ? config.baseUnit
277
+ ? config.baseUnit.trim()
253
278
  : "";
254
- const parsed = Number.parseInt(rawBaseUnit, 10);
255
- if (Number.isNaN(parsed) || parsed <= 0) return "18";
256
- return String(parsed);
257
- }
258
-
259
- function hasDependency(packageJson, dependencyName) {
260
- if (!packageJson) return false;
261
-
262
- return Boolean(
263
- packageJson.dependencies?.[dependencyName] ||
264
- packageJson.devDependencies?.[dependencyName],
265
- );
266
- }
267
-
268
- function titleCasePackageName(name) {
269
- return name.replace(/-/g, " ").replace(/\b\w/g, function (c) {
270
- return c.toUpperCase();
271
- });
272
- }
273
-
274
- function addEmilyScriptsToPackageJson() {
275
- const packagePath = path.join(process.cwd(), "package.json");
276
-
277
- if (!fs.existsSync(packagePath)) return false;
278
-
279
- try {
280
- const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
281
-
282
- packageJson.scripts = packageJson.scripts || {};
283
-
284
- let changed = false;
285
-
286
- const scripts = {
287
- "emily:build": "emily-css build",
288
- "emily:watch": "emily-css watch",
289
- "emily:doctor": "emily-css doctor",
290
- "emily:migrate": "emily-css migrate",
291
- "emily:info": "emily-css info",
292
- "emily:manifest": "emily-css manifest",
293
- "emily:version": "emily-css version",
294
- "emily:help": "emily-css help",
295
- "emily:showcase": "emily-css showcase",
296
- };
297
-
298
- for (const [key, value] of Object.entries(scripts)) {
299
- if (!packageJson.scripts[key]) {
300
- packageJson.scripts[key] = value;
301
- changed = true;
302
- }
303
- }
304
-
305
- if (changed) {
306
- fs.writeFileSync(
307
- packagePath,
308
- JSON.stringify(packageJson, null, 2) + "\n",
309
- );
310
- }
311
-
312
- return true;
313
- } catch {
314
- return false;
315
- }
316
- }
317
-
318
- // ============================================================================
319
- // PROJECT DETECTION
320
- // ============================================================================
321
-
322
- function detectProject() {
323
- const packageJson = readPackageJson();
324
-
325
- if (
326
- hasFile("nuxt.config.ts") ||
327
- hasFile("nuxt.config.js") ||
328
- hasDependency(packageJson, "nuxt")
329
- ) {
330
- return {
331
- name: "Nuxt",
332
- sourceDir: ".",
333
- outputPath: "public/emily.min.css",
334
- sourceGlobs: [
335
- "./components/**/*.{vue,js,ts}",
336
- "./pages/**/*.vue",
337
- "./layouts/**/*.vue",
338
- "./app.vue",
339
- ],
340
- linkHint: '<link rel="stylesheet" href="/emily.min.css">',
341
- };
342
- }
343
-
344
- if (hasDependency(packageJson, "next")) {
345
- return {
346
- name: "Next.js",
347
- sourceDir: ".",
348
- outputPath: "public/emily.min.css",
349
- sourceGlobs: [
350
- "./app/**/*.{js,jsx,ts,tsx}",
351
- "./pages/**/*.{js,jsx,ts,tsx}",
352
- "./components/**/*.{js,jsx,ts,tsx}",
353
- "./src/**/*.{js,jsx,ts,tsx}",
354
- ],
355
- linkHint: '<link rel="stylesheet" href="/emily.min.css">',
356
- };
357
- }
358
-
359
- if (hasDependency(packageJson, "react")) {
360
- return {
361
- name: "React",
362
- sourceDir: "./src",
363
- outputPath: hasFile("public")
364
- ? "public/emily.min.css"
365
- : "dist/emily.min.css",
366
- sourceGlobs: [
367
- "./src/**/*.{js,jsx,ts,tsx}",
368
- "./components/**/*.{js,jsx,ts,tsx}",
369
- ],
370
- linkHint: hasFile("public")
371
- ? '<link rel="stylesheet" href="/emily.min.css">'
372
- : '<link rel="stylesheet" href="./dist/emily.min.css">',
373
- };
374
- }
375
-
376
- if (
377
- hasDependency(packageJson, "vue") ||
378
- hasFile("vite.config.ts") ||
379
- hasFile("vite.config.js")
380
- ) {
381
- return {
382
- name: "Vue/Vite",
383
- sourceDir: "./src",
384
- outputPath: "public/emily.min.css",
385
- sourceGlobs: ["./src/**/*.{vue,js,ts}"],
386
- linkHint: '<link rel="stylesheet" href="/emily.min.css">',
387
- };
388
- }
389
-
390
- if (hasDependency(packageJson, "astro") || hasFile("astro.config.mjs")) {
391
- return {
392
- name: "Astro",
393
- sourceDir: "./src",
394
- outputPath: "public/emily.min.css",
395
- sourceGlobs: ["./src/**/*.{astro,html,js,ts,vue,jsx,tsx,svelte}"],
396
- linkHint: '<link rel="stylesheet" href="/emily.min.css">',
397
- };
398
- }
399
-
400
- const rootFiles = fs.readdirSync(process.cwd());
401
- const hasDrupalInfoFile = rootFiles.some(function (file) {
402
- return file.endsWith(".info.yml");
403
- });
404
-
405
- if (
406
- hasDrupalInfoFile ||
407
- fs.existsSync(path.join(process.cwd(), "web/core"))
408
- ) {
409
- return {
410
- name: "Drupal",
411
- sourceDir: ".",
412
- outputPath: "dist/emily.min.css",
413
- sourceGlobs: [
414
- "./web/themes/custom/**/*.{twig,js,ts}",
415
- "./templates/**/*.html.twig",
416
- "./components/**/*.twig",
417
- "./**/*.theme",
418
- ],
419
- linkHint: "Attach dist/emily.min.css through your theme library YAML.",
420
- };
421
- }
422
-
423
- return {
424
- name: "Static/Generic",
425
- sourceDir: ".",
426
- outputPath: "dist/emily.min.css",
427
- sourceGlobs: [
428
- "./**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,blade.php,jinja,jinja2,j2}",
429
- ],
430
- linkHint: '<link rel="stylesheet" href="./dist/emily.min.css">',
431
- };
279
+ if (validateSpacingValue(rawBaseUnit).valid) return rawBaseUnit;
280
+ return "18px";
432
281
  }
433
-
434
- // ============================================================================
435
- // CONFIG BUILDER
436
- // ============================================================================
437
-
282
+
283
+ function hasDependency(packageJson, dependencyName) {
284
+ if (!packageJson) return false;
285
+
286
+ return Boolean(
287
+ packageJson.dependencies?.[dependencyName] ||
288
+ packageJson.devDependencies?.[dependencyName],
289
+ );
290
+ }
291
+
292
+ function titleCasePackageName(name) {
293
+ return name.replace(/-/g, " ").replace(/\b\w/g, function (c) {
294
+ return c.toUpperCase();
295
+ });
296
+ }
297
+
298
+ function addEmilyScriptsToPackageJson() {
299
+ const packagePath = path.join(process.cwd(), "package.json");
300
+
301
+ if (!fs.existsSync(packagePath)) return false;
302
+
303
+ try {
304
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
305
+
306
+ packageJson.scripts = packageJson.scripts || {};
307
+
308
+ let changed = false;
309
+
310
+ const scripts = {
311
+ "emily:build": "emily-css build",
312
+ "emily:watch": "emily-css watch",
313
+ "emily:doctor": "emily-css doctor",
314
+ "emily:migrate": "emily-css migrate",
315
+ "emily:info": "emily-css info",
316
+ "emily:manifest": "emily-css manifest",
317
+ "emily:version": "emily-css version",
318
+ "emily:help": "emily-css help",
319
+ "emily:showcase": "emily-css showcase",
320
+ };
321
+
322
+ for (const [key, value] of Object.entries(scripts)) {
323
+ if (!packageJson.scripts[key]) {
324
+ packageJson.scripts[key] = value;
325
+ changed = true;
326
+ }
327
+ }
328
+
329
+ if (changed) {
330
+ fs.writeFileSync(
331
+ packagePath,
332
+ JSON.stringify(packageJson, null, 2) + "\n",
333
+ );
334
+ }
335
+
336
+ return true;
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ // ============================================================================
343
+ // PROJECT DETECTION
344
+ // ============================================================================
345
+
346
+ function detectProject() {
347
+ const packageJson = readPackageJson();
348
+
349
+ if (
350
+ hasFile("nuxt.config.ts") ||
351
+ hasFile("nuxt.config.js") ||
352
+ hasDependency(packageJson, "nuxt")
353
+ ) {
354
+ return {
355
+ name: "Nuxt",
356
+ sourceDir: ".",
357
+ outputPath: "public/emily.min.css",
358
+ sourceGlobs: [
359
+ "./components/**/*.{vue,js,ts}",
360
+ "./pages/**/*.vue",
361
+ "./layouts/**/*.vue",
362
+ "./app.vue",
363
+ ],
364
+ linkHint: '<link rel="stylesheet" href="/emily.min.css">',
365
+ };
366
+ }
367
+
368
+ if (hasDependency(packageJson, "next")) {
369
+ return {
370
+ name: "Next.js",
371
+ sourceDir: ".",
372
+ outputPath: "public/emily.min.css",
373
+ sourceGlobs: [
374
+ "./app/**/*.{js,jsx,ts,tsx}",
375
+ "./pages/**/*.{js,jsx,ts,tsx}",
376
+ "./components/**/*.{js,jsx,ts,tsx}",
377
+ "./src/**/*.{js,jsx,ts,tsx}",
378
+ ],
379
+ linkHint: '<link rel="stylesheet" href="/emily.min.css">',
380
+ };
381
+ }
382
+
383
+ if (hasDependency(packageJson, "react")) {
384
+ return {
385
+ name: "React",
386
+ sourceDir: "./src",
387
+ outputPath: hasFile("public")
388
+ ? "public/emily.min.css"
389
+ : "dist/emily.min.css",
390
+ sourceGlobs: [
391
+ "./src/**/*.{js,jsx,ts,tsx}",
392
+ "./components/**/*.{js,jsx,ts,tsx}",
393
+ ],
394
+ linkHint: hasFile("public")
395
+ ? '<link rel="stylesheet" href="/emily.min.css">'
396
+ : '<link rel="stylesheet" href="./dist/emily.min.css">',
397
+ };
398
+ }
399
+
400
+ if (
401
+ hasDependency(packageJson, "vue") ||
402
+ hasFile("vite.config.ts") ||
403
+ hasFile("vite.config.js")
404
+ ) {
405
+ return {
406
+ name: "Vue/Vite",
407
+ sourceDir: "./src",
408
+ outputPath: "public/emily.min.css",
409
+ sourceGlobs: ["./src/**/*.{vue,js,ts}"],
410
+ linkHint: '<link rel="stylesheet" href="/emily.min.css">',
411
+ };
412
+ }
413
+
414
+ if (hasDependency(packageJson, "astro") || hasFile("astro.config.mjs")) {
415
+ return {
416
+ name: "Astro",
417
+ sourceDir: "./src",
418
+ outputPath: "public/emily.min.css",
419
+ sourceGlobs: ["./src/**/*.{astro,html,js,ts,vue,jsx,tsx,svelte}"],
420
+ linkHint: '<link rel="stylesheet" href="/emily.min.css">',
421
+ };
422
+ }
423
+
424
+ const rootFiles = fs.readdirSync(process.cwd());
425
+ const hasDrupalInfoFile = rootFiles.some(function (file) {
426
+ return file.endsWith(".info.yml");
427
+ });
428
+
429
+ if (
430
+ hasDrupalInfoFile ||
431
+ fs.existsSync(path.join(process.cwd(), "web/core"))
432
+ ) {
433
+ return {
434
+ name: "Drupal",
435
+ sourceDir: ".",
436
+ outputPath: "dist/emily.min.css",
437
+ sourceGlobs: [
438
+ "./web/themes/custom/**/*.{twig,js,ts}",
439
+ "./templates/**/*.html.twig",
440
+ "./components/**/*.twig",
441
+ "./**/*.theme",
442
+ ],
443
+ linkHint: "Attach dist/emily.min.css through your theme library YAML.",
444
+ };
445
+ }
446
+
447
+ return {
448
+ name: "Static/Generic",
449
+ sourceDir: ".",
450
+ outputPath: "dist/emily.min.css",
451
+ sourceGlobs: [
452
+ "./**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,blade.php,jinja,jinja2,j2}",
453
+ ],
454
+ linkHint: '<link rel="stylesheet" href="./dist/emily.min.css">',
455
+ };
456
+ }
457
+
458
+ // ============================================================================
459
+ // CONFIG BUILDER
460
+ // ============================================================================
461
+
438
462
  function createDefaultConfig({
439
463
  name,
440
464
  colours,
441
465
  headingFont,
442
466
  bodyFont,
443
- baseUnit,
444
- detectedProject,
445
- }) {
446
- return {
447
- name,
448
- description: name + " design system",
449
-
450
- baseUnit: baseUnit + "px",
451
- baseFontSize: "16px",
452
-
453
- fontFamily: {
454
- heading: headingFont,
455
- body: bodyFont,
456
- },
457
-
458
- customFonts: [],
459
-
460
- output: {
461
- css: detectedProject.outputPath,
462
- fullCss: "dist/emily.css",
463
- },
464
-
465
- manifest: true,
466
-
467
- colours,
468
-
469
- semanticColours: {
470
- dark: "#1A1A1A",
471
- light: "#FAFAFA",
472
- },
473
-
474
- purge: {
475
- projectType: detectedProject.name,
476
- sourceDir: detectedProject.sourceDir,
477
- sourceGlobs: detectedProject.sourceGlobs,
478
- ignore: DEFAULT_PURGE_IGNORE,
479
- safelist: [
480
- "bg-dark",
481
- "text-dark",
482
- "border-dark",
483
- "fill-dark",
484
- "bg-light",
485
- "text-light",
486
- "border-light",
487
- "fill-light",
488
- ],
489
- extensions: PURGE_EXTENSIONS,
490
- },
491
-
492
- breakpoints: {
493
- sm: "640px",
494
- md: "768px",
495
- lg: "1024px",
496
- xl: "1280px",
497
- "2xl": "1536px",
498
- },
499
-
500
- spacing: {
501
- scale: {
502
- 0: "0px",
503
- px: "1px",
504
- 0.5: "0.125rem",
505
- 1: "0.25rem",
506
- 1.5: "0.375rem",
507
- 2: "0.5rem",
508
- 2.5: "0.625rem",
509
- 3: "0.75rem",
510
- 3.5: "0.875rem",
511
- 4: "1rem",
512
- 5: "1.25rem",
513
- 6: "1.5rem",
514
- 7: "1.75rem",
515
- 8: "2rem",
516
- 9: "2.25rem",
517
- 10: "2.5rem",
518
- 11: "2.75rem",
519
- 12: "3rem",
520
- 14: "3.5rem",
521
- 16: "4rem",
522
- 20: "5rem",
523
- 24: "6rem",
524
- 28: "7rem",
525
- 32: "8rem",
526
- 36: "9rem",
527
- 40: "10rem",
528
- 44: "11rem",
529
- 48: "12rem",
530
- 52: "13rem",
531
- 56: "14rem",
532
- 60: "15rem",
533
- 64: "16rem",
534
- 72: "18rem",
535
- 80: "20rem",
536
- 96: "24rem",
537
- },
538
-
539
- borderWidths: [0, 2, 4, 8],
540
-
541
- borderRadius: {
542
- none: "0",
543
- sm: "4px",
544
- base: "8px",
545
- md: "12px",
546
- lg: "16px",
547
- xl: "20px",
548
- "2xl": "24px",
549
- "3xl": "32px",
550
- full: "9999px",
551
- },
552
- },
553
-
554
- typography: {
555
- lineHeightRatio: 1.5,
556
-
557
- fontWeights: {
558
- light: 300,
559
- normal: 400,
560
- medium: 500,
561
- semibold: 600,
562
- bold: 700,
563
- },
564
-
565
- fontSizes: [
566
- { name: "xs", value: "12px", lineHeight: 1.5 },
567
- { name: "sm", value: "14px", lineHeight: 1.5 },
568
- { name: "base", value: "16px", lineHeight: 1.6 },
569
- { name: "lg", value: "18px", lineHeight: 1.6 },
570
- { name: "xl", value: "20px", lineHeight: 1.6 },
571
- { name: "2xl", value: "24px", lineHeight: 1.4 },
572
- { name: "3xl", value: "30px", lineHeight: 1.4 },
573
- { name: "4xl", value: "36px", lineHeight: 1.3 },
574
- { name: "5xl", value: "48px", lineHeight: 1.15 },
575
- { name: "6xl", value: "60px", lineHeight: 1.1 },
576
- { name: "7xl", value: "72px", lineHeight: 1.05 },
577
- { name: "8xl", value: "96px", lineHeight: 1 },
578
- { name: "9xl", value: "128px", lineHeight: 1 },
579
- ],
580
- },
581
-
582
- shadows: {
583
- sm: "0 1px 2px rgba(0, 0, 0, 0.05)",
584
- base: "0 4px 6px rgba(0, 0, 0, 0.1)",
585
- md: "0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)",
586
- lg: "0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05)",
587
- xl: "0 20px 25px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.04)",
588
- "2xl": "0 25px 50px rgba(0, 0, 0, 0.25)",
589
- inner: "inset 0 2px 4px rgba(0, 0, 0, 0.06)",
590
- none: "none",
591
- },
592
-
593
- transitions: {
594
- fast: "100ms",
595
- base: "200ms",
596
- slow: "300ms",
597
- timing: "cubic-bezier(0.4, 0, 0.2, 1)",
598
- },
599
-
600
- zIndex: {
601
- auto: "auto",
602
- 0: "0",
603
- 10: "10",
604
- 20: "20",
605
- 30: "30",
606
- 40: "40",
607
- 50: "50",
608
- dropdown: "1000",
609
- sticky: "1020",
610
- fixed: "1030",
611
- modal: "1040",
612
- popover: "1060",
613
- tooltip: "1070",
614
- },
615
-
616
- opacity: [0, 5, 10, 25, 50, 75, 90, 95, 100],
617
- };
618
- }
619
-
620
- // ============================================================================
621
- // INIT
622
- // ============================================================================
623
-
624
- async function init() {
625
- console.log(
626
- chalk.bold.magenta("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
627
- );
628
- console.log(chalk.bold.magenta(" EmilyUI Setup"));
629
- console.log(
630
- chalk.bold.magenta("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"),
631
- );
632
-
633
- try {
634
- const spinner = ora("Analysing project structure...").start();
635
- const detectedProject = detectProject();
636
- spinner.succeed("Detected project: " + chalk.cyan(detectedProject.name));
637
- const existingConfig = readExistingConfig();
638
- const existingColours = isPlainObject(existingConfig && existingConfig.colours)
639
- ? existingConfig.colours
640
- : {};
641
-
642
- if (existingConfig) {
643
- console.log(
644
- chalk.gray(
645
- " Found existing emily.config.json. Prompts are pre-filled from current settings.",
646
- ),
647
- );
648
- }
649
-
650
- const packageJsonData = readPackageJson();
651
- const pkgName =
652
- existingConfig && typeof existingConfig.name === "string" && existingConfig.name.trim()
653
- ? existingConfig.name.trim()
654
- : packageJsonData && packageJsonData.name
655
- ? titleCasePackageName(packageJsonData.name)
656
- : "My Design System";
657
-
658
- const projectName = await new Input({
659
- name: "projectName",
467
+ baseUnit,
468
+ baseFontSize,
469
+ detectedProject,
470
+ }) {
471
+ return {
472
+ name,
473
+ description: name + " design system",
474
+
475
+ baseUnit,
476
+ baseFontSize: baseFontSize || "16px",
477
+
478
+ fontFamily: {
479
+ heading: headingFont,
480
+ body: bodyFont,
481
+ },
482
+
483
+ customFonts: [],
484
+
485
+ output: {
486
+ css: detectedProject.outputPath,
487
+ fullCss: "dist/emily.css",
488
+ },
489
+
490
+ manifest: true,
491
+
492
+ colours,
493
+
494
+ semanticColours: {
495
+ dark: "#1A1A1A",
496
+ light: "#FAFAFA",
497
+ },
498
+
499
+ purge: {
500
+ projectType: detectedProject.name,
501
+ sourceDir: detectedProject.sourceDir,
502
+ sourceGlobs: detectedProject.sourceGlobs,
503
+ ignore: DEFAULT_PURGE_IGNORE,
504
+ safelist: [
505
+ "bg-dark",
506
+ "text-dark",
507
+ "border-dark",
508
+ "fill-dark",
509
+ "bg-light",
510
+ "text-light",
511
+ "border-light",
512
+ "fill-light",
513
+ ],
514
+ extensions: PURGE_EXTENSIONS,
515
+ },
516
+
517
+ breakpoints: {
518
+ sm: "640px",
519
+ md: "768px",
520
+ lg: "1024px",
521
+ xl: "1280px",
522
+ "2xl": "1536px",
523
+ },
524
+
525
+ spacing: {
526
+ scale: {
527
+ 0: "0px",
528
+ px: "1px",
529
+ 0.5: "0.125rem",
530
+ 1: "0.25rem",
531
+ 1.5: "0.375rem",
532
+ 2: "0.5rem",
533
+ 2.5: "0.625rem",
534
+ 3: "0.75rem",
535
+ 3.5: "0.875rem",
536
+ 4: "1rem",
537
+ 5: "1.25rem",
538
+ 6: "1.5rem",
539
+ 7: "1.75rem",
540
+ 8: "2rem",
541
+ 9: "2.25rem",
542
+ 10: "2.5rem",
543
+ 11: "2.75rem",
544
+ 12: "3rem",
545
+ 14: "3.5rem",
546
+ 16: "4rem",
547
+ 20: "5rem",
548
+ 24: "6rem",
549
+ 28: "7rem",
550
+ 32: "8rem",
551
+ 36: "9rem",
552
+ 40: "10rem",
553
+ 44: "11rem",
554
+ 48: "12rem",
555
+ 52: "13rem",
556
+ 56: "14rem",
557
+ 60: "15rem",
558
+ 64: "16rem",
559
+ 72: "18rem",
560
+ 80: "20rem",
561
+ 96: "24rem",
562
+ },
563
+
564
+ borderWidths: [0, 2, 4, 8],
565
+
566
+ borderRadius: {
567
+ none: "0",
568
+ sm: "4px",
569
+ base: "8px",
570
+ md: "12px",
571
+ lg: "16px",
572
+ xl: "20px",
573
+ "2xl": "24px",
574
+ "3xl": "32px",
575
+ full: "9999px",
576
+ },
577
+ },
578
+
579
+ typography: {
580
+ lineHeightRatio: 1.5,
581
+
582
+ fontWeights: {
583
+ light: 300,
584
+ normal: 400,
585
+ medium: 500,
586
+ semibold: 600,
587
+ bold: 700,
588
+ },
589
+
590
+ fontSizes: [
591
+ { name: "xs", value: "12px", lineHeight: 1.5 },
592
+ { name: "sm", value: "14px", lineHeight: 1.5 },
593
+ { name: "base", value: "16px", lineHeight: 1.6 },
594
+ { name: "lg", value: "18px", lineHeight: 1.6 },
595
+ { name: "xl", value: "20px", lineHeight: 1.6 },
596
+ { name: "2xl", value: "24px", lineHeight: 1.4 },
597
+ { name: "3xl", value: "30px", lineHeight: 1.4 },
598
+ { name: "4xl", value: "36px", lineHeight: 1.3 },
599
+ { name: "5xl", value: "48px", lineHeight: 1.15 },
600
+ { name: "6xl", value: "60px", lineHeight: 1.1 },
601
+ { name: "7xl", value: "72px", lineHeight: 1.05 },
602
+ { name: "8xl", value: "96px", lineHeight: 1 },
603
+ { name: "9xl", value: "128px", lineHeight: 1 },
604
+ ],
605
+ },
606
+
607
+ shadows: {
608
+ sm: "0 1px 2px rgba(0, 0, 0, 0.05)",
609
+ base: "0 4px 6px rgba(0, 0, 0, 0.1)",
610
+ md: "0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)",
611
+ lg: "0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05)",
612
+ xl: "0 20px 25px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.04)",
613
+ "2xl": "0 25px 50px rgba(0, 0, 0, 0.25)",
614
+ inner: "inset 0 2px 4px rgba(0, 0, 0, 0.06)",
615
+ none: "none",
616
+ },
617
+
618
+ transitions: {
619
+ fast: "100ms",
620
+ base: "200ms",
621
+ slow: "300ms",
622
+ timing: "cubic-bezier(0.4, 0, 0.2, 1)",
623
+ },
624
+
625
+ zIndex: {
626
+ auto: "auto",
627
+ 0: "0",
628
+ 10: "10",
629
+ 20: "20",
630
+ 30: "30",
631
+ 40: "40",
632
+ 50: "50",
633
+ dropdown: "1000",
634
+ sticky: "1020",
635
+ fixed: "1030",
636
+ modal: "1040",
637
+ popover: "1060",
638
+ tooltip: "1070",
639
+ },
640
+
641
+ opacity: [0, 5, 10, 25, 50, 75, 90, 95, 100],
642
+ };
643
+ }
644
+
645
+ // ============================================================================
646
+ // INIT
647
+ // ============================================================================
648
+
649
+ async function init() {
650
+ console.log(
651
+ chalk.bold.magenta("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
652
+ );
653
+ console.log(chalk.bold.magenta(" EmilyUI Setup"));
654
+ console.log(
655
+ chalk.bold.magenta("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"),
656
+ );
657
+
658
+ try {
659
+ const spinner = ora("Analysing project structure...").start();
660
+ const detectedProject = detectProject();
661
+ spinner.succeed("Detected project: " + chalk.cyan(detectedProject.name));
662
+ const existingConfig = readExistingConfig();
663
+ const existingColours = isPlainObject(existingConfig && existingConfig.colours)
664
+ ? existingConfig.colours
665
+ : {};
666
+
667
+ if (existingConfig) {
668
+ console.log(
669
+ chalk.gray(
670
+ " Found existing emily.config.json. Prompts are pre-filled from current settings.",
671
+ ),
672
+ );
673
+ }
674
+
675
+ const packageJsonData = readPackageJson();
676
+ const pkgName =
677
+ existingConfig && typeof existingConfig.name === "string" && existingConfig.name.trim()
678
+ ? existingConfig.name.trim()
679
+ : packageJsonData && packageJsonData.name
680
+ ? titleCasePackageName(packageJsonData.name)
681
+ : "My Design System";
682
+
683
+ const projectName = await askValidatedInput({
684
+ promptName: "projectName",
660
685
  message: "Project name",
661
686
  initial: pkgName,
662
- validate: function (value) {
663
- return value.trim() ? true : "Project name is required";
687
+ validator: function (value) {
688
+ if (typeof value !== "string" || !value.trim()) {
689
+ return { valid: false, reason: "project name is required" };
690
+ }
691
+ return { valid: true };
664
692
  },
665
- }).run();
666
-
667
- if (!projectName || !projectName.trim()) {
668
- console.log(chalk.red("\nProject name is required.\n"));
669
- process.exit(1);
670
- }
671
-
672
- // =========================================================================
673
- // COLOURS
674
- // =========================================================================
675
-
676
- console.log(chalk.bold("\n" + chalk.magenta("→") + " Brand colours"));
677
-
678
- const brand = await askColourFromPresets(
679
- "brand",
680
- COLOUR_PRESETS.primary,
681
- "#DB2777",
682
- existingColours.brand,
683
- );
684
- const accent = await askColourFromPresets(
685
- "accent",
686
- COLOUR_PRESETS.secondary,
687
- "#2563EB",
688
- existingColours.accent,
689
- );
690
-
691
- console.log(
692
- chalk.gray(
693
- "\n Button colour tokens will use your brand colours by default:",
694
- ),
695
- );
696
- console.log(chalk.gray(" - btn-primary = brand"));
697
- console.log(chalk.gray(" - btn-secondary = accent"));
698
-
699
- console.log(chalk.bold("\n" + chalk.magenta("→") + " Utility colours"));
693
+ });
694
+
695
+ if (!projectName || !projectName.trim()) {
696
+ console.log(chalk.red("\nProject name is required.\n"));
697
+ process.exit(1);
698
+ }
699
+
700
+ // =========================================================================
701
+ // COLOURS
702
+ // =========================================================================
703
+
704
+ console.log(chalk.bold("\n" + chalk.magenta("→") + " Brand colours"));
705
+
706
+ const brand = await askColourFromPresets(
707
+ "brand",
708
+ COLOUR_PRESETS.primary,
709
+ "#DB2777",
710
+ existingColours.brand,
711
+ );
712
+ const accent = await askColourFromPresets(
713
+ "accent",
714
+ COLOUR_PRESETS.secondary,
715
+ "#2563EB",
716
+ existingColours.accent,
717
+ );
718
+
719
+ console.log(
720
+ chalk.gray(
721
+ "\n Button colour tokens will use your brand colours by default:",
722
+ ),
723
+ );
724
+ console.log(chalk.gray(" - btn-primary = brand"));
725
+ console.log(chalk.gray(" - btn-secondary = accent"));
726
+
727
+ console.log(chalk.bold("\n" + chalk.magenta("→") + " Utility colours"));
728
+ console.log(
729
+ chalk.gray(
730
+ " Defaults shown. Press enter to accept or pick an alternative.\n",
731
+ ),
732
+ );
733
+
734
+ const success = await askColourFromPresets(
735
+ "success",
736
+ COLOUR_PRESETS.success,
737
+ "#017F65",
738
+ existingColours.success,
739
+ );
740
+ const warning = await askColourFromPresets(
741
+ "warning",
742
+ COLOUR_PRESETS.warning,
743
+ "#FFC107",
744
+ existingColours.warning,
745
+ );
746
+ const error = await askColourFromPresets(
747
+ "error",
748
+ COLOUR_PRESETS.error,
749
+ "#B20000",
750
+ existingColours.error,
751
+ );
752
+
753
+ const colours = {
754
+ brand,
755
+ accent,
756
+ "btn-primary": brand,
757
+ "btn-secondary": accent,
758
+ success,
759
+ warning,
760
+ error,
761
+ neutral: normaliseHex(existingColours.neutral) || "#57534E",
762
+ ...getExistingAdditionalColours(existingColours),
763
+ };
764
+
765
+ let addingMore = true;
766
+
767
+ while (addingMore) {
768
+ const wantsMore = await new Confirm({
769
+ name: "addMore",
770
+ message: "Add another utility colour?",
771
+ initial: false,
772
+ }).run();
773
+
774
+ if (!wantsMore) {
775
+ addingMore = false;
776
+ break;
777
+ }
778
+
779
+ const customName = await new Input({
780
+ name: "customName",
781
+ message: "Colour name (e.g. accent, highlight, brand-dark)",
782
+ validate: function (value) {
783
+ const trimmed = value.trim();
784
+
785
+ if (!trimmed) return "Name is required";
786
+ if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
787
+ return "Use lowercase letters, numbers, and hyphens only";
788
+ }
789
+ if (colours[trimmed]) return '"' + trimmed + '" is already defined';
790
+
791
+ return true;
792
+ },
793
+ }).run();
794
+
795
+ colours[customName.trim()] = await askHex(
796
+ "hex-" + customName,
797
+ "Hex for " + customName,
798
+ "#000000",
799
+ );
800
+ }
801
+
802
+ // =========================================================================
803
+ // TYPOGRAPHY
804
+ // =========================================================================
805
+
806
+ console.log(chalk.bold("\n" + chalk.magenta("→") + " Typography"));
807
+
700
808
  console.log(
701
809
  chalk.gray(
702
- " Defaults shown. Press enter to accept or pick an alternative.\n",
810
+ " Allowed font families: " + ALLOWED_FONT_FAMILIES.join(", "),
703
811
  ),
704
812
  );
705
813
 
706
- const success = await askColourFromPresets(
707
- "success",
708
- COLOUR_PRESETS.success,
709
- "#017F65",
710
- existingColours.success,
711
- );
712
- const warning = await askColourFromPresets(
713
- "warning",
714
- COLOUR_PRESETS.warning,
715
- "#FFC107",
716
- existingColours.warning,
717
- );
718
- const error = await askColourFromPresets(
719
- "error",
720
- COLOUR_PRESETS.error,
721
- "#B20000",
722
- existingColours.error,
723
- );
724
-
725
- const colours = {
726
- brand,
727
- accent,
728
- "btn-primary": brand,
729
- "btn-secondary": accent,
730
- success,
731
- warning,
732
- error,
733
- neutral: normaliseHex(existingColours.neutral) || "#57534E",
734
- ...getExistingAdditionalColours(existingColours),
735
- };
736
-
737
- let addingMore = true;
738
-
739
- while (addingMore) {
740
- const wantsMore = await new Confirm({
741
- name: "addMore",
742
- message: "Add another utility colour?",
743
- initial: false,
744
- }).run();
745
-
746
- if (!wantsMore) {
747
- addingMore = false;
748
- break;
749
- }
750
-
751
- const customName = await new Input({
752
- name: "customName",
753
- message: "Colour name (e.g. accent, highlight, brand-dark)",
754
- validate: function (value) {
755
- const trimmed = value.trim();
756
-
757
- if (!trimmed) return "Name is required";
758
- if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
759
- return "Use lowercase letters, numbers, and hyphens only";
760
- }
761
- if (colours[trimmed]) return '"' + trimmed + '" is already defined';
762
-
763
- return true;
764
- },
765
- }).run();
766
-
767
- colours[customName.trim()] = await askHex(
768
- "hex-" + customName,
769
- "Hex for " + customName,
770
- "#000000",
771
- );
772
- }
773
-
774
- // =========================================================================
775
- // TYPOGRAPHY
776
- // =========================================================================
777
-
778
- console.log(chalk.bold("\n" + chalk.magenta("→") + " Typography"));
779
-
780
- const headingFont = await new Select({
781
- name: "headingFont",
782
- message: "Heading font",
783
- choices: FONT_OPTIONS,
784
- initial: getFontInitialIndex(
785
- isPlainObject(existingConfig && existingConfig.fontFamily)
814
+ const headingFont = await askValidatedInput({
815
+ promptName: "headingFont",
816
+ message: "Heading font family",
817
+ initial: (function () {
818
+ const existingHeading = isPlainObject(existingConfig && existingConfig.fontFamily)
786
819
  ? existingConfig.fontFamily.heading
787
- : existingConfig && existingConfig.fontFamily,
788
- 0,
789
- ),
790
- }).run();
820
+ : existingConfig && existingConfig.fontFamily;
821
+ if (typeof existingHeading !== "string") return "lexend";
822
+ const candidate = existingHeading.trim().toLowerCase();
823
+ return validateFontFamily(candidate).valid ? candidate : "lexend";
824
+ })(),
825
+ validator: validateFontFamily,
826
+ normalise: function (value) {
827
+ return String(value).trim().toLowerCase();
828
+ },
829
+ });
791
830
 
792
- const bodyFont = await new Select({
793
- name: "bodyFont",
794
- message: "Body font",
795
- choices: FONT_OPTIONS,
796
- initial: getFontInitialIndex(
797
- isPlainObject(existingConfig && existingConfig.fontFamily)
831
+ const bodyFont = await askValidatedInput({
832
+ promptName: "bodyFont",
833
+ message: "Body font family",
834
+ initial: (function () {
835
+ const existingBody = isPlainObject(existingConfig && existingConfig.fontFamily)
798
836
  ? existingConfig.fontFamily.body
799
- : existingConfig && existingConfig.fontFamily,
800
- 1,
801
- ),
802
- }).run();
803
-
804
- // =========================================================================
805
- // SPACING
806
- // =========================================================================
807
-
808
- const baseUnitRaw = await new Input({
809
- name: "baseUnit",
810
- message: "Base spacing unit in px (label/documentation only)",
811
- initial: getBaseUnitInitial(existingConfig),
812
- validate: function (value) {
813
- const parsed = Number.parseInt(value, 10);
814
-
815
- if (Number.isNaN(parsed) || parsed <= 0) {
816
- return "Must be a positive number.";
817
- }
818
-
819
- return true;
837
+ : existingConfig && existingConfig.fontFamily;
838
+ if (typeof existingBody !== "string") return "inter";
839
+ const candidate = existingBody.trim().toLowerCase();
840
+ return validateFontFamily(candidate).valid ? candidate : "inter";
841
+ })(),
842
+ validator: validateFontFamily,
843
+ normalise: function (value) {
844
+ return String(value).trim().toLowerCase();
820
845
  },
821
- }).run();
822
-
823
- const baseUnit = Number.parseInt(baseUnitRaw, 10);
824
-
825
- // =========================================================================
826
- // PURGE / OUTPUT
827
- // =========================================================================
828
-
829
- console.log(chalk.bold("\n" + chalk.magenta("→") + " Project files"));
830
-
831
- console.log(
832
- chalk.gray(
833
- " Detected " +
834
- detectedProject.name +
835
- ". EmilyCSS will scan the recommended files automatically.",
836
- ),
837
- );
838
-
839
- detectedProject.sourceGlobs.forEach(function (glob) {
840
- console.log(chalk.gray(" - " + glob));
841
846
  });
842
-
843
- console.log(chalk.bold("\n" + chalk.magenta("→") + " CSS output"));
844
- console.log(chalk.gray(" Output: " + detectedProject.outputPath));
845
-
846
- // =========================================================================
847
- // BUILD
848
- // =========================================================================
849
-
850
- const generatedDefaults = createDefaultConfig({
851
- name: projectName.trim(),
852
- colours,
853
- headingFont,
854
- bodyFont,
855
- baseUnit,
856
- detectedProject,
847
+
848
+ const baseFontSize = await new Select({
849
+ name: "baseFontSize",
850
+ message: "Base font size (sets html font-size, scales all rem values)",
851
+ choices: ["14px", "16px", "18px", "20px"],
852
+ initial: (function () {
853
+ const existing = existingConfig && existingConfig.baseFontSize;
854
+ const idx = ["14px", "16px", "18px", "20px"].indexOf(existing);
855
+ return idx >= 0 ? idx : 1;
856
+ })(),
857
+ }).run();
858
+
859
+ // =========================================================================
860
+ // SPACING
861
+ // =========================================================================
862
+
863
+ const baseUnit = await askValidatedInput({
864
+ promptName: "baseUnit",
865
+ message: "Base spacing unit in px (label/documentation only) e.g. 1rem or 10px",
866
+ initial: getBaseUnitInitial(existingConfig),
867
+ validator: validateSpacingValue,
868
+ normalise: function (value) {
869
+ return String(value).trim().toLowerCase();
870
+ },
857
871
  });
858
- const config = mergeWithDefaults(generatedDefaults, existingConfig);
859
- config.name = projectName.trim();
860
-
861
- if (!existingConfig || !existingConfig.description) {
862
- config.description = config.name + " design system";
863
- }
864
-
865
- config.baseUnit = baseUnit + "px";
872
+
873
+ // =========================================================================
874
+ // PURGE / OUTPUT
875
+ // =========================================================================
876
+
877
+ console.log(chalk.bold("\n" + chalk.magenta("→") + " Project files"));
878
+
879
+ console.log(
880
+ chalk.gray(
881
+ " Detected " +
882
+ detectedProject.name +
883
+ ". EmilyCSS will scan the recommended files automatically.",
884
+ ),
885
+ );
886
+
887
+ detectedProject.sourceGlobs.forEach(function (glob) {
888
+ console.log(chalk.gray(" - " + glob));
889
+ });
890
+
891
+ console.log(chalk.bold("\n" + chalk.magenta("→") + " CSS output"));
892
+ console.log(chalk.gray(" Output: " + detectedProject.outputPath));
893
+
894
+ // =========================================================================
895
+ // BUILD
896
+ // =========================================================================
897
+
898
+ const generatedDefaults = createDefaultConfig({
899
+ name: projectName.trim(),
900
+ colours,
901
+ headingFont,
902
+ bodyFont,
903
+ baseUnit,
904
+ baseFontSize,
905
+ detectedProject,
906
+ });
907
+ const config = mergeWithDefaults(generatedDefaults, existingConfig);
908
+ config.name = projectName.trim();
909
+
910
+ if (!existingConfig || !existingConfig.description) {
911
+ config.description = config.name + " design system";
912
+ }
913
+
914
+ config.baseUnit = baseUnit;
915
+ config.baseFontSize = baseFontSize || "16px";
866
916
  config.fontFamily = {
867
917
  heading: headingFont,
868
918
  body: bodyFont,
869
919
  };
870
920
  config.colours = colours;
871
921
 
872
- const configPath = path.join(process.cwd(), "emily.config.json");
873
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
874
-
875
- console.log("");
876
-
877
- const buildSpinner = ora("Building EmilyUI CSS...").start();
878
-
879
- const build = crossSpawn("npx", ["emily-css", "build"], {
880
- cwd: process.cwd(),
881
- stdio: "pipe",
882
- shell: process.platform === "win32",
883
- });
884
-
885
- let stderr = "";
886
-
887
- build.stderr.on("data", function (data) {
888
- stderr += data.toString();
889
- });
890
-
891
- build.on("close", async function (code) {
892
- if (code === 0) {
893
- buildSpinner.succeed("EmilyUI CSS built successfully.");
894
-
895
- const scriptsAdded = addEmilyScriptsToPackageJson();
896
-
897
- console.log(
898
- "\n" +
899
- boxen(
900
- chalk.green.bold("Setup complete") +
901
- "\n\nConfig: " +
902
- chalk.cyan("emily.config.json") +
903
- "\nOutput: " +
904
- chalk.cyan(config.output.css) +
905
- "\nProject: " +
906
- chalk.cyan(detectedProject.name) +
907
- "\nScan:\n " +
908
- chalk.cyan(config.purge.sourceGlobs.join("\n ")) +
909
- "\n\nNext: add this stylesheet to your project:" +
910
- "\n" +
911
- chalk.yellow(" " + detectedProject.linkHint) +
912
- (scriptsAdded
913
- ? "\n\nScripts added:\n" +
914
- chalk.cyan(" npm run emily:build\n") +
915
- chalk.cyan(" npm run emily:watch\n") +
916
- chalk.cyan(" npm run emily:doctor\n") +
917
- chalk.cyan(" npm run emily:migrate\n") +
918
- chalk.cyan(" npm run emily:info\n") +
919
- chalk.cyan(" npm run emily:manifest\n") +
920
- chalk.cyan(" npm run emily:version\n") +
921
- chalk.cyan(" npm run emily:showcase\n") +
922
- chalk.cyan(" npm run emily:help")
923
- : ""),
924
- {
925
- padding: 1,
926
- margin: 1,
927
- borderStyle: "round",
928
- borderColor: "magenta",
929
- },
930
- ),
931
- );
932
-
933
- const startWatch = await new Confirm({
934
- name: "startWatch",
935
- message: "Start the file watcher now?",
936
- initial: true,
937
- }).run();
938
-
939
- if (startWatch) {
940
- console.log(
941
- chalk.cyan("\nStarting watcher. Press Ctrl+C to stop.\n"),
942
- );
943
-
944
- const watcher = crossSpawn("npx", ["emily-css", "watch"], {
945
- cwd: process.cwd(),
946
- stdio: "inherit",
947
- shell: process.platform === "win32",
948
- });
949
-
950
- watcher.on("close", function (c) {
951
- process.exit(c || 0);
952
- });
953
- } else {
954
- console.log(
955
- chalk.gray(
956
- "\nRun the watcher any time with: npm run emily:watch\n",
957
- ),
958
- );
959
- process.exit(0);
960
- }
961
-
962
- return;
963
- }
964
-
965
- buildSpinner.fail("Automatic build failed.");
966
- console.log("\nYour config was created, but CSS was not built.");
967
- console.log("\nRun manually:\n");
968
- console.log(chalk.cyan(" npx emily-css build"));
969
-
970
- if (stderr.trim()) {
971
- console.log(chalk.gray("\nBuild error:\n"));
972
- console.log(stderr.trim());
973
- }
974
-
975
- process.exit(1);
976
- });
977
-
978
- build.on("error", function (error) {
979
- buildSpinner.fail("Automatic build failed.");
980
- console.log("\nYour config was created, but CSS was not built.");
981
- console.log("Reason: " + error.message);
982
- console.log("\nRun manually:\n");
983
- console.log(chalk.cyan(" npx emily-css build\n"));
922
+ const finalValidation = validateConfigShape(config);
923
+ if (!finalValidation.valid) {
924
+ console.log(chalk.red("\n✗ Config validation failed. emily.config.json was not written.\n"));
925
+ finalValidation.errors.forEach(function (error) {
926
+ console.log(chalk.red(" - " + error));
927
+ });
984
928
  process.exit(1);
985
- });
986
- } catch (error) {
987
- console.log(chalk.red("\nSetup cancelled or failed."));
988
-
989
- if (error && error.message) {
990
- console.log(chalk.gray(error.message));
991
929
  }
992
930
 
993
- process.exit(1);
994
- }
995
- }
996
-
997
- init();
931
+ const configPath = path.join(process.cwd(), "emily.config.json");
932
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
933
+
934
+ console.log("");
935
+
936
+ const buildSpinner = ora("Building EmilyUI CSS...").start();
937
+
938
+ const build = crossSpawn("npx", ["emily-css", "build"], {
939
+ cwd: process.cwd(),
940
+ stdio: "pipe",
941
+ shell: process.platform === "win32",
942
+ });
943
+
944
+ let stderr = "";
945
+
946
+ build.stderr.on("data", function (data) {
947
+ stderr += data.toString();
948
+ });
949
+
950
+ build.on("close", async function (code) {
951
+ if (code === 0) {
952
+ buildSpinner.succeed("EmilyUI CSS built successfully.");
953
+
954
+ const scriptsAdded = addEmilyScriptsToPackageJson();
955
+
956
+ console.log(
957
+ "\n" +
958
+ boxen(
959
+ chalk.green.bold("Setup complete") +
960
+ "\n\nConfig: " +
961
+ chalk.cyan("emily.config.json") +
962
+ "\nOutput: " +
963
+ chalk.cyan(config.output.css) +
964
+ "\nProject: " +
965
+ chalk.cyan(detectedProject.name) +
966
+ "\nScan:\n " +
967
+ chalk.cyan(config.purge.sourceGlobs.join("\n ")) +
968
+ "\n\nNext: add this stylesheet to your project:" +
969
+ "\n" +
970
+ chalk.yellow(" " + detectedProject.linkHint) +
971
+ (scriptsAdded
972
+ ? "\n\nScripts added:\n" +
973
+ chalk.cyan(" npm run emily:build\n") +
974
+ chalk.cyan(" npm run emily:watch\n") +
975
+ chalk.cyan(" npm run emily:doctor\n") +
976
+ chalk.cyan(" npm run emily:migrate\n") +
977
+ chalk.cyan(" npm run emily:info\n") +
978
+ chalk.cyan(" npm run emily:manifest\n") +
979
+ chalk.cyan(" npm run emily:version\n") +
980
+ chalk.cyan(" npm run emily:showcase\n") +
981
+ chalk.cyan(" npm run emily:help")
982
+ : ""),
983
+ {
984
+ padding: 1,
985
+ margin: 1,
986
+ borderStyle: "round",
987
+ borderColor: "magenta",
988
+ },
989
+ ),
990
+ );
991
+
992
+ const startWatch = await new Confirm({
993
+ name: "startWatch",
994
+ message: "Start the file watcher now?",
995
+ initial: true,
996
+ }).run();
997
+
998
+ if (startWatch) {
999
+ console.log(
1000
+ chalk.cyan("\nStarting watcher. Press Ctrl+C to stop.\n"),
1001
+ );
1002
+
1003
+ const watcher = crossSpawn("npx", ["emily-css", "watch"], {
1004
+ cwd: process.cwd(),
1005
+ stdio: "inherit",
1006
+ shell: process.platform === "win32",
1007
+ });
1008
+
1009
+ watcher.on("close", function (c) {
1010
+ process.exit(c || 0);
1011
+ });
1012
+ } else {
1013
+ console.log(
1014
+ chalk.gray(
1015
+ "\nRun the watcher any time with: npm run emily:watch\n",
1016
+ ),
1017
+ );
1018
+ process.exit(0);
1019
+ }
1020
+
1021
+ return;
1022
+ }
1023
+
1024
+ buildSpinner.fail("Automatic build failed.");
1025
+ console.log("\nYour config was created, but CSS was not built.");
1026
+ console.log("\nRun manually:\n");
1027
+ console.log(chalk.cyan(" npx emily-css build"));
1028
+
1029
+ if (stderr.trim()) {
1030
+ console.log(chalk.gray("\nBuild error:\n"));
1031
+ console.log(stderr.trim());
1032
+ }
1033
+
1034
+ process.exit(1);
1035
+ });
1036
+
1037
+ build.on("error", function (error) {
1038
+ buildSpinner.fail("Automatic build failed.");
1039
+ console.log("\nYour config was created, but CSS was not built.");
1040
+ console.log("Reason: " + error.message);
1041
+ console.log("\nRun manually:\n");
1042
+ console.log(chalk.cyan(" npx emily-css build\n"));
1043
+ process.exit(1);
1044
+ });
1045
+ } catch (error) {
1046
+ console.log(chalk.red("\nSetup cancelled or failed."));
1047
+
1048
+ if (error && error.message) {
1049
+ console.log(chalk.gray(error.message));
1050
+ }
1051
+
1052
+ process.exit(1);
1053
+ }
1054
+ }
1055
+
1056
+ init();