import { strings } from '@angular-devkit/core'; import { Rule, MergeStrategy, SchematicsException, Tree, apply, applyTemplates, chain, filter, mergeWith, move, url, } from '@angular-devkit/schematics'; import { createProjectSchematic } from '@schematics/angular/utility/project'; import { parseName } from '@schematics/angular/utility/parse-name'; import { validateClassName, validateHtmlSelector } from '@schematics/angular/utility/validation'; import { buildDefaultPath } from '@schematics/angular/utility/workspace'; import * as path from 'node:path'; import ts from 'typescript'; import { Schema } from './schema'; interface ProjectDefinition { root?: string; sourceRoot?: string; prefix?: string; } export const feature = createProjectSchematic((options, { project }) => { validateOptions(options); options.path ??= buildDefaultPath(project); const parsedPath = parseName(options.path, options.name); const name = parsedPath.name; const page = options.page ?? true; const routing = options.routing ?? true; const lazy = options.lazy ?? true; const route = normalizeRoute(options.route ?? strings.dasherize(name)); const preset = options.preset ?? 'minimal'; const selector = `${project.prefix ? `${project.prefix}-` : ''}${strings.dasherize(name)}-page`; validateClassName(`${strings.classify(name)}Page`); validateHtmlSelector(selector); const templateSource = apply(url('./files'), [ filter((filePath) => { if (filePath.includes('__name@dasherize__-page') && !page) { return false; } if (filePath.endsWith('.routes.ts.template') && !routing) { return false; } return !filePath.endsWith('README.md.template') || (!page && !routing); }), applyTemplates({ ...strings, name, selector, page, routing, lazy, route, preset, }), move(parsedPath.path), ]); const rules: Rule[] = [mergeWith(templateSource, options.force ? MergeStrategy.Overwrite : MergeStrategy.Default)]; if (options.registerRoute) { if (!routing) { throw new SchematicsException('The feature route cannot be registered when --routing=false.'); } rules.push(registerFeatureRoute(project, path.posix.join(parsedPath.path, strings.dasherize(name)), name, route, lazy)); } return chain(rules); }); function validateOptions(options: Schema): void { const unsupportedOptions = [ options.api ? '--api' : undefined, options.state ? '--state' : undefined, options.models ? '--models' : undefined, options.e2e ? '--e2e' : undefined, ].filter((option): option is string => Boolean(option)); if (unsupportedOptions.length > 0) { throw new SchematicsException( `${unsupportedOptions.join(', ')} will be implemented in later roadmap phases and cannot be used yet.`, ); } if (options.preset && options.preset !== 'minimal') { throw new SchematicsException('Only the minimal feature preset is implemented in this phase.'); } } function normalizeRoute(route: string): string { const normalized = route.trim().replace(/^\/+|\/+$/g, ''); if (!normalized || normalized.includes('//') || !/^[a-zA-Z0-9._~!$&()*+,;=:@%/-]+$/.test(normalized)) { throw new SchematicsException('Feature route must be a non-empty URL segment or path.'); } return normalized; } function registerFeatureRoute( project: ProjectDefinition, featurePath: string, name: string, route: string, lazy: boolean, ): Rule { return (tree) => { const appRoutesPath = resolveAppRoutesPath(tree, project); const sourceText = tree.readText(appRoutesPath); const sourceFile = ts.createSourceFile(appRoutesPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const routesArray = findRoutesArray(sourceFile); if (!routesArray) { throw new SchematicsException(`Could not find a Routes array in ${appRoutesPath}. Register the feature manually.`); } if (hasRoute(routesArray, route)) { throw new SchematicsException(`Route "${route}" already exists in ${appRoutesPath}.`); } const featureRoutesPath = path.posix.join(featurePath, `${strings.dasherize(name)}.routes`); let importPath = path.posix.relative(path.posix.dirname(appRoutesPath), featureRoutesPath); if (!importPath.startsWith('.')) { importPath = `./${importPath}`; } const exportName = `${strings.underscore(name).toUpperCase()}_ROUTES`; const routeEntry = lazy ? `{\n path: '${route}',\n loadChildren: () => import('${importPath}').then((routes) => routes.${exportName}),\n }` : `{\n path: '${route}',\n children: ${exportName},\n }`; if (!lazy) { throw new SchematicsException( 'Registering an eager feature route requires a static import and is not implemented safely yet. Use the default --lazy option.', ); } const insertionPoint = routesArray.elements.end; const separator = routesArray.elements.length > 0 && !routesArray.elements.hasTrailingComma ? ',' : ''; const insertion = `${separator}\n ${routeEntry},\n`; const updated = `${sourceText.slice(0, insertionPoint)}${insertion}${sourceText.slice(insertionPoint)}`; tree.overwrite(appRoutesPath, updated); }; } function resolveAppRoutesPath(tree: Tree, project: ProjectDefinition): string { const sourceRoot = trimSlashes(project.sourceRoot ?? path.posix.join(project.root ?? '', 'src')); const candidates = [ `/${path.posix.join(sourceRoot, 'app/app.routes.ts')}`, `/${path.posix.join(project.root ?? '', 'src/app/app.routes.ts')}`, ]; const match = candidates.find((candidate) => tree.exists(candidate)); if (!match) { throw new SchematicsException( `Could not find app.routes.ts for the selected project. Re-run without --register-route and register the feature manually.`, ); } return match; } function findRoutesArray(sourceFile: ts.SourceFile): ts.ArrayLiteralExpression | undefined { let fallback: ts.ArrayLiteralExpression | undefined; for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) { continue; } for (const declaration of statement.declarationList.declarations) { if (!declaration.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) { continue; } fallback ??= declaration.initializer; if (ts.isIdentifier(declaration.name) && declaration.name.text.toLowerCase().includes('routes')) { return declaration.initializer; } } } return fallback; } function hasRoute(routes: ts.ArrayLiteralExpression, route: string): boolean { return routes.elements.some((element) => { if (!ts.isObjectLiteralExpression(element)) { return false; } return element.properties.some((property) => { if (!ts.isPropertyAssignment(property) || property.name.getText() !== 'path') { return false; } return ts.isStringLiteralLike(property.initializer) && property.initializer.text === route; }); }); } function trimSlashes(value: string): string { return value.replace(/^\/+|\/+$/g, ''); }