emily-css 1.2.9 → 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/CHANGELOG.md +12 -0
- package/package.json +3 -2
- package/src/index.js +7 -5
- package/src/init.js +162 -117
- package/src/test/e2e.test.js +332 -0
- package/src/validate.js +227 -0
- package/src/validateConfig.js +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `emily-css` are documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## v1.2.10 — May 2026
|
|
8
|
+
|
|
9
|
+
**v1.2.10 — Config validation & CI/CD**
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- feat: add config validation, E2E chaos testing, and GitHub Actions CI
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- add randomized E2E config fuzzing suite
|
|
16
|
+
- add GitHub Actions test workflow and PR template
|
|
17
|
+
|
|
18
|
+
---
|
|
7
19
|
## v1.2.9 — May 2026
|
|
8
20
|
|
|
9
21
|
**Fix baseFontSize handling in emily-css init and generated CSS**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emily-css",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.10",
|
|
4
4
|
"description": "A config-driven utility CSS framework. Define your brand once, generate the CSS.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"dev": "nodemon src/index.js",
|
|
20
20
|
"dev:full": "nodemon src/index.js -- --keep-full",
|
|
21
21
|
"init": "node src/init.js",
|
|
22
|
-
"test": "node tests/test.js",
|
|
22
|
+
"test": "node tests/test.js && node src/test/e2e.test.js",
|
|
23
|
+
"test:e2e": "node src/test/e2e.test.js",
|
|
23
24
|
"emily:showcase": "node src/showcase.js",
|
|
24
25
|
"commit": "node scripts/commit.js",
|
|
25
26
|
"release": "node scripts/release.js",
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { generateManifest } = require('./manifest');
|
|
6
|
-
const { generateIntellisense } = require('./intellisense');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { generateManifest } = require('./manifest');
|
|
6
|
+
const { generateIntellisense } = require('./intellisense');
|
|
7
|
+
const { validateConfigOrExit } = require('./validateConfig');
|
|
7
8
|
const {
|
|
8
9
|
getConfigPath,
|
|
9
10
|
getConfig,
|
|
@@ -1508,8 +1509,9 @@ function generatePatternComponents() {
|
|
|
1508
1509
|
// BUILD FUNCTION
|
|
1509
1510
|
// ============================================================================
|
|
1510
1511
|
|
|
1511
|
-
function buildFullFramework() {
|
|
1512
|
-
|
|
1512
|
+
function buildFullFramework() {
|
|
1513
|
+
validateConfigOrExit();
|
|
1514
|
+
const config = getConfig();
|
|
1513
1515
|
|
|
1514
1516
|
console.log('Building EmilyCSS full framework...');
|
|
1515
1517
|
|
package/src/init.js
CHANGED
|
@@ -3,9 +3,16 @@ const path = require("path");
|
|
|
3
3
|
const crossSpawn = require("cross-spawn");
|
|
4
4
|
const { Select, Input, Confirm } = require("enquirer");
|
|
5
5
|
const chalk = require("chalk");
|
|
6
|
-
const ora = require("ora");
|
|
7
|
-
const boxen = require("boxen");
|
|
8
|
-
const { DEFAULT_PURGE_IGNORE, PURGE_EXTENSIONS } = require("./constants.js");
|
|
6
|
+
const ora = require("ora");
|
|
7
|
+
const boxen = require("boxen");
|
|
8
|
+
const { DEFAULT_PURGE_IGNORE, PURGE_EXTENSIONS } = require("./constants.js");
|
|
9
|
+
const {
|
|
10
|
+
ALLOWED_FONT_FAMILIES,
|
|
11
|
+
validateHexColour,
|
|
12
|
+
validateSpacingValue,
|
|
13
|
+
validateFontFamily,
|
|
14
|
+
validateConfigShape,
|
|
15
|
+
} = require("./validate.js");
|
|
9
16
|
|
|
10
17
|
// ============================================================================
|
|
11
18
|
// CONSTANTS
|
|
@@ -48,15 +55,7 @@ const COLOUR_PRESETS = {
|
|
|
48
55
|
],
|
|
49
56
|
};
|
|
50
57
|
|
|
51
|
-
const
|
|
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
|
-
];
|
|
59
|
-
const CORE_COLOUR_KEYS = new Set([
|
|
58
|
+
const CORE_COLOUR_KEYS = new Set([
|
|
60
59
|
"brand",
|
|
61
60
|
"accent",
|
|
62
61
|
"btn-primary",
|
|
@@ -71,13 +70,9 @@ const CORE_COLOUR_KEYS = new Set([
|
|
|
71
70
|
// HELPERS
|
|
72
71
|
// ============================================================================
|
|
73
72
|
|
|
74
|
-
function
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
function isPlainObject(value) {
|
|
79
|
-
return (
|
|
80
|
-
value !== null &&
|
|
73
|
+
function isPlainObject(value) {
|
|
74
|
+
return (
|
|
75
|
+
value !== null &&
|
|
81
76
|
typeof value === "object" &&
|
|
82
77
|
!Array.isArray(value)
|
|
83
78
|
);
|
|
@@ -110,26 +105,63 @@ function colourSwatch(hex) {
|
|
|
110
105
|
return chalk.hex(hex)("■");
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
function normaliseHex(value) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
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;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatValueForMessage(value) {
|
|
116
|
+
if (value === null) return "null";
|
|
117
|
+
if (value === undefined) return "undefined";
|
|
118
|
+
return "'" + String(value) + "'";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function askValidatedInput({
|
|
122
|
+
promptName,
|
|
123
|
+
message,
|
|
124
|
+
initial,
|
|
125
|
+
validator,
|
|
126
|
+
normalise,
|
|
127
|
+
}) {
|
|
128
|
+
let nextInitial = initial;
|
|
129
|
+
|
|
130
|
+
while (true) {
|
|
131
|
+
const raw = await new Input({
|
|
132
|
+
name: promptName,
|
|
133
|
+
message,
|
|
134
|
+
initial: nextInitial,
|
|
135
|
+
}).run();
|
|
136
|
+
|
|
137
|
+
const value = typeof raw === "string" ? raw.trim() : raw;
|
|
138
|
+
const result = validator(value);
|
|
139
|
+
|
|
140
|
+
if (result.valid) {
|
|
141
|
+
console.log(chalk.green("✓ Valid"));
|
|
142
|
+
return typeof normalise === "function" ? normalise(value) : value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(
|
|
146
|
+
chalk.red(
|
|
147
|
+
"✗ Invalid: " + result.reason + " (got " + formatValueForMessage(raw) + ")",
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
nextInitial = value || initial;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function askHex(promptName, message, initial) {
|
|
155
|
+
return askValidatedInput({
|
|
156
|
+
promptName,
|
|
157
|
+
message,
|
|
158
|
+
initial: initial || "#000000",
|
|
159
|
+
validator: validateHexColour,
|
|
160
|
+
normalise: function (value) {
|
|
161
|
+
return String(value).toUpperCase();
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
133
165
|
|
|
134
166
|
async function askColourFromPresets(label, presets, defaultHex, currentHex) {
|
|
135
167
|
const defaultHexValue = normaliseHex(defaultHex);
|
|
@@ -224,14 +256,7 @@ function readExistingConfig() {
|
|
|
224
256
|
}
|
|
225
257
|
}
|
|
226
258
|
|
|
227
|
-
function
|
|
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
|
-
|
|
234
|
-
function getExistingAdditionalColours(existingColours) {
|
|
259
|
+
function getExistingAdditionalColours(existingColours) {
|
|
235
260
|
if (!isPlainObject(existingColours)) return {};
|
|
236
261
|
|
|
237
262
|
const additional = {};
|
|
@@ -247,14 +272,13 @@ function getExistingAdditionalColours(existingColours) {
|
|
|
247
272
|
return additional;
|
|
248
273
|
}
|
|
249
274
|
|
|
250
|
-
function getBaseUnitInitial(config) {
|
|
251
|
-
const rawBaseUnit = config && typeof config.baseUnit === "string"
|
|
252
|
-
? config.baseUnit
|
|
253
|
-
: "";
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
275
|
+
function getBaseUnitInitial(config) {
|
|
276
|
+
const rawBaseUnit = config && typeof config.baseUnit === "string"
|
|
277
|
+
? config.baseUnit.trim()
|
|
278
|
+
: "";
|
|
279
|
+
if (validateSpacingValue(rawBaseUnit).valid) return rawBaseUnit;
|
|
280
|
+
return "18px";
|
|
281
|
+
}
|
|
258
282
|
|
|
259
283
|
function hasDependency(packageJson, dependencyName) {
|
|
260
284
|
if (!packageJson) return false;
|
|
@@ -435,11 +459,11 @@ function detectProject() {
|
|
|
435
459
|
// CONFIG BUILDER
|
|
436
460
|
// ============================================================================
|
|
437
461
|
|
|
438
|
-
function createDefaultConfig({
|
|
439
|
-
name,
|
|
440
|
-
colours,
|
|
441
|
-
headingFont,
|
|
442
|
-
bodyFont,
|
|
462
|
+
function createDefaultConfig({
|
|
463
|
+
name,
|
|
464
|
+
colours,
|
|
465
|
+
headingFont,
|
|
466
|
+
bodyFont,
|
|
443
467
|
baseUnit,
|
|
444
468
|
baseFontSize,
|
|
445
469
|
detectedProject,
|
|
@@ -448,7 +472,7 @@ function createDefaultConfig({
|
|
|
448
472
|
name,
|
|
449
473
|
description: name + " design system",
|
|
450
474
|
|
|
451
|
-
baseUnit
|
|
475
|
+
baseUnit,
|
|
452
476
|
baseFontSize: baseFontSize || "16px",
|
|
453
477
|
|
|
454
478
|
fontFamily: {
|
|
@@ -656,14 +680,17 @@ async function init() {
|
|
|
656
680
|
? titleCasePackageName(packageJsonData.name)
|
|
657
681
|
: "My Design System";
|
|
658
682
|
|
|
659
|
-
const projectName = await
|
|
660
|
-
|
|
661
|
-
message: "Project name",
|
|
662
|
-
initial: pkgName,
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
683
|
+
const projectName = await askValidatedInput({
|
|
684
|
+
promptName: "projectName",
|
|
685
|
+
message: "Project name",
|
|
686
|
+
initial: pkgName,
|
|
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 };
|
|
692
|
+
},
|
|
693
|
+
});
|
|
667
694
|
|
|
668
695
|
if (!projectName || !projectName.trim()) {
|
|
669
696
|
console.log(chalk.red("\nProject name is required.\n"));
|
|
@@ -778,29 +805,45 @@ async function init() {
|
|
|
778
805
|
|
|
779
806
|
console.log(chalk.bold("\n" + chalk.magenta("→") + " Typography"));
|
|
780
807
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
808
|
+
console.log(
|
|
809
|
+
chalk.gray(
|
|
810
|
+
" Allowed font families: " + ALLOWED_FONT_FAMILIES.join(", "),
|
|
811
|
+
),
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
const headingFont = await askValidatedInput({
|
|
815
|
+
promptName: "headingFont",
|
|
816
|
+
message: "Heading font family",
|
|
817
|
+
initial: (function () {
|
|
818
|
+
const existingHeading = isPlainObject(existingConfig && existingConfig.fontFamily)
|
|
819
|
+
? existingConfig.fontFamily.heading
|
|
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
|
+
});
|
|
830
|
+
|
|
831
|
+
const bodyFont = await askValidatedInput({
|
|
832
|
+
promptName: "bodyFont",
|
|
833
|
+
message: "Body font family",
|
|
834
|
+
initial: (function () {
|
|
835
|
+
const existingBody = isPlainObject(existingConfig && existingConfig.fontFamily)
|
|
836
|
+
? existingConfig.fontFamily.body
|
|
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();
|
|
845
|
+
},
|
|
846
|
+
});
|
|
804
847
|
|
|
805
848
|
const baseFontSize = await new Select({
|
|
806
849
|
name: "baseFontSize",
|
|
@@ -817,22 +860,15 @@ async function init() {
|
|
|
817
860
|
// SPACING
|
|
818
861
|
// =========================================================================
|
|
819
862
|
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
message: "Base spacing unit in px (label/documentation only)",
|
|
823
|
-
initial: getBaseUnitInitial(existingConfig),
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
return true;
|
|
832
|
-
},
|
|
833
|
-
}).run();
|
|
834
|
-
|
|
835
|
-
const baseUnit = Number.parseInt(baseUnitRaw, 10);
|
|
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
|
+
},
|
|
871
|
+
});
|
|
836
872
|
|
|
837
873
|
// =========================================================================
|
|
838
874
|
// PURGE / OUTPUT
|
|
@@ -875,15 +911,24 @@ async function init() {
|
|
|
875
911
|
config.description = config.name + " design system";
|
|
876
912
|
}
|
|
877
913
|
|
|
878
|
-
config.baseUnit = baseUnit
|
|
879
|
-
config.baseFontSize = baseFontSize || "16px";
|
|
880
|
-
config.fontFamily = {
|
|
881
|
-
heading: headingFont,
|
|
882
|
-
body: bodyFont,
|
|
883
|
-
};
|
|
884
|
-
config.colours = colours;
|
|
885
|
-
|
|
886
|
-
const
|
|
914
|
+
config.baseUnit = baseUnit;
|
|
915
|
+
config.baseFontSize = baseFontSize || "16px";
|
|
916
|
+
config.fontFamily = {
|
|
917
|
+
heading: headingFont,
|
|
918
|
+
body: bodyFont,
|
|
919
|
+
};
|
|
920
|
+
config.colours = colours;
|
|
921
|
+
|
|
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
|
+
});
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const configPath = path.join(process.cwd(), "emily.config.json");
|
|
887
932
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
888
933
|
|
|
889
934
|
console.log("");
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const TOTAL_RUNS = 60;
|
|
10
|
+
const FONT_KEYS = ['system', 'inter', 'lexend', 'georgia', 'dm-sans', 'nunito', 'atkinson', 'mono'];
|
|
11
|
+
const BUILD_MODULE_PATH = path.resolve(__dirname, '../index.js');
|
|
12
|
+
|
|
13
|
+
function readBaseConfig() {
|
|
14
|
+
const configPath = path.join(__dirname, '../../emily.config.json');
|
|
15
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clone(value) {
|
|
19
|
+
return JSON.parse(JSON.stringify(value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function randomInt(min, max) {
|
|
23
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function randomChoice(values) {
|
|
27
|
+
return values[randomInt(0, values.length - 1)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createDeepObject(depth) {
|
|
31
|
+
let root = {};
|
|
32
|
+
let cursor = root;
|
|
33
|
+
for (let i = 0; i < depth; i += 1) {
|
|
34
|
+
cursor['layer' + i] = {};
|
|
35
|
+
cursor = cursor['layer' + i];
|
|
36
|
+
}
|
|
37
|
+
cursor.value = 'deep-value';
|
|
38
|
+
return root;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createCircularObject(baseConfig) {
|
|
42
|
+
const config = clone(baseConfig);
|
|
43
|
+
const circular = { label: 'circular' };
|
|
44
|
+
circular.self = circular;
|
|
45
|
+
config.attackVector = circular;
|
|
46
|
+
return config;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureBaselineShape(config) {
|
|
50
|
+
if (!config || typeof config !== 'object') {
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
config.output = {
|
|
55
|
+
css: 'dist/emily.min.css',
|
|
56
|
+
fullCss: 'dist/emily.css',
|
|
57
|
+
};
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withConfigMutation(name, mutate) {
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
create(baseConfig) {
|
|
65
|
+
const config = ensureBaselineShape(clone(baseConfig));
|
|
66
|
+
mutate(config);
|
|
67
|
+
return { type: 'config', payload: config };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withRawJson(name, payload) {
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
create() {
|
|
76
|
+
return { type: 'raw', payload };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ABUSE_CASES = [
|
|
82
|
+
withConfigMutation('colour-8-digit-hex', (config) => { config.colours.brand = '#FF0000FF'; }),
|
|
83
|
+
withConfigMutation('colour-3-digit-hex', (config) => { config.colours.brand = '#F00'; }),
|
|
84
|
+
withConfigMutation('colour-invalid-hex', (config) => { config.colours.brand = '#GGGGGG'; }),
|
|
85
|
+
withConfigMutation('colour-named', (config) => { config.colours.brand = 'red'; }),
|
|
86
|
+
withConfigMutation('colour-null', (config) => { config.colours.brand = null; }),
|
|
87
|
+
withConfigMutation('colour-empty', (config) => { config.colours.brand = ''; }),
|
|
88
|
+
withConfigMutation('colour-number', (config) => { config.colours.brand = 123456; }),
|
|
89
|
+
withConfigMutation('colour-xss-payload', (config) => { config.colours.brand = '<script>alert("hi")</script>'; }),
|
|
90
|
+
|
|
91
|
+
withConfigMutation('spacing-negative-px', (config) => { config.spacing.scale['4'] = '-10px'; }),
|
|
92
|
+
withConfigMutation('spacing-negative-rem', (config) => { config.spacing.scale['4'] = '-1rem'; }),
|
|
93
|
+
withConfigMutation('spacing-huge-rem', (config) => { config.spacing.scale['4'] = '99999rem'; }),
|
|
94
|
+
withConfigMutation('spacing-huge-px', (config) => { config.spacing.scale['4'] = '1000000px'; }),
|
|
95
|
+
withConfigMutation('spacing-invalid-no-unit', (config) => { config.spacing.scale['4'] = '10'; }),
|
|
96
|
+
withConfigMutation('spacing-invalid-unit', (config) => { config.spacing.scale['4'] = '10xx'; }),
|
|
97
|
+
withConfigMutation('spacing-invalid-whitespace-unit', (config) => { config.spacing.scale['4'] = '10 rem'; }),
|
|
98
|
+
withConfigMutation('spacing-null-value', (config) => { config.spacing.scale['4'] = null; }),
|
|
99
|
+
withConfigMutation('spacing-nonnumeric', (config) => { config.spacing.scale['4'] = 'abc'; }),
|
|
100
|
+
withConfigMutation('spacing-infinity', (config) => { config.spacing.scale['4'] = 'infinity'; }),
|
|
101
|
+
withConfigMutation('spacing-descending-scale', (config) => {
|
|
102
|
+
config.spacing.scale = {
|
|
103
|
+
'10': '2.5rem',
|
|
104
|
+
'8': '2rem',
|
|
105
|
+
'6': '1.5rem',
|
|
106
|
+
'4': '1rem',
|
|
107
|
+
'2': '0.5rem',
|
|
108
|
+
'0': '0px',
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
withConfigMutation('font-nonexistent', (config) => { config.fontFamily = { heading: 'banana-sans', body: 'foo' }; }),
|
|
113
|
+
withConfigMutation('font-null', (config) => { config.fontFamily = null; }),
|
|
114
|
+
withConfigMutation('font-empty-string', (config) => { config.fontFamily = ''; }),
|
|
115
|
+
withConfigMutation('font-number', (config) => { config.fontFamily = 42; }),
|
|
116
|
+
withConfigMutation('font-array', (config) => { config.fontFamily = ['inter', 'lexend']; }),
|
|
117
|
+
withConfigMutation('font-object-weird-types', (config) => { config.fontFamily = { heading: {}, body: [] }; }),
|
|
118
|
+
withConfigMutation('font-unicode-edge', (config) => { config.fontFamily = { heading: '\uD83D\uDCA5\u200D\uFE0F', body: '\u2066\u2067\u2069' }; }),
|
|
119
|
+
withConfigMutation('font-very-long-string', (config) => {
|
|
120
|
+
const long = 'x'.repeat(10000);
|
|
121
|
+
config.fontFamily = { heading: long, body: long };
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
withConfigMutation('missing-colours', (config) => { delete config.colours; }),
|
|
125
|
+
withConfigMutation('missing-spacing', (config) => { delete config.spacing; }),
|
|
126
|
+
withConfigMutation('wrong-type-colours-string', (config) => { config.colours = 'not-an-object'; }),
|
|
127
|
+
withConfigMutation('wrong-type-spacing-string', (config) => { config.spacing = 'not-an-object'; }),
|
|
128
|
+
withConfigMutation('wrong-type-transitions-string', (config) => { config.transitions = 'not-an-object'; }),
|
|
129
|
+
withConfigMutation('unknown-extra-fields', (config) => {
|
|
130
|
+
config.__unknown = true;
|
|
131
|
+
config.notExpected = { nested: [1, 2, 3] };
|
|
132
|
+
}),
|
|
133
|
+
withConfigMutation('deeply-nested-object', (config) => {
|
|
134
|
+
config.veryDeep = createDeepObject(300);
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
name: 'circular-reference',
|
|
138
|
+
create(baseConfig) {
|
|
139
|
+
return { type: 'config', payload: createCircularObject(baseConfig) };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
withConfigMutation('transition-negative-fast', (config) => { config.transitions.fast = '-100ms'; }),
|
|
144
|
+
withConfigMutation('transition-negative-base', (config) => { config.transitions.base = '-200ms'; }),
|
|
145
|
+
withConfigMutation('transition-negative-slow', (config) => { config.transitions.slow = '-300ms'; }),
|
|
146
|
+
withConfigMutation('transition-nonnumeric-fast', (config) => { config.transitions.fast = 'abc'; }),
|
|
147
|
+
withConfigMutation('transition-null-base', (config) => { config.transitions.base = null; }),
|
|
148
|
+
withConfigMutation('transition-order-invalid', (config) => {
|
|
149
|
+
config.transitions.fast = '300ms';
|
|
150
|
+
config.transitions.base = '200ms';
|
|
151
|
+
config.transitions.slow = '100ms';
|
|
152
|
+
}),
|
|
153
|
+
withConfigMutation('transition-number-types', (config) => {
|
|
154
|
+
config.transitions.fast = 100;
|
|
155
|
+
config.transitions.base = 200;
|
|
156
|
+
config.transitions.slow = 300;
|
|
157
|
+
}),
|
|
158
|
+
withConfigMutation('transition-timing-malicious', (config) => {
|
|
159
|
+
config.transitions.timing = 'cubic-bezier(0.4, 0, 0.2, 1));background:url(javascript:alert(1))/*';
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
withRawJson('top-level-null', 'null'),
|
|
163
|
+
withRawJson('top-level-string', '"oops"'),
|
|
164
|
+
withRawJson('top-level-array', '["bad", "config"]'),
|
|
165
|
+
withRawJson('invalid-json-syntax', '{"colours": {"brand": "#FF0000",}}'),
|
|
166
|
+
|
|
167
|
+
withConfigMutation('spacing-scale-missing', (config) => { delete config.spacing.scale; }),
|
|
168
|
+
withConfigMutation('spacing-scale-null', (config) => { config.spacing.scale = null; }),
|
|
169
|
+
withConfigMutation('spacing-scale-array', (config) => { config.spacing.scale = ['1rem', '2rem']; }),
|
|
170
|
+
withConfigMutation('manifest-wrong-type', (config) => { config.manifest = 'yes'; }),
|
|
171
|
+
withConfigMutation('output-wrong-type', (config) => { config.output = 'dist/emily.css'; }),
|
|
172
|
+
withConfigMutation('breakpoints-wrong-type', (config) => { config.breakpoints = 'sm,md,lg'; }),
|
|
173
|
+
withConfigMutation('typography-null', (config) => { config.typography = null; }),
|
|
174
|
+
withConfigMutation('typography-fontsizes-string', (config) => { config.typography.fontSizes = '16px'; }),
|
|
175
|
+
withConfigMutation('typography-fontweights-number', (config) => { config.typography.fontWeights = 700; }),
|
|
176
|
+
withConfigMutation('colours-empty-object', (config) => { config.colours = {}; }),
|
|
177
|
+
withConfigMutation('colours-undefined-via-delete', (config) => { delete config.colours.brand; }),
|
|
178
|
+
withConfigMutation('spacing-proto-pollution-shape', (config) => {
|
|
179
|
+
config.spacing.scale = {
|
|
180
|
+
'__proto__': { polluted: true },
|
|
181
|
+
'4': '1rem',
|
|
182
|
+
'8': '2rem',
|
|
183
|
+
'0': '0px',
|
|
184
|
+
};
|
|
185
|
+
}),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
function writeConfigFile(tempDir, caseDef, baseConfig) {
|
|
189
|
+
const generated = caseDef.create(baseConfig);
|
|
190
|
+
const configPath = path.join(tempDir, 'emily.config.json');
|
|
191
|
+
|
|
192
|
+
if (generated.type === 'raw') {
|
|
193
|
+
fs.writeFileSync(configPath, generated.payload);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assert.strictEqual(generated.type, 'config', `Unknown generated config type: ${generated.type}`);
|
|
198
|
+
fs.writeFileSync(configPath, JSON.stringify(generated.payload, null, 2));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function runBuildInSubprocess(tempDir) {
|
|
202
|
+
const runner = [
|
|
203
|
+
'const { buildFullFramework } = require(' + JSON.stringify(BUILD_MODULE_PATH) + ');',
|
|
204
|
+
'try {',
|
|
205
|
+
' buildFullFramework();',
|
|
206
|
+
' process.exit(0);',
|
|
207
|
+
'} catch (error) {',
|
|
208
|
+
' const message = error && error.message ? error.message : String(error);',
|
|
209
|
+
' console.error(message);',
|
|
210
|
+
' process.exit(2);',
|
|
211
|
+
'}',
|
|
212
|
+
].join('\n');
|
|
213
|
+
|
|
214
|
+
return spawnSync(process.execPath, ['-e', runner], {
|
|
215
|
+
cwd: tempDir,
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function assertFailureLooksClear(output, caseName) {
|
|
221
|
+
const trimmed = output.trim();
|
|
222
|
+
assert.ok(trimmed.length >= 8, `Case "${caseName}" failed but gave no clear error message`);
|
|
223
|
+
assert.ok(trimmed !== '[object Object]', `Case "${caseName}" returned an unhelpful object error`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatErrorSnippet(message) {
|
|
227
|
+
return String(message || '')
|
|
228
|
+
.replace(/\s+/g, ' ')
|
|
229
|
+
.trim()
|
|
230
|
+
.slice(0, 100);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function assertSuccessLooksSensible(tempDir, caseName) {
|
|
234
|
+
const cssPath = path.join(tempDir, 'dist', 'emily.css');
|
|
235
|
+
assert.ok(fs.existsSync(cssPath), `Case "${caseName}" succeeded but did not output dist/emily.css`);
|
|
236
|
+
|
|
237
|
+
const cssContent = fs.readFileSync(cssPath, 'utf8');
|
|
238
|
+
assert.ok(cssContent.trim().length > 0, `Case "${caseName}" succeeded but CSS output was empty`);
|
|
239
|
+
|
|
240
|
+
const manifestPath = path.join(tempDir, 'dist', 'emily.manifest.json');
|
|
241
|
+
if (fs.existsSync(manifestPath)) {
|
|
242
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
243
|
+
assert.doesNotThrow(() => JSON.parse(raw), `Case "${caseName}" wrote invalid manifest JSON`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function run() {
|
|
248
|
+
const baseConfig = readBaseConfig();
|
|
249
|
+
const initialCwd = process.cwd();
|
|
250
|
+
const createdTempDirs = [];
|
|
251
|
+
let handledFailures = 0;
|
|
252
|
+
let gracefulSuccesses = 0;
|
|
253
|
+
let crashes = 0;
|
|
254
|
+
const gracefulSuccessCases = [];
|
|
255
|
+
const handledFailureCases = [];
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < TOTAL_RUNS; i += 1) {
|
|
258
|
+
const runLabel = `[${i + 1}/${TOTAL_RUNS}]`;
|
|
259
|
+
const caseDef = randomChoice(ABUSE_CASES);
|
|
260
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emily-chaos-'));
|
|
261
|
+
createdTempDirs.push(tempDir);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
try {
|
|
265
|
+
writeConfigFile(tempDir, caseDef, baseConfig);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const message = error && error.message ? error.message : String(error);
|
|
268
|
+
assertFailureLooksClear(message, caseDef.name);
|
|
269
|
+
const snippet = formatErrorSnippet(message);
|
|
270
|
+
console.log(`${runLabel} ${caseDef.name} ... FAIL (${snippet})`);
|
|
271
|
+
handledFailures += 1;
|
|
272
|
+
handledFailureCases.push({ name: caseDef.name, error: snippet });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = runBuildInSubprocess(tempDir);
|
|
277
|
+
const output = (result.stdout || '') + '\n' + (result.stderr || '');
|
|
278
|
+
|
|
279
|
+
if (result.status === 0) {
|
|
280
|
+
assertSuccessLooksSensible(tempDir, caseDef.name);
|
|
281
|
+
console.log(`${runLabel} ${caseDef.name} ... OK (graceful success)`);
|
|
282
|
+
gracefulSuccesses += 1;
|
|
283
|
+
gracefulSuccessCases.push(caseDef.name);
|
|
284
|
+
} else if (result.status === 1 || result.status === 2) {
|
|
285
|
+
assertFailureLooksClear(output, caseDef.name);
|
|
286
|
+
const snippet = formatErrorSnippet(output);
|
|
287
|
+
console.log(`${runLabel} ${caseDef.name} ... FAIL (${snippet})`);
|
|
288
|
+
handledFailures += 1;
|
|
289
|
+
handledFailureCases.push({ name: caseDef.name, error: snippet });
|
|
290
|
+
} else {
|
|
291
|
+
crashes += 1;
|
|
292
|
+
const snippet = formatErrorSnippet(output || `exit ${result.status}`);
|
|
293
|
+
console.log(`${runLabel} ${caseDef.name} ... CRASH (${snippet})`);
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Case "${caseDef.name}" crashed (exit ${result.status}). Output:\n${output.trim() || '(no output)'}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
throw new Error(`Chaos run ${i + 1}/${TOTAL_RUNS} failed for case "${caseDef.name}": ${error.message}`);
|
|
300
|
+
} finally {
|
|
301
|
+
process.chdir(initialCwd);
|
|
302
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const leftoverTempDirs = createdTempDirs.filter((tempDir) => fs.existsSync(tempDir));
|
|
307
|
+
assert.strictEqual(leftoverTempDirs.length, 0, `Temp directory cleanup failed for ${leftoverTempDirs.length} case(s)`);
|
|
308
|
+
assert.ok(handledFailures + gracefulSuccesses >= 50, 'Expected at least 50 chaos runs');
|
|
309
|
+
assert.strictEqual(crashes, 0, `Detected ${crashes} unhandled crash(es) during chaos testing`);
|
|
310
|
+
|
|
311
|
+
console.log('\nResults:');
|
|
312
|
+
console.log(` Graceful successes (${gracefulSuccesses}): ${gracefulSuccessCases.join(', ') || '(none)'}`);
|
|
313
|
+
console.log(` Handled failures (${handledFailures}):`);
|
|
314
|
+
if (handledFailureCases.length === 0) {
|
|
315
|
+
console.log(' - (none)');
|
|
316
|
+
} else {
|
|
317
|
+
handledFailureCases.forEach((entry) => {
|
|
318
|
+
console.log(` - ${entry.name}: ${entry.error}`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log(
|
|
323
|
+
`✓ Chaos testing passed (50+ abuse cases, 0 crashes) [runs=${TOTAL_RUNS}, handled-failures=${handledFailures}, graceful-successes=${gracefulSuccesses}]`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
run();
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(error.message);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ALLOWED_FONT_FAMILIES = [
|
|
4
|
+
'system',
|
|
5
|
+
'inter',
|
|
6
|
+
'lexend',
|
|
7
|
+
'georgia',
|
|
8
|
+
'dm-sans',
|
|
9
|
+
'nunito',
|
|
10
|
+
'atkinson',
|
|
11
|
+
'mono',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const ALLOWED_FONT_FAMILY_SET = new Set(ALLOWED_FONT_FAMILIES);
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateHexColour(value) {
|
|
21
|
+
if (typeof value !== 'string') {
|
|
22
|
+
return { valid: false, reason: 'must be a string in #RRGGBB format' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return { valid: false, reason: 'must be a 6-digit hex like #FF0000 (value is empty)' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!trimmed.startsWith('#')) {
|
|
31
|
+
return { valid: false, reason: 'must include # symbol (example: #FF0000)' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!/^#[0-9A-Fa-f]{6}$/.test(trimmed)) {
|
|
35
|
+
return { valid: false, reason: 'must be #RRGGBB format' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { valid: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateSpacingValue(value) {
|
|
42
|
+
if (typeof value !== 'string') {
|
|
43
|
+
return { valid: false, reason: 'must be a string CSS length like 1rem or 10px' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
if (!trimmed) {
|
|
48
|
+
return { valid: false, reason: 'must be a CSS length like 1rem or 10px (value is empty)' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (/\s/.test(trimmed)) {
|
|
52
|
+
return { valid: false, reason: 'must not contain spaces (use 10px, not 10 px)' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (trimmed.startsWith('-')) {
|
|
56
|
+
return { valid: false, reason: 'must not be negative (e.g. -1rem is not allowed)' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const match = /^(\d+(?:\.\d+)?)(rem|px)$/i.exec(trimmed);
|
|
60
|
+
if (!match) {
|
|
61
|
+
return { valid: false, reason: 'must be numeric and use rem or px units (e.g. 1rem, 10px)' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const numericPart = Number.parseFloat(match[1]);
|
|
65
|
+
const unit = match[2].toLowerCase();
|
|
66
|
+
|
|
67
|
+
if (!Number.isFinite(numericPart)) {
|
|
68
|
+
return { valid: false, reason: 'must be a finite numeric value' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (unit === 'rem' && numericPart > 9999) {
|
|
72
|
+
return { valid: false, reason: 'rem value is too large (max 9999rem)' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (unit === 'px' && numericPart > 99999) {
|
|
76
|
+
return { valid: false, reason: 'px value is too large (max 99999px)' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { valid: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateFontFamily(value) {
|
|
83
|
+
if (typeof value !== 'string') {
|
|
84
|
+
return { valid: false, reason: 'must be a string font key' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (!trimmed) {
|
|
89
|
+
return { valid: false, reason: 'must not be empty' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!ALLOWED_FONT_FAMILY_SET.has(trimmed)) {
|
|
93
|
+
return {
|
|
94
|
+
valid: false,
|
|
95
|
+
reason: `must be one of: ${ALLOWED_FONT_FAMILIES.join(', ')}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { valid: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateConfigShape(config) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
|
|
105
|
+
if (!isPlainObject(config)) {
|
|
106
|
+
return {
|
|
107
|
+
valid: false,
|
|
108
|
+
errors: ['config must be an object (not null, array, or string)'],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const requiredFields = ['colours', 'spacing', 'fontFamily', 'output'];
|
|
113
|
+
requiredFields.forEach((field) => {
|
|
114
|
+
if (!(field in config)) {
|
|
115
|
+
errors.push(`missing required field: ${field}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if ('colours' in config) {
|
|
120
|
+
if (!isPlainObject(config.colours)) {
|
|
121
|
+
errors.push('colours must be an object of #RRGGBB values');
|
|
122
|
+
} else {
|
|
123
|
+
Object.entries(config.colours).forEach(([name, value]) => {
|
|
124
|
+
const result = validateHexColour(value);
|
|
125
|
+
if (!result.valid) {
|
|
126
|
+
errors.push(`colours.${name} ${result.reason}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if ('spacing' in config) {
|
|
133
|
+
if (!isPlainObject(config.spacing)) {
|
|
134
|
+
errors.push('spacing must be an object');
|
|
135
|
+
} else if (!('scale' in config.spacing)) {
|
|
136
|
+
errors.push('spacing must include a scale key');
|
|
137
|
+
} else if (!isPlainObject(config.spacing.scale)) {
|
|
138
|
+
errors.push('spacing.scale must be an object of spacing values');
|
|
139
|
+
} else {
|
|
140
|
+
const spacingKeys = Object.keys(config.spacing.scale);
|
|
141
|
+
if (spacingKeys.length === 0) {
|
|
142
|
+
errors.push('spacing.scale must not be empty');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
spacingKeys.forEach((key) => {
|
|
146
|
+
if (!/^(\d+(\.\d+)?|px)$/.test(key)) {
|
|
147
|
+
errors.push(`spacing.scale key "${key}" must be numeric (or "px" for legacy support)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = validateSpacingValue(config.spacing.scale[key]);
|
|
151
|
+
if (!result.valid) {
|
|
152
|
+
errors.push(`spacing.scale.${key} ${result.reason}`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ('fontFamily' in config) {
|
|
159
|
+
if (!isPlainObject(config.fontFamily)) {
|
|
160
|
+
errors.push('fontFamily must be an object with heading and body');
|
|
161
|
+
} else {
|
|
162
|
+
if (!('heading' in config.fontFamily)) {
|
|
163
|
+
errors.push('fontFamily.heading is required');
|
|
164
|
+
} else {
|
|
165
|
+
const headingValidation = validateFontFamily(config.fontFamily.heading);
|
|
166
|
+
if (!headingValidation.valid) {
|
|
167
|
+
errors.push(`fontFamily.heading ${headingValidation.reason}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!('body' in config.fontFamily)) {
|
|
172
|
+
errors.push('fontFamily.body is required');
|
|
173
|
+
} else {
|
|
174
|
+
const bodyValidation = validateFontFamily(config.fontFamily.body);
|
|
175
|
+
if (!bodyValidation.valid) {
|
|
176
|
+
errors.push(`fontFamily.body ${bodyValidation.reason}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if ('output' in config) {
|
|
183
|
+
if (!isPlainObject(config.output)) {
|
|
184
|
+
errors.push('output must be an object');
|
|
185
|
+
} else {
|
|
186
|
+
if (typeof config.output.css !== 'string' || !config.output.css.trim()) {
|
|
187
|
+
errors.push('output.css must be a non-empty string');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof config.output.fullCss !== 'string' || !config.output.fullCss.trim()) {
|
|
191
|
+
errors.push('output.fullCss must be a non-empty string');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if ('manifest' in config) {
|
|
197
|
+
if (typeof config.manifest !== 'boolean' && !isPlainObject(config.manifest)) {
|
|
198
|
+
errors.push('manifest must be a boolean or an object');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isPlainObject(config.manifest)) {
|
|
202
|
+
if ('enabled' in config.manifest && typeof config.manifest.enabled !== 'boolean') {
|
|
203
|
+
errors.push('manifest.enabled must be a boolean');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ('output' in config.manifest) {
|
|
207
|
+
if (typeof config.manifest.output !== 'string' || !config.manifest.output.trim()) {
|
|
208
|
+
errors.push('manifest.output must be a non-empty string when provided');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (errors.length > 0) {
|
|
215
|
+
return { valid: false, errors };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { valid: true, errors: [] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
ALLOWED_FONT_FAMILIES,
|
|
223
|
+
validateHexColour,
|
|
224
|
+
validateSpacingValue,
|
|
225
|
+
validateFontFamily,
|
|
226
|
+
validateConfigShape,
|
|
227
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { validateConfigShape } = require('./validate');
|
|
6
|
+
|
|
7
|
+
function validateConfigOrExit() {
|
|
8
|
+
const configPath = path.join(process.cwd(), 'emily.config.json');
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(configPath)) {
|
|
11
|
+
console.error('Invalid EmilyCSS config: emily.config.json not found.');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let config;
|
|
16
|
+
try {
|
|
17
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error('Invalid EmilyCSS config: emily.config.json is not valid JSON.');
|
|
20
|
+
console.error(error.message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = validateConfigShape(config);
|
|
25
|
+
if (!result.valid) {
|
|
26
|
+
console.error('Invalid EmilyCSS config:');
|
|
27
|
+
result.errors.forEach((error) => {
|
|
28
|
+
console.error('- ' + error);
|
|
29
|
+
});
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
validateConfigOrExit,
|
|
36
|
+
};
|