diff --git a/projects/element-ng/schematics/collection.json b/projects/element-ng/schematics/collection.json index 0afd7afb9..7abe7ad28 100644 --- a/projects/element-ng/schematics/collection.json +++ b/projects/element-ng/schematics/collection.json @@ -5,6 +5,11 @@ "description": "A schematic to add the @siemens/element-ng to your project.", "factory": "./ng-add/index#ngAdd", "schema": "./ng-add/schema.json" + }, + "ng-add-setup-element-styles": { + "description": "Configures Element styles after installation (internal schematic)", + "factory": "./ng-add/add-styling#ngAddSetupElementStyles", + "private": true } } } diff --git a/projects/element-ng/schematics/ng-add/add-dependencies.ts b/projects/element-ng/schematics/ng-add/add-dependencies.ts new file mode 100644 index 000000000..064af6a2d --- /dev/null +++ b/projects/element-ng/schematics/ng-add/add-dependencies.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ + +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { + addDependency, + ExistingBehavior, + InstallBehavior +} from '@schematics/angular/utility/dependency'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Adds required Element dependencies to package.json + */ +export const addElementDependencies = (): Rule => { + return (tree: Tree, context: SchematicContext) => { + const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const elementVersion = `^${packageJson.version}`; + const cdkVersion = `^${packageJson.peerDependencies['@angular/cdk']}`; + const elementIconsVersion = `^${packageJson.peerDependencies['@siemens/element-icons']}`; + + const options = { + existing: ExistingBehavior.Replace, + install: InstallBehavior.Auto + }; + + return chain([ + addDependency('@siemens/element-ng', elementVersion, options), + addDependency('@siemens/element-theme', elementVersion, options), + addDependency('@siemens/element-translate-ng', elementVersion, options), + addDependency('@angular/cdk', cdkVersion, options), + addDependency('@simpl/brand', '2.2.0', options), + addDependency('@siemens/element-icons', elementIconsVersion, options) + ])(tree, context); + }; +}; diff --git a/projects/element-ng/schematics/ng-add/add-styling.ts b/projects/element-ng/schematics/ng-add/add-styling.ts new file mode 100644 index 000000000..56ee316c1 --- /dev/null +++ b/projects/element-ng/schematics/ng-add/add-styling.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { updateWorkspace } from '@schematics/angular/utility/workspace'; + +import { getGlobalStyles } from '../utils/index.js'; + +export const ngAddSetupElementStyles = (): Rule => { + return (tree: Tree, context: SchematicContext) => { + context.logger.info('🎨 Configuring Element styles...'); + return addElementStyle()(tree, context); + }; +}; + +const addElementStyle = (): Rule => { + return async (tree: Tree, context: SchematicContext) => { + const rules: Rule[] = []; + const globalStyles = await getGlobalStyles(tree); + for (const style of globalStyles) { + if (style.endsWith('.scss') || style.endsWith('.sass')) { + const content = tree.readText(style); + + let predecessor = ''; + for (const themeEntry of THEME_STYLE_ENTRIES) { + const match = content.match(themeEntry.pattern ?? themeEntry.insert); + if (match) { + predecessor = match[0]; + continue; + } + + rules.push(applyGlobalStyles(style, predecessor, themeEntry.insert + '\n')); + predecessor = themeEntry.insert; + } + } + } + + rules.push(addStylesToAngularJson()); + return chain([...rules]); + }; +}; + +const applyGlobalStyles = (filePath: string, anchor: string, insert: string): Rule => { + return (tree: Tree): Tree => { + const recorder = tree.beginUpdate(filePath); + const content = tree.readText(filePath); + let pos = content.indexOf(anchor) + anchor.length; + if (pos > 0) { + // If the insert position is not the file start we want to insert the next line after the new line + pos = content.indexOf('\n', pos) + 1; + } + recorder.insertRight(pos, insert); + tree.commitUpdate(recorder); + return tree; + }; +}; + +/** + * Adds Element styles configuration to angular.json + */ +const addStylesToAngularJson = (): Rule => { + return updateWorkspace(workspace => { + for (const [, project] of workspace.projects) { + for (const [targetName, target] of project.targets) { + if (!['build', 'test'].includes(targetName)) { + continue; + } + + // Add node_modules to stylePreprocessorOptions + if (target.options) { + const preprocessorOptions = target.options.stylePreprocessorOptions as + | { + includePaths?: string[]; + } + | undefined; + + if (!preprocessorOptions) { + target.options.stylePreprocessorOptions = { + includePaths: ['node_modules/'] + }; + } else if (!preprocessorOptions.includePaths) { + preprocessorOptions.includePaths = ['node_modules/']; + } else if (!preprocessorOptions.includePaths.includes('node_modules/')) { + preprocessorOptions.includePaths.push('node_modules/'); + } + } + } + } + }); +}; + +// Apply theme styles if not already present +const THEME_STYLE_ENTRIES = [ + { insert: `// Load Siemens fonts` }, + { insert: `@use '@simpl/brand/assets/fonts/styles/siemens-sans';` }, + { insert: `// Load Element icons` }, + { insert: `@use '@siemens/element-icons/dist/style/siemens-element-icons';` }, + { insert: `// Use Element theme` }, + { + insert: `@use '@siemens/element-theme/src/theme' with ( + $element-theme-default: 'siemens-brand', + $element-themes: ( + 'siemens-brand', + 'element' + ) +);`, + pattern: /@use ['"]@siemens\/element-theme\/src\/theme['"] with \(([\s\S]*?)\);/g + }, + { insert: `// Use Element components` }, + { insert: `@use '@siemens/element-ng/element-ng';` }, + { insert: `// Build the siemens-brand theme` }, + { insert: `@use '@siemens/element-theme/src/styles/themes';` }, + { insert: `@use '@simpl/brand/dist/element-theme-siemens-brand-light' as brand-light;` }, + { insert: `@use '@simpl/brand/dist/element-theme-siemens-brand-dark' as brand-dark;` }, + { + insert: `@include themes.make-theme(brand-light.$tokens, 'siemens-brand');`, + pattern: /@include themes\.make-theme\(brand-light\.\$tokens, 'siemens-brand'\);/g + }, + { + insert: `@include themes.make-theme(brand-dark.$tokens, 'siemens-brand', true);`, + pattern: /@include themes\.make-theme\(brand-dark\.\$tokens, 'siemens-brand', true\);/g + } +]; diff --git a/projects/element-ng/schematics/ng-add/index.spec.ts b/projects/element-ng/schematics/ng-add/index.spec.ts new file mode 100644 index 000000000..5760ab2dc --- /dev/null +++ b/projects/element-ng/schematics/ng-add/index.spec.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { getPackageJsonDependency } from '@schematics/angular/utility/dependencies'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +import { createTestApp } from '../utils/index.js'; + +const collectionPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../collection.json' +); + +describe('ng-add schematic', () => { + let runner: SchematicTestRunner; + let appTree: Tree; + + beforeEach(async () => { + runner = new SchematicTestRunner('@siemens/element-ng', collectionPath); + appTree = await createTestApp(runner, { style: 'scss' }); + }); + + it('should add required dependencies to package.json', async () => { + const tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + + const elementNg = getPackageJsonDependency(tree, '@siemens/element-ng'); + const elementTheme = getPackageJsonDependency(tree, '@siemens/element-theme'); + const elementTranslate = getPackageJsonDependency(tree, '@siemens/element-translate-ng'); + const angularCdk = getPackageJsonDependency(tree, '@angular/cdk'); + const elementIcons = getPackageJsonDependency(tree, '@siemens/element-icons'); + + expect(elementNg).toBeDefined(); + expect(elementTheme).toBeDefined(); + expect(elementTranslate).toBeDefined(); + expect(angularCdk).toBeDefined(); + expect(elementIcons).toBeDefined(); + }); + + it('should overwrite dependencies that already exist', async () => { + // Pre-add one dependency + const packageJsonPath = '/package.json'; + const packageJsonBuffer = appTree.read(packageJsonPath); + if (!packageJsonBuffer) throw new Error('package.json not found'); + const packageJson = JSON.parse(packageJsonBuffer.toString()); + packageJson.dependencies['@siemens/element-theme'] = '^0.0.1'; + appTree.overwrite(packageJsonPath, JSON.stringify(packageJson, null, 2)); + + const tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + + const elementTheme = getPackageJsonDependency(tree, '@siemens/element-theme'); + expect(elementTheme?.version).not.toBe('^0.0.1'); + }); + + it('should add node_modules to stylePreprocessorOptions', async () => { + let tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + // Styling setup runs after npm install, so manually trigger it for tests + tree = await runner.runSchematic('ng-add-setup-element-styles', {}, tree); + + const angularJson = JSON.parse(tree.readContent('/angular.json')); + const buildOptions = angularJson.projects.app.architect.build.options; + + expect(buildOptions.stylePreprocessorOptions).toBeDefined(); + expect(buildOptions.stylePreprocessorOptions.includePaths).toContain('node_modules/'); + + const stylePath = (buildOptions.styles as string[]).find( + p => p.endsWith('.scss') || p.endsWith('.sass') + ); + expect(stylePath).toBeDefined(); + + const globalStyles = tree.readContent(stylePath!); + expect(globalStyles).toContain( + "@use '@siemens/element-icons/dist/style/siemens-element-icons';" + ); + expect(globalStyles).not.toContain( + "@use '@simpl/element-icons/dist/style/simpl-element-icons';" + ); + }); + + it('should warn if @simpl/element-ng is found', async () => { + // Add @simpl/element-ng to package.json + const packageJsonPath = '/package.json'; + const packageJsonBuffer = appTree.read(packageJsonPath); + if (!packageJsonBuffer) throw new Error('package.json not found'); + const packageJson = JSON.parse(packageJsonBuffer.toString()); + packageJson.dependencies['@simpl/element-ng'] = '^47.0.0'; + appTree.overwrite(packageJsonPath, JSON.stringify(packageJson, null, 2)); + + const tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + + // Should not add new dependencies when @simpl/element-ng is present + const elementTheme = getPackageJsonDependency(tree, '@siemens/element-theme'); + expect(elementTheme).toBeNull(); + }); + + it('should handle projects without build configuration gracefully', async () => { + const tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + expect(tree).toBeDefined(); + }); + + it('should not duplicate node_modules in stylePreprocessorOptions', async () => { + // Pre-configure stylePreprocessorOptions + const angularJsonPath = '/angular.json'; + const angularJsonBuffer = appTree.read(angularJsonPath); + if (!angularJsonBuffer) throw new Error('angular.json not found'); + const angularJson = JSON.parse(angularJsonBuffer.toString()); + angularJson.projects.app.architect.build.options.stylePreprocessorOptions = { + includePaths: ['node_modules/', 'src/styles'] + }; + appTree.overwrite(angularJsonPath, JSON.stringify(angularJson, null, 2)); + + let tree = await runner.runSchematic('ng-add', { path: '/' }, appTree); + // Styling setup runs after npm install, so manually trigger it for tests + tree = await runner.runSchematic('ng-add-setup-element-styles', {}, tree); + + const updatedAngularJson = JSON.parse(tree.readContent(angularJsonPath)); + const includePaths = + updatedAngularJson.projects.app.architect.build.options.stylePreprocessorOptions.includePaths; + + // Count occurrences of 'node_modules/' + const nodeModulesCount = includePaths.filter((p: string) => p === 'node_modules/').length; + expect(nodeModulesCount).toBe(1); + }); +}); diff --git a/projects/element-ng/schematics/ng-add/index.ts b/projects/element-ng/schematics/ng-add/index.ts index 2f06d1554..0bde1e772 100644 --- a/projects/element-ng/schematics/ng-add/index.ts +++ b/projects/element-ng/schematics/ng-add/index.ts @@ -5,15 +5,24 @@ import { chain, Rule, schematic, SchematicContext, Tree } from '@angular-devkit/schematics'; import { getPackageJsonDependency } from '@schematics/angular/utility/dependencies'; -export const ngAdd = (options: { path: string }): Rule => { +import { addElementDependencies } from './add-dependencies.js'; + +export const ngAdd = (): Rule => { return (tree: Tree, context: SchematicContext) => { context.logger.info('🔧 Adding @siemens/element-ng to your project...'); const hasSimplElementNgDependency = getPackageJsonDependency(tree, '@simpl/element-ng'); if (hasSimplElementNgDependency) { - const chainedRules = chain([schematic('simpl-siemens-migration', options)]); - return chainedRules(tree, context); + context.logger.warn( + '⚠️ Found @simpl/element-ng in dependencies. Please run ng update to migrate to @siemens/element-ng.' + ); + return tree; } + + return chain([addElementDependencies(), schematic('ng-add-setup-element-styles', {})])( + tree, + context + ); }; }; diff --git a/projects/element-ng/schematics/ng-add/schema.json b/projects/element-ng/schematics/ng-add/schema.json index e40f1e6b6..7df210ab2 100644 --- a/projects/element-ng/schematics/ng-add/schema.json +++ b/projects/element-ng/schematics/ng-add/schema.json @@ -3,14 +3,6 @@ "$id": "siemens-element-ng-add", "title": "Siemens Element ng-add schematic", "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the directory where all simpl imports should be migrated.", - "x-prompt": "Which directory do you want to migrate?", - "format": "path", - "default": "/" - } - }, + "properties": {}, "required": [] }