diff --git a/.changeset/green-moons-rush.md b/.changeset/green-moons-rush.md new file mode 100644 index 00000000..111a15a7 --- /dev/null +++ b/.changeset/green-moons-rush.md @@ -0,0 +1,5 @@ +--- +"@adaptive-web/adaptive-ui": patch +--- + +Consolidate style generation and enable bulk update. diff --git a/package-lock.json b/package-lock.json index 266538ea..f2272cc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3449,15 +3449,15 @@ } }, "node_modules/@changesets/cli/node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@changesets/cli/node_modules/ansi-colors": { @@ -32501,9 +32501,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT", "optional": true, @@ -34441,6 +34441,7 @@ "@happy-dom/global-registrator": "^14.7.1", "@microsoft/fast-element": "2.0.0-beta.26", "@microsoft/fast-foundation": "3.0.0-alpha.31", + "change-case": "^5.4.4", "commander": "^12.0.0", "culori": "^3.2.0", "deepmerge-ts": "^7.1.3", @@ -36143,6 +36144,7 @@ "@types/culori": "^2.0.0", "@types/mocha": "^10.0.1", "chai": "^4.3.7", + "change-case": "^5.4.4", "commander": "^12.0.0", "culori": "^3.2.0", "deepmerge-ts": "^7.1.3", @@ -39177,14 +39179,14 @@ } }, "@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "optional": true, "peer": true, "requires": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "ansi-colors": { @@ -61152,9 +61154,9 @@ } }, "undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "optional": true, "peer": true diff --git a/packages/adaptive-ui/package.json b/packages/adaptive-ui/package.json index b0b062da..ffac5aca 100644 --- a/packages/adaptive-ui/package.json +++ b/packages/adaptive-ui/package.json @@ -40,6 +40,7 @@ "@happy-dom/global-registrator": "^14.7.1", "@microsoft/fast-element": "2.0.0-beta.26", "@microsoft/fast-foundation": "3.0.0-alpha.31", + "change-case": "^5.4.4", "commander": "^12.0.0", "culori": "^3.2.0", "deepmerge-ts": "^7.1.3", diff --git a/packages/adaptive-ui/src/bin/aui.ts b/packages/adaptive-ui/src/bin/aui.ts index d01e838d..ef99e7c6 100644 --- a/packages/adaptive-ui/src/bin/aui.ts +++ b/packages/adaptive-ui/src/bin/aui.ts @@ -8,6 +8,7 @@ import * as prettier from "prettier"; import { ComposableStyles, ElementStyles } from '@microsoft/fast-element'; import { CSSDesignToken } from "@microsoft/fast-foundation"; import { Command } from 'commander'; +import { kebabCase } from "change-case"; import { deepmerge } from "deepmerge-ts"; import { glob } from "glob"; import postcss, { type Processor} from "postcss"; @@ -60,9 +61,9 @@ program.command('compile-style ') ensureFileExists(inFilePath); - const fileData = await compileFile(inFilePath, outFilePath, exportNames.s, exportNames.a); + const { css } = await compileFile(inFilePath, exportNames.s, exportNames.a, outFilePath); - writeStyleFile(fileData, outFilePath) + writeStyleFile(css, outFilePath) process.exit(0); }) @@ -72,55 +73,44 @@ program.command("compile-styles ") .option("-s , --styles ", "The name of the styles exports. This option supports wildcards.", "styles") .option("-e , --extension ", "The file extension of the file to write.", ".css") .action(async (globPath, args: { a: string, s: string, e: string }) => { - glob(globPath, { absolute: true }).then(async (paths) => { - await Promise.all(paths.map(async (inFilePath) => { - ensureFileExists(inFilePath) - const outFilePath = path.format({ ...path.parse(inFilePath), base: '', ext: args.e }); - const fileData = await compileFile(inFilePath, outFilePath, args.s, args.a); - writeStyleFile(fileData, outFilePath); - })); - - process.exit(0); - }) + const paths = await glob(globPath, { absolute: true }); + + await Promise.all(paths.map(async (inFilePath) => { + ensureFileExists(inFilePath); + + let outFilePath: string | undefined; + + if (isJsonAnatomyFile(inFilePath)) { + const jsonData = JSON.parse((await fsp.readFile(inFilePath)).toString()) as SerializableAnatomyWithImports; + if (!jsonData.name) { + console.warn(warnColor, `Skipping ${inFilePath}: JSON anatomy has no name property.`); + return; + } + + outFilePath = resolveStylesheetOutputPath(inFilePath, jsonData.name, args.e); + } + + const { css, anatomyName } = await compileFile(inFilePath, args.s, args.a, outFilePath); + + if (!outFilePath) { + outFilePath = resolveStylesheetOutputPath(inFilePath, anatomyName, args.e); + } + + writeStyleFile(css, outFilePath); + })); + + process.exit(0); }); program.command("compile-json-anatomy ") .description("Compile a stylesheet from a JSON anatomy") .action(async (jsonPath: string) => { - const data = (await fsp.readFile(jsonPath)).toString(); - await import("../reference/index.js"); - - let jsonData = JSON.parse(data) as SerializableAnatomyWithImports; - - if (jsonData.imports) { - for (const imp of jsonData.imports) { - const impWithExt = imp.toLowerCase().endsWith(".json") ? imp : `${imp}.json`; - const impFilePath = path.format({ ...path.parse(path.join(path.parse(jsonPath).dir, impWithExt)) }); - const impData = (await fsp.readFile(impFilePath)).toString(); - const impJsonData = JSON.parse(impData) as SerializableAnatomy; - - // If `parts` are in the import, they are either for validation/consistency of that file - // or additive to the main anatomy definition. - // If the part selector is empty, remove it an use the value from the main anatomy definition. - for (const part in impJsonData.parts) { - if (impJsonData.parts.hasOwnProperty(part)) { - if (impJsonData.parts[part] === "") { - delete impJsonData.parts[part]; - } - } - } - - jsonData = deepmerge(jsonData, impJsonData); - } - } + const jsonData = await readJsonAnatomyWithImports(jsonPath); + const styles = jsonToAUIStyleSheet(jsonData); + const outFilePath = resolveStylesheetOutputPath(jsonPath, jsonData.name, ".css"); + const css = await compileStylesheet(styles, jsonPath, outFilePath); - const compiler = new SheetCompilerImpl(); - const sheet = jsonToAUIStyleSheet(jsonData); - const compiledSheet = compiler.compile(sheet); - const formatted = await prettier.format(compiledSheet, { filepath: "foo.css" }); - const minified = await mergeCSSRules(formatted); - process.stdout.write("/* This file is generated. Do not edit directly */\n",); - process.stdout.write(minified) + process.stdout.write(css); process.stdout.end(); }); @@ -139,51 +129,131 @@ function ensureFileExists(inFilePath: string) { } /** - * Compile a single style file + * Resolve the output CSS path from an anatomy name, matching the designer convention. */ -async function compileFile(inFilePath: string, outFilePath: string, stylesName: string, anatomyName: string): Promise { - // Ensure inFile exists - ensureFileExists(inFilePath) - - const module = await import(pathToFileURL(inFilePath).href); - const exportKeys = Object.keys(module); - - const stylesExportName = matcher(exportKeys, stylesName); - const anatomyExportsName = matcher(exportKeys, anatomyName); - - if (stylesExportName.length === 0) { - console.error(failColor, `No style rules for export matching '${stylesName}'.`) - process.exit(1); - } else if (stylesExportName.length > 1) { - console.warn( - warnColor, - `Multiple exports for style rules found in ${inFilePath}.\nConsider re-naming exports or fixing the --styles matcher.` - ); +function resolveStylesheetOutputPath(inFilePath: string, anatomyName: string, extension: string): string { + return path.resolve(path.dirname(inFilePath), `../stylesheets/${kebabCase(anatomyName)}${extension}`); +} + +function resolveAnatomyName(anatomy: ComponentAnatomy, inFilePath: string): string { + if (anatomy.name) { + return anatomy.name; } + return path.basename(path.dirname(inFilePath)); +} + +function isJsonAnatomyFile(inFilePath: string): boolean { + return inFilePath.toLowerCase().endsWith(".json"); +} - if (anatomyExportsName.length === 0) { - console.error(failColor, `No anatomy for export matching '${stylesName}'.`) - process.exit(1); - } else if (stylesExportName.length > 1) { - console.warn( - warnColor, - `Multiple exports for anatomy found in ${inFilePath}.\nConsider re-naming exports or fixing the --anatomy matcher.` - ); +async function readJsonAnatomyWithImports(jsonPath: string): Promise { + await import("../reference/index.js"); + + const data = (await fsp.readFile(jsonPath)).toString(); + let jsonData = JSON.parse(data) as SerializableAnatomyWithImports; + + if (jsonData.imports) { + for (const imp of jsonData.imports) { + const impWithExt = imp.toLowerCase().endsWith(".json") ? imp : `${imp}.json`; + const impFilePath = path.format({ ...path.parse(path.join(path.parse(jsonPath).dir, impWithExt)) }); + const impData = (await fsp.readFile(impFilePath)).toString(); + const impJsonData = JSON.parse(impData) as SerializableAnatomy; + + // If `parts` are in the import, they are either for validation/consistency of that file + // or additive to the main anatomy definition. + // If the part selector is empty, remove it an use the value from the main anatomy definition. + for (const part in impJsonData.parts) { + if (impJsonData.parts.hasOwnProperty(part)) { + if (impJsonData.parts[part] === "") { + delete impJsonData.parts[part]; + } + } + } + + jsonData = deepmerge(jsonData, impJsonData); + } } + return jsonData; +} +function createGeneratedStylesheetHeader(inFilePath: string, outFilePath: string): string { + const sourcePath = path.relative(path.dirname(outFilePath), inFilePath); - const styles: AUIStyleSheet = { - rules: module[stylesExportName[0]], - anatomy: module[anatomyExportsName[0]] - } + return `/* This file is generated by Adaptive UI from ${sourcePath}. Do not edit directly. */\n`; +} +async function compileStylesheet( + styles: AUIStyleSheet, + inFilePath: string, + outFilePath: string +): Promise { const compiler = new SheetCompilerImpl(); const compiled = compiler.compile(styles); - const formatted = await prettier.format(compiled, { filepath: outFilePath }); - return (`/* This file is generated by Adaptive UI from ${path.relative(outFilePath, inFilePath)} */\n`) + formatted; + const minified = await mergeCSSRules(formatted); + + return createGeneratedStylesheetHeader(inFilePath, outFilePath) + minified; +} + +/** + * Compile a single input file to CSS. + */ +async function compileFile( + inFilePath: string, + stylesName: string, + anatomyName: string, + outFilePath?: string +): Promise<{ css: string, anatomyName: string }> { + ensureFileExists(inFilePath); + + const formatPath = outFilePath ?? path.format({ ...path.parse(inFilePath), ext: ".css" }); + let styles: AUIStyleSheet; + let resolvedAnatomyName: string; + + if (isJsonAnatomyFile(inFilePath)) { + const jsonData = await readJsonAnatomyWithImports(inFilePath); + resolvedAnatomyName = jsonData.name; + styles = jsonToAUIStyleSheet(jsonData); + } else { + const module = await import(pathToFileURL(inFilePath).href); + const exportKeys = Object.keys(module); + + const stylesExportName = matcher(exportKeys, stylesName); + const anatomyExportsName = matcher(exportKeys, anatomyName); + + if (stylesExportName.length === 0) { + console.error(failColor, `No style rules for export matching '${stylesName}'.`) + process.exit(1); + } else if (stylesExportName.length > 1) { + console.warn( + warnColor, + `Multiple exports for style rules found in ${inFilePath}.\nConsider re-naming exports or fixing the --styles matcher.` + ); + } + + + if (anatomyExportsName.length === 0) { + console.error(failColor, `No anatomy for export matching '${anatomyName}'.`) + process.exit(1); + } else if (anatomyExportsName.length > 1) { + console.warn( + warnColor, + `Multiple exports for anatomy found in ${inFilePath}.\nConsider re-naming exports or fixing the --anatomy matcher.` + ); + } + + styles = { + rules: module[stylesExportName[0]], + anatomy: module[anatomyExportsName[0]] + }; + resolvedAnatomyName = resolveAnatomyName(styles.anatomy, inFilePath); + } + + const css = await compileStylesheet(styles, inFilePath, formatPath); + + return { css, anatomyName: resolvedAnatomyName }; } /**