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.
@@ -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
+ }
@@ -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
+ };