Skip to content

Commit cc0e7f3

Browse files
wagnermacielvikerman
authored andcommitted
feat(builders): extract routes with guess-js (#1426)
Use the static routes inferred from the routing module as initial set of routes to prerender. Can set the builder `guessRoutes` option to `false` to turn off this behavior.
1 parent 36db45a commit cc0e7f3

File tree

11 files changed

+500
-172
lines changed

11 files changed

+500
-172
lines changed

modules/builders/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ts_library(
2828
"@npm//@types/browser-sync",
2929
"@npm//@types/http-proxy-middleware",
3030
"@npm//browser-sync",
31+
"@npm//guess-parser",
3132
"@npm//http-proxy-middleware",
3233
"@npm//rxjs",
3334
"@npm//tree-kill",
@@ -85,6 +86,7 @@ ng_test_library(
8586
"@npm//@angular-devkit/core",
8687
"@npm//@types/browser-sync",
8788
"@npm//@types/shelljs",
89+
"@npm//guess-parser",
8890
"@npm//rxjs",
8991
],
9092
)

modules/builders/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"browser-sync": "^2.26.7",
2020
"http-proxy-middleware": "^0.20.0",
2121
"rxjs": "RXJS_VERSION",
22-
"tree-kill": "^1.2.1"
22+
"tree-kill": "^1.2.1",
23+
"guess-parser": "^0.4.12"
2324
},
2425
"ng-update": {
2526
"packageGroup": "NG_UPDATE_PACKAGE_GROUP"

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('Prerender Builder', () => {
3838
});
3939

4040
it('fails with error when no routes are provided', async () => {
41-
const run = await architect.scheduleTarget(target, { routes: [] });
41+
const run = await architect.scheduleTarget(target, { routes: [], guessRoutes: false });
4242
await expectAsync(run.result).toBeRejectedWith(
4343
jasmine.objectContaining({ message: jasmine.stringMatching(/No routes found/)})
4444
);
@@ -127,4 +127,36 @@ describe('Prerender Builder', () => {
127127
);
128128
await run.stop();
129129
});
130+
131+
it('should guess routes to prerender when guessRoutes is set to true.', async () => {
132+
const run = await architect.scheduleTarget(target, {
133+
routes: ['/foo'],
134+
guessRoutes: true,
135+
});
136+
137+
const output = await run.result;
138+
139+
const fooContent = virtualFs.fileBufferToString(
140+
host.scopedSync().read(join(outputPathBrowser, 'foo/index.html'))
141+
);
142+
const fooBarContent = virtualFs.fileBufferToString(
143+
host.scopedSync().read(join(outputPathBrowser, 'foo/bar/index.html'))
144+
);
145+
const appContent = virtualFs.fileBufferToString(
146+
host.scopedSync().read(join(outputPathBrowser, 'index.html'))
147+
);
148+
149+
expect(output.success).toBe(true);
150+
151+
expect(appContent).toContain('app app is running!');
152+
expect(appContent).toContain('This page was prerendered with Angular Universal');
153+
154+
expect(fooContent).toContain('foo works!');
155+
expect(fooContent).toContain('This page was prerendered with Angular Universal');
156+
157+
expect(fooBarContent).toContain('foo-bar works!');
158+
expect(fooBarContent).toContain('This page was prerendered with Angular Universal');
159+
160+
await run.stop();
161+
});
130162
});

modules/builders/src/prerender/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,15 @@
77
*/
88

99
import { BuilderContext, BuilderOutput, createBuilder, targetFromTargetString } from '@angular-devkit/architect';
10-
import { json } from '@angular-devkit/core';
1110
import { fork } from 'child_process';
12-
1311
import * as fs from 'fs';
1412
import * as path from 'path';
1513

16-
import { Schema } from './schema';
14+
import { PrerenderBuilderOptions, PrerenderBuilderOutput } from './models';
1715
import { getRoutes, shardArray } from './utils';
1816

19-
export type PrerenderBuilderOptions = Schema & json.JsonObject;
20-
21-
export type PrerenderBuilderOutput = BuilderOutput;
17+
export type PrerenderBuilderOptions = PrerenderBuilderOptions;
18+
export type PrerenderBuilderOutput = PrerenderBuilderOutput;
2219

2320
type BuildBuilderOutput = BuilderOutput & {
2421
baseOutputPath: string;
@@ -101,7 +98,7 @@ async function _parallelRenderRoutes(
10198
}
10299

103100
/**
104-
* Renders each route in options.routes and writes them to
101+
* Renders each route and writes them to
105102
* <route>/index.html for each output path in the browser result.
106103
*/
107104
async function _renderUniversal(
@@ -147,10 +144,11 @@ export async function execute(
147144
options: PrerenderBuilderOptions,
148145
context: BuilderContext
149146
): Promise<PrerenderBuilderOutput> {
150-
const routes = getRoutes(context.workspaceRoot, options.routesFile, options.routes);
147+
const routes = await getRoutes(options, context);
151148
if (!routes.length) {
152149
throw new Error('No routes found.');
153150
}
151+
154152
const result = await _scheduleBuilds(options, context);
155153
const { success, error, browserResult, serverResult } = result;
156154
if (!success || !browserResult || !serverResult) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 { BuilderOutput } from '@angular-devkit/architect';
10+
import { json } from '@angular-devkit/core';
11+
import { Schema } from './schema';
12+
13+
export type PrerenderBuilderOptions = Schema & json.JsonObject;
14+
15+
export type PrerenderBuilderOutput = BuilderOutput;

modules/builders/src/prerender/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
},
2828
"default": []
2929
},
30+
"guessRoutes": {
31+
"type": "boolean",
32+
"description": "Whether or not the builder should extract routes and guess which paths to render.",
33+
"default": true
34+
},
3035
"numProcesses": {
3136
"type": "number",
3237
"description": "The number of cpus to use. Defaults to all but one.",

modules/builders/src/prerender/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface Schema {
1111
* Target to build.
1212
*/
1313
browserTarget: string;
14+
/**
15+
* Whether or not the builder should extract routes and guess which paths to render.
16+
*/
17+
guessRoutes?: boolean;
1418
/**
1519
* The number of cpus to use. Defaults to all but one.
1620
*/

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

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,77 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import * as Architect from '@angular-devkit/architect';
10+
import { NullLogger } from '@angular-devkit/core/src/logger';
911
import * as fs from 'fs';
12+
import * as guessParser from 'guess-parser';
13+
import { RoutingModule } from 'guess-parser/dist/common/interfaces';
14+
import 'jasmine';
15+
16+
import { PrerenderBuilderOptions } from './models';
1017
import { getRoutes, shardArray } from './utils';
1118

19+
1220
describe('Prerender Builder Utils', () => {
1321
describe('#getRoutes', () => {
14-
const WORKSPACE_ROOT = '/path/to/angular/json';
1522
const ROUTES_FILE = './routes.txt';
1623
const ROUTES_FILE_CONTENT = ['/route1', '/route1', '/route2', '/route3'].join('\n');
1724
const ROUTES = ['/route3', '/route3', '/route4'];
25+
const GUESSED_ROUTES = [{ path: '/route4' }, { path: '/route5' }, { path: '/**' }, { path: '/user/:id' }];
26+
27+
const CONTEXT = {
28+
workspaceRoot: '/path/to/angular/json',
29+
getTargetOptions: () => ({ tsConfig: 'tsconfig.app.json' }),
30+
logger: new NullLogger(),
31+
} as unknown as Architect.BuilderContext;
32+
33+
let parseAngularRoutesSpy: jasmine.Spy;
34+
let loggerErrorSpy: jasmine.Spy;
1835

1936
beforeEach(() => {
2037
spyOn(fs, 'readFileSync').and.returnValue(ROUTES_FILE_CONTENT);
38+
spyOn(Architect, 'targetFromTargetString').and.returnValue({} as Architect.Target);
39+
parseAngularRoutesSpy = spyOn(guessParser, 'parseAngularRoutes')
40+
.and.returnValue(GUESSED_ROUTES as RoutingModule[]);
41+
loggerErrorSpy = spyOn(CONTEXT.logger, 'error');
42+
});
43+
44+
it('Should return the union of the routes from routes, routesFile, and the extracted routes without any parameterized routes', async () => {
45+
const options = {
46+
routes: ROUTES,
47+
routesFile: ROUTES_FILE,
48+
guessRoutes: true,
49+
} as unknown as PrerenderBuilderOptions;
50+
const routes = await getRoutes(options, CONTEXT);
51+
expect(routes).toContain('/route1');
52+
expect(routes).toContain('/route2');
53+
expect(routes).toContain('/route3');
54+
expect(routes).toContain('/route4');
55+
expect(routes).toContain('/route5');
2156
});
2257

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']);
58+
it('Should return only the given routes', async () => {
59+
const options = { routes: ROUTES } as PrerenderBuilderOptions;
60+
const routes = await getRoutes(options, CONTEXT);
61+
expect(routes).toContain('/route3');
62+
expect(routes).toContain('/route4');
2663
});
2764

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']);
65+
it('Should return the routes from the routesFile', async () => {
66+
const options = { routesFile: ROUTES_FILE } as PrerenderBuilderOptions;
67+
const routes = await getRoutes(options, CONTEXT);
68+
expect(routes).toContain('/route1');
69+
expect(routes).toContain('/route2');
70+
expect(routes).toContain('/route3');
3171
});
3272

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']);
73+
it('Should catch errors thrown by parseAngularRoutes', async () => {
74+
const options = { routes: ROUTES, guessRoutes: true } as PrerenderBuilderOptions;
75+
parseAngularRoutesSpy.and.throwError('Test Error');
76+
const routes = await getRoutes(options, CONTEXT);
77+
expect(routes).toContain('/route3');
78+
expect(routes).toContain('/route4');
79+
expect(loggerErrorSpy).toHaveBeenCalled();
3680
});
3781
});
3882

modules/builders/src/prerender/utils.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,50 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
910
import * as fs from 'fs';
11+
import { parseAngularRoutes } from 'guess-parser';
1012
import * as os from 'os';
1113
import * as path from 'path';
1214

15+
import { PrerenderBuilderOptions } from './models';
16+
1317
/**
14-
* Returns the concatenation of options.routes and the contents of options.routesFile.
18+
* Returns the union of routes, the contents of routesFile if given,
19+
* and the static routes extracted if guessRoutes is set to true.
1520
*/
16-
export function getRoutes(
17-
workspaceRoot: string,
18-
routesFile?: string,
19-
routes: string[] = [],
20-
): string[] {
21-
let routesFileResult: string[] = [];
22-
if (routesFile) {
23-
const routesFilePath = path.resolve(workspaceRoot, routesFile);
24-
25-
routesFileResult = fs.readFileSync(routesFilePath, 'utf8')
26-
.split(/\r?\n/)
27-
.filter(v => !!v);
21+
export async function getRoutes(
22+
options: PrerenderBuilderOptions,
23+
context: BuilderContext,
24+
): Promise<string[]> {
25+
let routes: string[] = options.routes ? options.routes : [];
26+
27+
if (options.routesFile) {
28+
const routesFilePath = path.resolve(context.workspaceRoot, options.routesFile);
29+
routes = routes.concat(
30+
fs.readFileSync(routesFilePath, 'utf8')
31+
.split(/\r?\n/)
32+
.filter(v => !!v)
33+
);
34+
}
35+
36+
if (options.guessRoutes) {
37+
const browserTarget = targetFromTargetString(options.browserTarget);
38+
const { tsConfig } = await context.getTargetOptions(browserTarget);
39+
if (tsConfig) {
40+
try {
41+
routes = routes.concat(
42+
parseAngularRoutes(path.join(context.workspaceRoot, tsConfig as string))
43+
.map(routeObj => routeObj.path)
44+
.filter(route => !route.includes('*') && !route.includes(':'))
45+
);
46+
} catch (e) {
47+
context.logger.error('Unable to extract routes from application.', e);
48+
}
49+
}
2850
}
2951

30-
return [...new Set([...routesFileResult, ...routes])];
52+
return [...new Set(routes)];
3153
}
3254

3355
/**

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"browser-sync": "^2.26.7",
7373
"domino": "^2.1.2",
7474
"express": "^4.15.2",
75+
"guess-parser": "^0.4.12",
7576
"http-proxy-middleware": "^0.20.0",
7677
"jasmine-core": "^3.0.0",
7778
"karma": "^4.1.0",

0 commit comments

Comments
 (0)