mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-18 20:02:40 +08:00
feat(@schematics/angular): update SSR and application builder migration schematics to work with new outputPath
In #26675 we introduced a long-form variant of `outputPath`, this commit updates the application builder migration and ssr schematics to handle this change.
This commit is contained in:
parent
69d2dfdb50
commit
a708dccff3
@ -14,7 +14,7 @@ import {
|
||||
chain,
|
||||
externalSchematic,
|
||||
} from '@angular-devkit/schematics';
|
||||
import { dirname } from 'node:path';
|
||||
import { dirname, join } from 'node:path/posix';
|
||||
import { JSONFile } from '../../utility/json-file';
|
||||
import { TreeWorkspaceHost, allTargetOptions, getWorkspace } from '../../utility/workspace';
|
||||
import { Builders, ProjectType } from '../../utility/workspace-models';
|
||||
@ -68,8 +68,33 @@ export default function (): Rule {
|
||||
options['polyfills'] = [options['polyfills']];
|
||||
}
|
||||
|
||||
if (typeof options['outputPath'] === 'string') {
|
||||
options['outputPath'] = options['outputPath']?.replace(/\/browser\/?$/, '');
|
||||
let outputPath = options['outputPath'];
|
||||
if (typeof outputPath === 'string') {
|
||||
if (!/\/browser\/?$/.test(outputPath)) {
|
||||
// TODO: add prompt.
|
||||
context.logger.warn(
|
||||
`The output location of the browser build has been updated from "${outputPath}" to ` +
|
||||
`"${join(outputPath, 'browser')}". ` +
|
||||
'You might need to adjust your deployment pipeline or, as an alternative, ' +
|
||||
'set outputPath.browser to "" in order to maintain the previous functionality.',
|
||||
);
|
||||
} else {
|
||||
outputPath = outputPath.replace(/\/browser\/?$/, '');
|
||||
}
|
||||
|
||||
options['outputPath'] = {
|
||||
base: outputPath,
|
||||
};
|
||||
|
||||
if (typeof options['resourcesOutputPath'] === 'string') {
|
||||
const media = options['resourcesOutputPath'].replaceAll('/', '');
|
||||
if (media && media !== 'media') {
|
||||
options['outputPath'] = {
|
||||
base: outputPath,
|
||||
media: media,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed options
|
||||
@ -189,13 +214,5 @@ function usesNoLongerSupportedOptions(
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof resourcesOutputPath === 'string' && /^\/?media\/?$/.test(resourcesOutputPath)) {
|
||||
hasUsage = true;
|
||||
context.logger.warn(
|
||||
`Skipping migration for project "${projectName}". "resourcesOutputPath" option is not available in the application builder.` +
|
||||
`Media files will be output into a "media" directory within the output location.`,
|
||||
);
|
||||
}
|
||||
|
||||
return hasUsage;
|
||||
}
|
||||
|
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import { EmptyTree } from '@angular-devkit/schematics';
|
||||
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
|
||||
import { Builders, ProjectType, WorkspaceSchema } from '../../utility/workspace-models';
|
||||
|
||||
function createWorkSpaceConfig(tree: UnitTestTree) {
|
||||
const angularConfig: WorkspaceSchema = {
|
||||
version: 1,
|
||||
projects: {
|
||||
app: {
|
||||
root: '/project/lib',
|
||||
sourceRoot: '/project/app/src',
|
||||
projectType: ProjectType.Application,
|
||||
prefix: 'app',
|
||||
architect: {
|
||||
build: {
|
||||
builder: Builders.Browser,
|
||||
options: {
|
||||
tsConfig: 'src/tsconfig.app.json',
|
||||
main: 'src/main.ts',
|
||||
polyfills: 'src/polyfills.ts',
|
||||
outputPath: 'dist/project',
|
||||
resourcesOutputPath: '/resources',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2));
|
||||
tree.create('/tsconfig.json', JSON.stringify({}, undefined, 2));
|
||||
tree.create('/package.json', JSON.stringify({}, undefined, 2));
|
||||
}
|
||||
|
||||
describe(`Migration to use the application builder`, () => {
|
||||
const schematicName = 'use-application-builder';
|
||||
const schematicRunner = new SchematicTestRunner(
|
||||
'migrations',
|
||||
require.resolve('../migration-collection.json'),
|
||||
);
|
||||
|
||||
let tree: UnitTestTree;
|
||||
beforeEach(() => {
|
||||
tree = new UnitTestTree(new EmptyTree());
|
||||
createWorkSpaceConfig(tree);
|
||||
});
|
||||
|
||||
it(`should replace 'outputPath' to string if 'resourcesOutputPath' is set to 'media'`, async () => {
|
||||
// Replace resourcesOutputPath
|
||||
tree.overwrite('angular.json', tree.readContent('angular.json').replace('/resources', 'media'));
|
||||
|
||||
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
|
||||
const {
|
||||
projects: { app },
|
||||
} = JSON.parse(newTree.readContent('/angular.json'));
|
||||
|
||||
const { outputPath, resourcesOutputPath } = app.architect['build'].options;
|
||||
expect(outputPath).toEqual({
|
||||
base: 'dist/project',
|
||||
});
|
||||
expect(resourcesOutputPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`should set 'outputPath.media' if 'resourcesOutputPath' is set and is not 'media'`, async () => {
|
||||
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
|
||||
const {
|
||||
projects: { app },
|
||||
} = JSON.parse(newTree.readContent('/angular.json'));
|
||||
|
||||
const { outputPath, resourcesOutputPath } = app.architect['build'].options;
|
||||
expect(outputPath).toEqual({
|
||||
base: 'dist/project',
|
||||
media: 'resources',
|
||||
});
|
||||
expect(resourcesOutputPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`should remove 'browser' portion from 'outputPath'`, async () => {
|
||||
// Replace outputPath
|
||||
tree.overwrite(
|
||||
'angular.json',
|
||||
tree.readContent('angular.json').replace('dist/project/', 'dist/project/browser/'),
|
||||
);
|
||||
|
||||
const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
|
||||
const {
|
||||
projects: { app },
|
||||
} = JSON.parse(newTree.readContent('/angular.json'));
|
||||
|
||||
const { outputPath } = app.architect['build'].options;
|
||||
expect(outputPath).toEqual({
|
||||
base: 'dist/project',
|
||||
media: 'resources',
|
||||
});
|
||||
});
|
||||
});
|
@ -9,7 +9,7 @@ import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> fr
|
||||
export function app(): express.Express {
|
||||
const server = express();
|
||||
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||
const browserDistFolder = resolve(serverDistFolder, '../<%= browserDistDirectory %>');
|
||||
const indexHtml = join(serverDistFolder, 'index.server.html');
|
||||
|
||||
const commonEngine = new CommonEngine();
|
||||
@ -19,7 +19,7 @@ export function app(): express.Express {
|
||||
|
||||
// Example Express Rest API endpoints
|
||||
// server.get('/api/**', (req, res) => { });
|
||||
// Serve static files from /browser
|
||||
// Serve static files from /<%= browserDistDirectory %>
|
||||
server.get('*.*', express.static(browserDistFolder, {
|
||||
maxAge: '1y'
|
||||
}));
|
||||
|
@ -6,9 +6,10 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import { join, normalize, strings } from '@angular-devkit/core';
|
||||
import { isJsonObject, join, normalize, strings } from '@angular-devkit/core';
|
||||
import {
|
||||
Rule,
|
||||
SchematicContext,
|
||||
SchematicsException,
|
||||
Tree,
|
||||
apply,
|
||||
@ -19,6 +20,7 @@ import {
|
||||
schematic,
|
||||
url,
|
||||
} from '@angular-devkit/schematics';
|
||||
import { posix } from 'node:path';
|
||||
import { Schema as ServerOptions } from '../server/schema';
|
||||
import { DependencyType, addDependency, readWorkspace, updateWorkspace } from '../utility';
|
||||
import { JSONFile } from '../utility/json-file';
|
||||
@ -33,8 +35,11 @@ import { Schema as SSROptions } from './schema';
|
||||
|
||||
const SERVE_SSR_TARGET_NAME = 'serve-ssr';
|
||||
const PRERENDER_TARGET_NAME = 'prerender';
|
||||
const DEFAULT_BROWSER_DIR = 'browser';
|
||||
const DEFAULT_MEDIA_DIR = 'media';
|
||||
const DEFAULT_SERVER_DIR = 'server';
|
||||
|
||||
async function getOutputPath(
|
||||
async function getLegacyOutputPaths(
|
||||
host: Tree,
|
||||
projectName: string,
|
||||
target: 'server' | 'build',
|
||||
@ -42,12 +47,12 @@ async function getOutputPath(
|
||||
// Generate new output paths
|
||||
const workspace = await readWorkspace(host);
|
||||
const project = workspace.projects.get(projectName);
|
||||
const serverTarget = project?.targets.get(target);
|
||||
if (!serverTarget || !serverTarget.options) {
|
||||
const architectTarget = project?.targets.get(target);
|
||||
if (!architectTarget?.options) {
|
||||
throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
|
||||
}
|
||||
|
||||
const { outputPath } = serverTarget.options;
|
||||
const { outputPath } = architectTarget.options;
|
||||
if (typeof outputPath !== 'string') {
|
||||
throw new SchematicsException(
|
||||
`outputPath for ${projectName} ${target} target is not a string.`,
|
||||
@ -57,6 +62,52 @@ async function getOutputPath(
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function getApplicationBuilderOutputPaths(
|
||||
host: Tree,
|
||||
projectName: string,
|
||||
): Promise<{ browser: string; server: string; base: string }> {
|
||||
// Generate new output paths
|
||||
const target = 'build';
|
||||
const workspace = await readWorkspace(host);
|
||||
const project = workspace.projects.get(projectName);
|
||||
const architectTarget = project?.targets.get(target);
|
||||
|
||||
if (!architectTarget?.options) {
|
||||
throw new SchematicsException(`Cannot find 'options' for ${projectName} ${target} target.`);
|
||||
}
|
||||
|
||||
const { outputPath } = architectTarget.options;
|
||||
if (outputPath === null || outputPath === undefined) {
|
||||
throw new SchematicsException(
|
||||
`outputPath for ${projectName} ${target} target is undeined or null.`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultDirs = {
|
||||
server: DEFAULT_SERVER_DIR,
|
||||
browser: DEFAULT_BROWSER_DIR,
|
||||
};
|
||||
|
||||
if (outputPath && isJsonObject(outputPath)) {
|
||||
return {
|
||||
...defaultDirs,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(outputPath as any),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof outputPath !== 'string') {
|
||||
throw new SchematicsException(
|
||||
`outputPath for ${projectName} ${target} target is not a string.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
base: outputPath,
|
||||
...defaultDirs,
|
||||
};
|
||||
}
|
||||
|
||||
function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: boolean): Rule {
|
||||
return async (host) => {
|
||||
const pkgPath = '/package.json';
|
||||
@ -66,11 +117,11 @@ function addScriptsRule({ project }: SSROptions, isUsingApplicationBuilder: bool
|
||||
}
|
||||
|
||||
if (isUsingApplicationBuilder) {
|
||||
const distPath = await getOutputPath(host, project, 'build');
|
||||
const { base, server } = await getApplicationBuilderOutputPaths(host, project);
|
||||
pkg.scripts ??= {};
|
||||
pkg.scripts[`serve:ssr:${project}`] = `node ${distPath}/server/server.mjs`;
|
||||
pkg.scripts[`serve:ssr:${project}`] = `node ${posix.join(base, server)}/server.mjs`;
|
||||
} else {
|
||||
const serverDist = await getOutputPath(host, project, 'server');
|
||||
const serverDist = await getLegacyOutputPaths(host, project, 'server');
|
||||
pkg.scripts = {
|
||||
...pkg.scripts,
|
||||
'dev:ssr': `ng run ${project}:${SERVE_SSR_TARGET_NAME}`,
|
||||
@ -111,6 +162,7 @@ function updateApplicationBuilderTsConfigRule(options: SSROptions): Rule {
|
||||
function updateApplicationBuilderWorkspaceConfigRule(
|
||||
projectRoot: string,
|
||||
options: SSROptions,
|
||||
{ logger }: SchematicContext,
|
||||
): Rule {
|
||||
return updateWorkspace((workspace) => {
|
||||
const buildTarget = workspace.projects.get(options.project)?.targets.get('build');
|
||||
@ -118,8 +170,32 @@ function updateApplicationBuilderWorkspaceConfigRule(
|
||||
return;
|
||||
}
|
||||
|
||||
let outputPath = buildTarget.options?.outputPath;
|
||||
if (outputPath && isJsonObject(outputPath)) {
|
||||
if (outputPath.browser === '') {
|
||||
const base = outputPath.base as string;
|
||||
logger.warn(
|
||||
`The output location of the browser build has been updated from "${base}" to "${posix.join(
|
||||
base,
|
||||
DEFAULT_BROWSER_DIR,
|
||||
)}".
|
||||
You might need to adjust your deployment pipeline.`,
|
||||
);
|
||||
|
||||
if (
|
||||
(outputPath.media && outputPath.media !== DEFAULT_MEDIA_DIR) ||
|
||||
(outputPath.server && outputPath.server !== DEFAULT_SERVER_DIR)
|
||||
) {
|
||||
delete outputPath.browser;
|
||||
} else {
|
||||
outputPath = outputPath.base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTarget.options = {
|
||||
...buildTarget.options,
|
||||
outputPath,
|
||||
prerender: true,
|
||||
ssr: {
|
||||
entry: join(normalize(projectRoot), 'server.ts'),
|
||||
@ -238,23 +314,22 @@ function addDependencies(isUsingApplicationBuilder: boolean): Rule {
|
||||
|
||||
function addServerFile(options: ServerOptions, isStandalone: boolean): Rule {
|
||||
return async (host) => {
|
||||
const projectName = options.project;
|
||||
const workspace = await readWorkspace(host);
|
||||
const project = workspace.projects.get(options.project);
|
||||
const project = workspace.projects.get(projectName);
|
||||
if (!project) {
|
||||
throw new SchematicsException(`Invalid project name (${options.project})`);
|
||||
throw new SchematicsException(`Invalid project name (${projectName})`);
|
||||
}
|
||||
const isUsingApplicationBuilder =
|
||||
project?.targets?.get('build')?.builder === Builders.Application;
|
||||
|
||||
const browserDistDirectory = await getOutputPath(host, options.project, 'build');
|
||||
const browserDistDirectory = isUsingApplicationBuilder
|
||||
? (await getApplicationBuilderOutputPaths(host, projectName)).browser
|
||||
: await getLegacyOutputPaths(host, projectName, 'build');
|
||||
|
||||
return mergeWith(
|
||||
apply(
|
||||
url(
|
||||
`./files/${
|
||||
project?.targets?.get('build')?.builder === Builders.Application
|
||||
? 'application-builder'
|
||||
: 'server-builder'
|
||||
}`,
|
||||
),
|
||||
url(`./files/${isUsingApplicationBuilder ? 'application-builder' : 'server-builder'}`),
|
||||
[
|
||||
applyTemplates({
|
||||
...strings,
|
||||
@ -270,7 +345,7 @@ function addServerFile(options: ServerOptions, isStandalone: boolean): Rule {
|
||||
}
|
||||
|
||||
export default function (options: SSROptions): Rule {
|
||||
return async (host) => {
|
||||
return async (host, context) => {
|
||||
const browserEntryPoint = await getMainFilePath(host, options.project);
|
||||
const isStandalone = isStandaloneApp(host, browserEntryPoint);
|
||||
|
||||
@ -289,7 +364,7 @@ export default function (options: SSROptions): Rule {
|
||||
}),
|
||||
...(isUsingApplicationBuilder
|
||||
? [
|
||||
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options),
|
||||
updateApplicationBuilderWorkspaceConfigRule(clientProject.root, options, context),
|
||||
updateApplicationBuilderTsConfigRule(options),
|
||||
]
|
||||
: [
|
||||
|
@ -143,6 +143,52 @@ describe('SSR Schematic', () => {
|
||||
|
||||
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/server/server.mjs`);
|
||||
});
|
||||
|
||||
it('works when using a custom "outputPath.browser" and "outputPath.server" values', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const config = appTree.readJson('/angular.json') as any;
|
||||
const build = config.projects['test-app'].architect.build;
|
||||
|
||||
build.options.outputPath = {
|
||||
base: build.options.outputPath,
|
||||
browser: 'public',
|
||||
server: 'node-server',
|
||||
};
|
||||
|
||||
appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2));
|
||||
const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree);
|
||||
|
||||
const { scripts } = tree.readJson('/package.json') as { scripts: Record<string, string> };
|
||||
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/node-server/server.mjs`);
|
||||
|
||||
const serverFileContent = tree.readContent('/projects/test-app/server.ts');
|
||||
expect(serverFileContent).toContain(`resolve(serverDistFolder, '../public')`);
|
||||
});
|
||||
|
||||
it(`removes "outputPath.browser" when it's an empty string`, async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const config = appTree.readJson('/angular.json') as any;
|
||||
const build = config.projects['test-app'].architect.build;
|
||||
|
||||
build.options.outputPath = {
|
||||
base: build.options.outputPath,
|
||||
browser: '',
|
||||
server: 'node-server',
|
||||
};
|
||||
|
||||
appTree.overwrite('/angular.json', JSON.stringify(config, undefined, 2));
|
||||
const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree);
|
||||
|
||||
const { scripts } = tree.readJson('/package.json') as { scripts: Record<string, string> };
|
||||
expect(scripts['serve:ssr:test-app']).toBe(`node dist/test-app/node-server/server.mjs`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updatedConfig = tree.readJson('/angular.json') as any;
|
||||
expect(updatedConfig.projects['test-app'].architect.build.options.outputPath).toEqual({
|
||||
base: 'dist/test-app',
|
||||
server: 'node-server',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy browser builder', () => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user