Skip to content

Commit 970c5d0

Browse files
committed
fix: add Angular 20 compatibility for ng add schematic
Angular 20 simplified angular.json by removing outputPath option entirely (uses sensible defaults). The ng-add schematic now accepts: - Angular 20+: outputPath undefined (uses default dist/<project>) - Angular 17-19: outputPath as object { base, browser, ... } - Earlier: outputPath as string Fixes #199 See: angular/angular-cli#26675
1 parent d401375 commit 970c5d0

File tree

3 files changed

+178
-5
lines changed

3 files changed

+178
-5
lines changed

src/deploy/actions.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,27 @@ describe('Deploy Angular apps', () => {
191191
// actions.ts successfully handled the object case)
192192
expect(capturedDir).not.toBeNull();
193193
});
194+
195+
it('uses correct dir when outputPath is object with empty browser (Angular 19 SPA style)', async () => {
196+
// Angular 19 can use browser: "" to output directly to base folder
197+
let capturedDir: string | null = null;
198+
199+
const mockEngineWithCapture: EngineHost = {
200+
run: (dir: string, _options: Schema, _logger: logging.LoggerApi) => {
201+
capturedDir = dir;
202+
return Promise.resolve();
203+
}
204+
};
205+
206+
context.getTargetOptions = (_: Target) =>
207+
Promise.resolve({
208+
outputPath: { base: 'dist/my-app', browser: '' }
209+
} as JsonObject);
210+
211+
await deploy(mockEngineWithCapture, context, BUILD_TARGET, { noBuild: false });
212+
213+
expect(capturedDir).toBe('dist/my-app');
214+
});
194215
});
195216
});
196217

src/ng-add.spec.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,18 @@ describe('ng-add', () => {
116116
);
117117
});
118118

119-
it('should throw if app does not have architect configured', async () => {
119+
it('should throw if app does not have build target configured', async () => {
120120
const tree = Tree.empty();
121121
tree.create(
122122
'angular.json',
123123
JSON.stringify({
124124
version: 1,
125125
projects: {
126-
[PROJECT_NAME]: { projectType: 'application', root: PROJECT_NAME }
126+
[PROJECT_NAME]: {
127+
projectType: 'application',
128+
root: PROJECT_NAME,
129+
architect: {}
130+
}
127131
}
128132
})
129133
);
@@ -133,7 +137,135 @@ describe('ng-add', () => {
133137
project: PROJECT_NAME
134138
})(tree, {} as SchematicContext)
135139
).rejects.toThrowError(
136-
'Cannot read the output path (architect.build.options.outputPath) of the Angular project "THEPROJECT" in angular.json'
140+
/Cannot find build target for the Angular project/
141+
);
142+
});
143+
});
144+
145+
describe('Angular 17+ outputPath formats', () => {
146+
it('should accept Angular 20+ projects without outputPath (uses default)', async () => {
147+
const tree = Tree.empty();
148+
tree.create(
149+
'angular.json',
150+
JSON.stringify({
151+
version: 1,
152+
projects: {
153+
[PROJECT_NAME]: {
154+
projectType: 'application',
155+
root: PROJECT_ROOT,
156+
architect: {
157+
build: {
158+
builder: '@angular/build:application',
159+
options: {
160+
// Angular 20+ omits outputPath - uses sensible default
161+
}
162+
}
163+
}
164+
}
165+
}
166+
})
167+
);
168+
169+
const result = await ngAdd({ project: PROJECT_NAME })(
170+
tree,
171+
{} as SchematicContext
172+
);
173+
174+
const resultConfig = readJSONFromTree(result, 'angular.json');
175+
const deployTarget = resultConfig.projects[PROJECT_NAME].architect.deploy;
176+
expect(deployTarget.builder).toBe('angular-cli-ghpages:deploy');
177+
});
178+
179+
it('should accept outputPath as object with base property', async () => {
180+
const tree = Tree.empty();
181+
tree.create(
182+
'angular.json',
183+
JSON.stringify({
184+
version: 1,
185+
projects: {
186+
[PROJECT_NAME]: {
187+
projectType: 'application',
188+
root: PROJECT_ROOT,
189+
architect: {
190+
build: {
191+
options: {
192+
outputPath: { base: 'dist/my-app', browser: '' }
193+
}
194+
}
195+
}
196+
}
197+
}
198+
})
199+
);
200+
201+
const result = await ngAdd({ project: PROJECT_NAME })(
202+
tree,
203+
{} as SchematicContext
204+
);
205+
206+
const resultConfig = readJSONFromTree(result, 'angular.json');
207+
const deployTarget = resultConfig.projects[PROJECT_NAME].architect.deploy;
208+
expect(deployTarget.builder).toBe('angular-cli-ghpages:deploy');
209+
});
210+
211+
it('should accept outputPath object with base and browser properties', async () => {
212+
const tree = Tree.empty();
213+
tree.create(
214+
'angular.json',
215+
JSON.stringify({
216+
version: 1,
217+
projects: {
218+
[PROJECT_NAME]: {
219+
projectType: 'application',
220+
root: PROJECT_ROOT,
221+
architect: {
222+
build: {
223+
options: {
224+
outputPath: { base: 'dist/app', browser: 'browser', server: 'server' }
225+
}
226+
}
227+
}
228+
}
229+
}
230+
})
231+
);
232+
233+
const result = await ngAdd({ project: PROJECT_NAME })(
234+
tree,
235+
{} as SchematicContext
236+
);
237+
238+
const resultConfig = readJSONFromTree(result, 'angular.json');
239+
const deployTarget = resultConfig.projects[PROJECT_NAME].architect.deploy;
240+
expect(deployTarget.builder).toBe('angular-cli-ghpages:deploy');
241+
});
242+
243+
it('should reject invalid outputPath object without base property', async () => {
244+
const tree = Tree.empty();
245+
tree.create(
246+
'angular.json',
247+
JSON.stringify({
248+
version: 1,
249+
projects: {
250+
[PROJECT_NAME]: {
251+
projectType: 'application',
252+
root: PROJECT_ROOT,
253+
architect: {
254+
build: {
255+
options: {
256+
outputPath: { browser: 'browser' } // missing base - invalid
257+
}
258+
}
259+
}
260+
}
261+
}
262+
})
263+
);
264+
265+
await expect(
266+
ngAdd({ project: PROJECT_NAME })(tree, {} as SchematicContext)
267+
).rejects.toThrowError(
268+
/Invalid outputPath configuration.*Expected undefined.*a string.*an object with a "base" property/
137269
);
138270
});
139271
});

src/ng-add.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,29 @@ export const ngAdd = (options: NgAddOptions) => async (
4141
);
4242
}
4343

44-
if (!project.targets.get('build')?.options?.outputPath) {
44+
// Validate build target exists (required for deployment)
45+
const buildTarget = project.targets.get('build');
46+
if (!buildTarget) {
4547
throw new SchematicsException(
46-
`Cannot read the output path (architect.build.options.outputPath) of the Angular project "${options.project}" in angular.json`
48+
`Cannot find build target for the Angular project "${options.project}" in angular.json.`
49+
);
50+
}
51+
52+
// outputPath validation:
53+
// - Angular 20+: outputPath is omitted (uses default dist/<project-name>)
54+
// - Angular 17-19: object format { base: "dist/app", browser: "", ... }
55+
// - Earlier versions: string format "dist/app"
56+
// See: https://github.com/angular/angular-cli/pull/26675
57+
const outputPath = buildTarget.options?.outputPath;
58+
const hasValidOutputPath =
59+
outputPath === undefined || // Angular 20+ uses sensible defaults
60+
typeof outputPath === 'string' ||
61+
(typeof outputPath === 'object' && outputPath !== null && 'base' in outputPath);
62+
63+
if (!hasValidOutputPath) {
64+
throw new SchematicsException(
65+
`Invalid outputPath configuration for the Angular project "${options.project}" in angular.json. ` +
66+
`Expected undefined (default), a string, or an object with a "base" property.`
4767
);
4868
}
4969

0 commit comments

Comments
 (0)