Skip to content

Commit 73a985d

Browse files
committed
Build Tools: Swap from JSON to JS files for spec source of truth
1 parent 0eda787 commit 73a985d

File tree

2 files changed

+144
-27
lines changed

2 files changed

+144
-27
lines changed

internal-packages/scripts/src/build-ui-deps.js

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,29 @@ import { asyncEach, each } from '@semantic-ui/utils';
44
import { readFileSync, writeFileSync } from 'fs';
55
import { dirname, resolve } from 'path';
66
import glob from 'tiny-glob';
7+
import { pathToFileURL } from 'url';
78
import { build } from './lib/build.js';
89
import { INTERNAL_CSS_BANNER } from './lib/config.js';
10+
import { validateSpec } from './lib/validate-spec.js';
911

1012
/*
11-
Generate component spec JS directly without intermediate JSON file
13+
Generate component spec JS from source spec
1214
*/
13-
const generateComponentSpecJS = async (spec, plural = false, specSettings = {}) => {
15+
const generateComponentSpecJS = async (spec, plural = false, specSettings = {}, sourceFile = '') => {
1416
const readerSettings = {
1517
plural,
1618
...specSettings,
1719
};
1820
const reader = new SpecReader(spec, readerSettings);
1921
const componentSpec = reader.getWebComponentSpec();
2022
const filename = plural
21-
? `${spec?.pluralTagName?.replace('ui-', '')}-component.js`
23+
? `${spec?.pluralTagName?.replace('ui-', '')}.component.js`
2224
: 'component.js';
23-
return `// Auto-generated from ${spec?.tagName?.replace('ui-', '') || 'spec'}.json\nexport default ${
24-
JSON.stringify(componentSpec, null, 2)
25-
};\n`;
25+
26+
const sourceFileName = sourceFile
27+
? sourceFile.split('/').pop()
28+
: (spec?.tagName?.replace('ui-', '') || 'spec') + '.spec.js';
29+
return `// Auto-generated from ${sourceFileName}\nexport default ${JSON.stringify(componentSpec, null, 2)};\n`;
2630
};
2731

2832
/*
@@ -51,41 +55,76 @@ export const buildUIDeps = async ({
5155
});
5256

5357
// External glob needed for proper negation support
54-
const allFiles = await glob('src/primitives/**/specs/*.json');
55-
const entryPoints = allFiles.filter(path => !path.endsWith('-component.json'));
58+
// Support both .json (legacy) and .spec.js (new) during transition
59+
const jsonFiles = await glob('src/primitives/**/specs/*.json');
60+
const specJsFiles = await glob('src/primitives/**/specs/*.spec.js');
61+
62+
const allFiles = [...jsonFiles, ...specJsFiles];
63+
// Exclude all generated files (*.component.js, *.component.json, *.spec.json)
64+
const entryPoints = allFiles.filter(path =>
65+
!path.endsWith('.component.json')
66+
&& !path.endsWith('.component.js')
67+
&& !path.endsWith('.spec.json') // Generated JSON from .spec.js
68+
);
5669

5770
const createComponentSpecs = async () => {
5871
await asyncEach(entryPoints, async (entryPath) => {
5972
try {
60-
const contents = readFileSync(entryPath, 'utf8');
61-
const spec = JSON.parse(contents);
73+
let spec;
74+
const isJsSpec = entryPath.endsWith('.spec.js');
75+
76+
if (isJsSpec) {
77+
// Load JS module with cache busting for watch mode
78+
const specModule = await import(`${pathToFileURL(entryPath).href}?t=${Date.now()}`);
79+
spec = specModule.default;
80+
81+
// Validate JS specs are pure data
82+
validateSpec(spec, entryPath);
83+
84+
// Generate JSON snapshot for machine readability (LLMs, tooling)
85+
const jsonPath = entryPath.replace('.spec.js', '.spec.json');
86+
const jsonContent = `${JSON.stringify(spec, null, 2)}\n`;
87+
writeFileSync(jsonPath, jsonContent);
88+
}
89+
else {
90+
// Legacy JSON loading
91+
const contents = readFileSync(entryPath, 'utf8');
92+
spec = JSON.parse(contents);
93+
}
6294

6395
// Generate component spec JS directly
64-
const componentSpecJS = await generateComponentSpecJS(spec, false);
65-
const componentJSPath = entryPath.replace('.json', '-component.js');
96+
const componentSpecJS = await generateComponentSpecJS(spec, false, {}, entryPath);
97+
const componentJSPath = isJsSpec
98+
? entryPath.replace('.spec.js', '.component.js')
99+
: entryPath.replace('.json', '.component.js');
66100
writeFileSync(componentJSPath, componentSpecJS);
67101

68102
// Generate plural variant if supported
69103
if (spec?.supportsPlural) {
70-
const pluralComponentSpecJS = await generateComponentSpecJS(spec, true);
104+
const pluralComponentSpecJS = await generateComponentSpecJS(spec, true, {}, entryPath);
71105
const pluralName = spec?.pluralTagName.replace('ui-', '');
72-
const pluralJSPath = resolve(dirname(entryPath), `${pluralName}-component.js`);
106+
const pluralJSPath = resolve(dirname(entryPath), `${pluralName}.component.js`);
73107
writeFileSync(pluralJSPath, pluralComponentSpecJS);
74108
}
75109
}
76110
catch (e) {
77-
// Silently skip malformed JSON files
111+
console.error(`Error processing ${entryPath}:`, e.message);
112+
throw e; // Don't silently skip errors in new system
78113
}
79114
});
80115
};
81116

82117
// Convert raw spec JSON to JS modules to avoid ESM JSON import compatibility issues
118+
// Note: .spec.js files don't need conversion - they're already JS modules
83119
const generateJSExportsFromSpecs = async () => {
84120
await createComponentSpecs();
85121

86-
// Only process raw spec files (not component specs, which are generated directly above)
122+
// Only process legacy JSON spec files (not .spec.js/json or .component.js)
87123
const rawSpecFiles = await glob('src/primitives/*/specs/*.json');
88-
const filteredRawSpecs = rawSpecFiles.filter(path => !path.endsWith('-component.json'));
124+
const filteredRawSpecs = rawSpecFiles.filter(path =>
125+
!path.endsWith('.component.json')
126+
&& !path.endsWith('.spec.json')
127+
);
89128

90129
each(filteredRawSpecs, (jsonFile) => {
91130
try {
@@ -105,22 +144,29 @@ export const buildUIDeps = async ({
105144

106145
const generateJSExports = generateJSExportsFromSpecs();
107146

108-
// Set up a separate esbuild watcher for JSON spec files
147+
// Set up a separate esbuild watcher for spec files
109148
let specWatcher;
110149
if (watch) {
111-
// Get all spec files to watch
112-
const specsPattern = 'src/primitives/**/specs/*.json';
113-
const watchedFiles = await glob(specsPattern);
114-
const specFiles = watchedFiles.filter(path => !path.endsWith('-component.json'));
115-
116-
// Use esbuild to watch the JSON files by treating them as entry points
117-
// with a plugin that rebuilds our spec JS files
118-
if (specFiles.length > 0) {
150+
// Get all spec files to watch (legacy .json and source .spec.js)
151+
const jsonSpecFiles = await glob('src/primitives/**/specs/*.json');
152+
const jsSpecFiles = await glob('src/primitives/**/specs/*.spec.js');
153+
154+
// Exclude all generated files from watch
155+
const watchedFiles = [...jsonSpecFiles, ...jsSpecFiles].filter(
156+
path =>
157+
!path.endsWith('.component.json')
158+
&& !path.endsWith('.component.js')
159+
&& !path.endsWith('.spec.json'), // Don't watch generated JSON
160+
);
161+
162+
// Use esbuild to watch the spec files by treating them as entry points
163+
// with a plugin that rebuilds our component specs
164+
if (watchedFiles.length > 0) {
119165
specWatcher = build({
120166
watch,
121167
write: false, // Don't write output, just watch
122168
logLevel: 'silent', // Suppress esbuild's own logs
123-
entryPoints: specFiles,
169+
entryPoints: watchedFiles,
124170
outdir: '.temp-watch', // Required by esbuild when multiple entry points
125171
plugins: [
126172
callbackPlugin({
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { each, isArray, isFunction, isPlainObject } from '@semantic-ui/utils';
2+
3+
/**
4+
* Validates that a spec is pure data (JSON-serializable)
5+
* Prevents functions, dates, and other non-serializable values in specs
6+
*
7+
* @param {Object} spec - The spec object to validate
8+
* @param {string} specPath - Path to the spec file (for error messages)
9+
* @throws {Error} If validation fails
10+
* @returns {true} If validation passes
11+
*/
12+
export function validateSpec(spec, specPath) {
13+
const errors = [];
14+
15+
// Check required fields
16+
const required = ['uiType', 'name', 'description', 'tagName', 'exportName'];
17+
each(required, (field) => {
18+
if (!spec[field]) {
19+
errors.push(`Missing required field: ${field}`);
20+
}
21+
});
22+
23+
// Ensure spec is JSON-serializable
24+
try {
25+
const json = JSON.stringify(spec);
26+
JSON.parse(json); // Round-trip to ensure it works
27+
}
28+
catch (e) {
29+
errors.push(`Spec is not JSON-serializable: ${e.message}`);
30+
}
31+
32+
// Check for forbidden values (functions, etc.)
33+
const checkForFunctions = (obj, path = '') => {
34+
if (isFunction(obj)) {
35+
errors.push(`Function found at ${path || 'root'} - specs must be pure data`);
36+
return;
37+
}
38+
39+
if (obj instanceof Date) {
40+
errors.push(`Date object found at ${path || 'root'} - use ISO strings instead`);
41+
return;
42+
}
43+
44+
if (obj instanceof RegExp) {
45+
errors.push(`RegExp found at ${path || 'root'} - use string patterns instead`);
46+
return;
47+
}
48+
49+
if (isArray(obj)) {
50+
each(obj, (item, i) => checkForFunctions(item, `${path}[${i}]`));
51+
}
52+
else if (isPlainObject(obj)) {
53+
each(obj, (value, key) => {
54+
checkForFunctions(value, path ? `${path}.${key}` : key);
55+
});
56+
}
57+
};
58+
59+
checkForFunctions(spec);
60+
61+
if (errors.length > 0) {
62+
const errorMessage = [
63+
`Spec validation failed for ${specPath}:`,
64+
...errors.map(e => ` - ${e}`),
65+
].join('\n');
66+
67+
throw new Error(errorMessage);
68+
}
69+
70+
return true;
71+
}

0 commit comments

Comments
 (0)