Skip to content

Commit 55fd57e

Browse files
wagnermacielalan-agius4
authored andcommitted
feat(builders): routesFile option for prerender builder
Closes #1395
1 parent 7bacde5 commit 55fd57e

File tree

6 files changed

+203
-47
lines changed

6 files changed

+203
-47
lines changed

modules/builders/src/prerender/index.spec.ts

+56-2
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,23 @@ describe('Prerender Builder', () => {
2828
await host.restore().toPromise();
2929
});
3030

31-
it('fails with error when no routes are provided', async () => {
31+
it('fails with error when .routes nor .routesFile are defined', async () => {
3232
const run = await architect.scheduleTarget(target);
3333
await expectAsync(run.result)
3434
.toBeRejectedWith(
35-
jasmine.objectContaining({ message: jasmine.stringMatching(`Data path "" should have required property 'routes'.`) })
35+
jasmine.objectContaining({ message: jasmine.stringMatching(/Data path "" should match some schema in anyOf./) })
3636
);
3737
await run.stop();
3838
});
3939

40+
it('fails with error when no routes are provided', async () => {
41+
const run = await architect.scheduleTarget(target, { routes: [] });
42+
await expectAsync(run.result).toBeRejectedWith(
43+
jasmine.objectContaining({ message: jasmine.stringMatching(/No routes found/)})
44+
);
45+
await run.stop();
46+
});
47+
4048
it('should generate output for route when provided', async () => {
4149
const run = await architect.scheduleTarget(target, { routes: ['foo'] });
4250
const output = await run.result;
@@ -73,4 +81,50 @@ describe('Prerender Builder', () => {
7381
expect(content).toContain('<router-outlet');
7482
await run.stop();
7583
});
84+
85+
it('should generate output for routes when provided with a file', async () => {
86+
await host.write(
87+
join(host.root(), 'routes-file.txt'),
88+
virtualFs.stringToFileBuffer(
89+
['/foo', '/foo/bar'].join('\n')
90+
),
91+
).toPromise();
92+
const run = await architect.scheduleTarget(target, {
93+
routes: ['/foo', '/'],
94+
routesFile: './routes-file.txt',
95+
});
96+
const output = await run.result;
97+
expect(output.success).toBe(true);
98+
99+
const fooContent = virtualFs.fileBufferToString(
100+
host.scopedSync().read(join(outputPathBrowser, 'foo/index.html'))
101+
);
102+
const fooBarContent = virtualFs.fileBufferToString(
103+
host.scopedSync().read(join(outputPathBrowser, 'foo/bar/index.html'))
104+
);
105+
const appContent = virtualFs.fileBufferToString(
106+
host.scopedSync().read(join(outputPathBrowser, 'index.html'))
107+
);
108+
109+
expect(appContent).toContain('app app is running!');
110+
expect(appContent).toContain('This page was prerendered with Angular Universal');
111+
112+
expect(fooContent).toContain('foo works!');
113+
expect(fooContent).toContain('This page was prerendered with Angular Universal');
114+
115+
expect(fooBarContent).toContain('foo-bar works!');
116+
expect(fooBarContent).toContain('This page was prerendered with Angular Universal');
117+
118+
await run.stop();
119+
});
120+
121+
it('should halt execution if a route file is given but does not exist.', async () => {
122+
const run = await architect.scheduleTarget(target, {
123+
routesFile: './nonexistent-file.txt',
124+
});
125+
await expectAsync(run.result).toBeRejectedWith(
126+
jasmine.objectContaining({ message: jasmine.stringMatching(/no such file or directory/)})
127+
);
128+
await run.stop();
129+
});
76130
});

modules/builders/src/prerender/index.ts

+64-41
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,70 @@ import * as fs from 'fs';
1313
import * as path from 'path';
1414

1515
import { Schema } from './schema';
16+
import { getRoutes } from './utils';
1617

1718
export type PrerenderBuilderOptions = Schema & json.JsonObject;
1819

19-
export type PrerenderBuilderOutput = BuilderOutput & {
20+
export type PrerenderBuilderOutput = BuilderOutput;
21+
22+
type BuildBuilderOutput = BuilderOutput & {
2023
baseOutputPath: string;
2124
outputPaths: string[];
2225
outputPath: string;
2326
};
2427

28+
type ScheduleBuildsOutput = BuilderOutput & {
29+
serverResult?: BuildBuilderOutput;
30+
browserResult?: BuildBuilderOutput;
31+
};
32+
33+
/**
34+
* Schedules the server and browser builds and returns their results if both builds are successful.
35+
*/
36+
async function _scheduleBuilds(
37+
options: PrerenderBuilderOptions,
38+
context: BuilderContext
39+
): Promise<ScheduleBuildsOutput> {
40+
const browserTarget = targetFromTargetString(options.browserTarget);
41+
const serverTarget = targetFromTargetString(options.serverTarget);
42+
43+
const browserTargetRun = await context.scheduleTarget(browserTarget, {
44+
watch: false,
45+
serviceWorker: false,
46+
// todo: handle service worker augmentation
47+
});
48+
const serverTargetRun = await context.scheduleTarget(serverTarget, {
49+
watch: false,
50+
});
51+
52+
try {
53+
const [browserResult, serverResult] = await Promise.all([
54+
browserTargetRun.result as unknown as BuildBuilderOutput,
55+
serverTargetRun.result as unknown as BuildBuilderOutput,
56+
]);
57+
58+
const success =
59+
browserResult.success && serverResult.success && browserResult.baseOutputPath !== undefined;
60+
const error = browserResult.error || serverResult.error as string;
61+
62+
return { success, error, browserResult, serverResult };
63+
} catch (e) {
64+
return { success: false, error: e.message };
65+
} finally {
66+
await Promise.all([browserTargetRun.stop(), serverTargetRun.stop()]);
67+
}
68+
}
69+
2570
/**
2671
* Renders each route in options.routes and writes them to
2772
* <route>/index.html for each output path in the browser result.
2873
*/
2974
async function _renderUniversal(
30-
options: Schema,
75+
routes: string[],
3176
context: BuilderContext,
32-
browserResult: PrerenderBuilderOutput,
33-
serverResult: PrerenderBuilderOutput,
34-
): Promise<PrerenderBuilderOutput> {
77+
browserResult: BuildBuilderOutput,
78+
serverResult: BuildBuilderOutput,
79+
): Promise<BuildBuilderOutput> {
3580
// We need to render the routes for each locale from the browser output.
3681
for (const outputPath of browserResult.outputPaths) {
3782
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
@@ -40,10 +85,10 @@ async function _renderUniversal(
4085
const { AppServerModuleDef, renderModuleFn } =
4186
await _getServerModuleBundle(serverResult, localeDirectory);
4287

43-
context.logger.info(`\nPrerendering ${options.routes.length} route(s) to ${outputPath}`);
88+
context.logger.info(`\nPrerendering ${routes.length} route(s) to ${outputPath}`);
4489

4590
// Render each route and write them to <route>/index.html.
46-
for (const route of options.routes) {
91+
for (const route of routes) {
4792
const renderOpts = {
4893
document: indexHtml + '<!-- This page was prerendered with Angular Universal -->',
4994
url: route,
@@ -59,8 +104,6 @@ async function _renderUniversal(
59104
fs.writeFileSync(browserIndexOutputPathOriginal, indexHtml);
60105
}
61106

62-
// There will never conflicting output folders
63-
// because items in options.routes must be unique.
64107
try {
65108
fs.mkdirSync(outputFolderPath, { recursive: true });
66109
fs.writeFileSync(outputIndexPath, html);
@@ -84,7 +127,7 @@ async function _renderUniversal(
84127
* Throws if no app module bundle is found.
85128
*/
86129
async function _getServerModuleBundle(
87-
serverResult: PrerenderBuilderOutput,
130+
serverResult: BuildBuilderOutput,
88131
browserLocaleDirectory: string,
89132
) {
90133
const { baseOutputPath = '' } = serverResult;
@@ -127,38 +170,18 @@ async function _getServerModuleBundle(
127170
export async function execute(
128171
options: PrerenderBuilderOptions,
129172
context: BuilderContext
130-
): Promise<PrerenderBuilderOutput | BuilderOutput> {
131-
const browserTarget = targetFromTargetString(options.browserTarget);
132-
const serverTarget = targetFromTargetString(options.serverTarget);
133-
134-
const browserTargetRun = await context.scheduleTarget(browserTarget, {
135-
watch: false,
136-
serviceWorker: false,
137-
// todo: handle service worker augmentation
138-
});
139-
const serverTargetRun = await context.scheduleTarget(serverTarget, {
140-
watch: false,
141-
});
142-
143-
try {
144-
const [browserResult, serverResult] = await Promise.all([
145-
browserTargetRun.result as unknown as PrerenderBuilderOutput,
146-
serverTargetRun.result as unknown as PrerenderBuilderOutput,
147-
]);
148-
149-
if (browserResult.success === false || browserResult.baseOutputPath === undefined) {
150-
return browserResult;
151-
}
152-
if (serverResult.success === false) {
153-
return serverResult;
154-
}
155-
156-
return await _renderUniversal(options, context, browserResult, serverResult);
157-
} catch (e) {
158-
return { success: false, error: e.message };
159-
} finally {
160-
await Promise.all([browserTargetRun.stop(), serverTargetRun.stop()]);
173+
): Promise<PrerenderBuilderOutput> {
174+
const routes = getRoutes(context.workspaceRoot, options.routesFile, options.routes);
175+
if (!routes.length) {
176+
throw new Error('No routes found.');
177+
}
178+
const result = await _scheduleBuilds(options, context);
179+
const { success, error, browserResult, serverResult } = result;
180+
if (!success || !browserResult || !serverResult) {
181+
return { success, error } as BuilderOutput;
161182
}
183+
184+
return _renderUniversal(routes, context, browserResult, serverResult);
162185
}
163186

164187
export default createBuilder(execute);

modules/builders/src/prerender/schema.json

+9-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"description": "Server target to use for prerendering the app.",
1414
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
1515
},
16+
"routesFile": {
17+
"type": "string",
18+
"description": "The path to a file containing routes separated by newlines."
19+
},
1620
"routes": {
1721
"type": "array",
1822
"description": "The routes to render.",
@@ -26,8 +30,11 @@
2630
},
2731
"required": [
2832
"browserTarget",
29-
"serverTarget",
30-
"routes"
33+
"serverTarget"
34+
],
35+
"anyOf": [
36+
{ "required": ["routes"] },
37+
{ "required": ["routesFile"] }
3138
],
3239
"additionalProperties": false
3340
}

modules/builders/src/prerender/schema.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
export interface Schema {
9+
export interface Schema {
1010
/**
1111
* Target to build.
1212
*/
1313
browserTarget: string;
1414
/**
1515
* The routes to render.
1616
*/
17-
routes: string[];
17+
routes?: string[];
18+
/**
19+
* The path to a file containing routes separated by newlines.
20+
*/
21+
routesFile?: string;
1822
/**
1923
* Server target to use for prerendering the app.
2024
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { getRoutes } from './utils';
10+
import * as fs from 'fs';
11+
12+
describe('Prerender Builder Utils', () => {
13+
describe('#getRoutes', () => {
14+
const WORKSPACE_ROOT = '/path/to/angular/json';
15+
const ROUTES_FILE = './routes.txt';
16+
const ROUTES_FILE_CONTENT = ['/route1', '/route1', '/route2', '/route3'].join('\n');
17+
const ROUTES = ['/route3', '/route3', '/route4'];
18+
19+
beforeEach(() => {
20+
spyOn(fs, 'readFileSync').and.returnValue(ROUTES_FILE_CONTENT);
21+
});
22+
23+
it('Should return the deduped union of options.routes and options.routesFile - routes and routesFile defined', () => {
24+
const routes = getRoutes(WORKSPACE_ROOT, ROUTES_FILE, ROUTES);
25+
expect(routes).toEqual(['/route1', '/route2', '/route3', '/route4']);
26+
});
27+
28+
it('Should return the deduped union of options.routes and options.routesFile - only routes defined', () => {
29+
const routes = getRoutes(WORKSPACE_ROOT, undefined, ROUTES);
30+
expect(routes).toEqual(['/route3', '/route4']);
31+
});
32+
33+
it('Should return the deduped union of options.routes and options.routesFile - only routes file defined', () => {
34+
const routes = getRoutes(WORKSPACE_ROOT, ROUTES_FILE, undefined);
35+
expect(routes).toEqual(['/route1', '/route2', '/route3']);
36+
});
37+
});
38+
});
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
/**
13+
* Returns the concatenation of options.routes and the contents of options.routesFile.
14+
*/
15+
export function getRoutes(
16+
workspaceRoot: string,
17+
routesFile?: string,
18+
routes: string[] = [],
19+
): string[] {
20+
let routesFileResult: string[] = [];
21+
if (routesFile) {
22+
const routesFilePath = path.resolve(workspaceRoot, routesFile);
23+
24+
routesFileResult = fs.readFileSync(routesFilePath, 'utf8')
25+
.split(/\r?\n/)
26+
.filter(v => !!v);
27+
}
28+
29+
return [...new Set([...routesFileResult, ...routes])];
30+
}

0 commit comments

Comments
 (0)