Skip to content

Commit cb165a7

Browse files
alan-agius4dgp1130
authored andcommitted
feat(@angular-devkit/build-angular): add pre-rendering (SSG) and App-shell support generation to application builder
This commit introduces experimental support to pre-render (SSG) and app-shell generation to the new application builder. - `appShell`: option which can have a value of `true` or `false` has been added to support generating an app-shell. - `prerender`: option which can have a value of `true`, `false` or an object with the below listed properties can be used to static render pages; - `routes`: Array of routes to render. - `discoverRoutes`: Whether the builder should statically discover routes. - `routesFile`: The path to a file containing routes separated by newlines.
1 parent 9afe193 commit cb165a7

File tree

20 files changed

+1017
-46
lines changed

20 files changed

+1017
-46
lines changed

‎WORKSPACE

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ nodejs_register_toolchains(
5858

5959
nodejs_register_toolchains(
6060
name = "node18",
61-
node_version = "18.10.0",
61+
node_version = "18.13.0",
6262
)
6363

6464
# Set the default nodejs toolchain to the latest supported major version

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"eslint-plugin-import": "2.27.5",
142142
"express": "4.18.2",
143143
"fast-glob": "3.2.12",
144+
"guess-parser": "0.4.22",
144145
"http-proxy": "^1.18.1",
145146
"https-proxy-agent": "5.0.1",
146147
"husky": "8.0.3",

‎packages/angular_devkit/build_angular/BUILD.bazel

+5
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ ts_library(
157157
"@npm//esbuild",
158158
"@npm//esbuild-wasm",
159159
"@npm//fast-glob",
160+
"@npm//guess-parser",
160161
"@npm//https-proxy-agent",
161162
"@npm//inquirer",
162163
"@npm//jsonc-parser",
@@ -298,6 +299,10 @@ ts_library(
298299
LARGE_SPECS = {
299300
"application": {
300301
"shards": 10,
302+
"tags": [
303+
# TODO: This is broken as app-shell tests do not work in Node versions prior to 18.13 due to Zone.js and SafePromise.
304+
"node16-broken",
305+
],
301306
"extra_deps": [
302307
"@npm//buffer",
303308
],

‎packages/angular_devkit/build_angular/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"css-loader": "6.8.1",
3535
"esbuild-wasm": "0.18.10",
3636
"fast-glob": "3.2.12",
37+
"guess-parser": "0.4.22",
3738
"https-proxy-agent": "5.0.1",
3839
"inquirer": "8.2.4",
3940
"jsonc-parser": "3.2.0",

‎packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
7878
];
7979

8080
// Render platform server module
81-
if (bootstrapAppFn) {
81+
if (isBootstrapFn(bootstrapAppFn)) {
8282
assert(renderApplication, `renderApplication was not exported from: ${serverBundlePath}.`);
8383

8484
return renderApplication(bootstrapAppFn, {
@@ -101,6 +101,11 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
101101
});
102102
}
103103

104+
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
105+
// We can differentiate between a module and a bootstrap function by reading `cmp`:
106+
return typeof value === 'function' && !('ɵmod' in value);
107+
}
108+
104109
/**
105110
* Initializes the worker when it is first created by loading the Zone.js package
106111
* into the worker instance.

‎packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

+64-8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10+
import assert from 'node:assert';
1011
import { SourceFileCache } from '../../tools/esbuild/angular/compiler-plugin';
1112
import {
1213
createBrowserCodeBundleOptions,
@@ -26,10 +27,13 @@ import {
2627
transformSupportedBrowsersToTargets,
2728
} from '../../tools/esbuild/utils';
2829
import { copyAssets } from '../../utils/copy-assets';
30+
import { maxWorkers } from '../../utils/environment-options';
2931
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
32+
import { prerenderPages } from '../../utils/ssg/render';
3033
import { getSupportedBrowsers } from '../../utils/supported-browsers';
3134
import { NormalizedApplicationBuildOptions } from './options';
3235

36+
// eslint-disable-next-line max-lines-per-function
3337
export async function executeBuild(
3438
options: NormalizedApplicationBuildOptions,
3539
context: BuilderContext,
@@ -46,6 +50,8 @@ export async function executeBuild(
4650
assets,
4751
indexHtmlOptions,
4852
cacheOptions,
53+
prerenderOptions,
54+
appShellOptions,
4955
} = options;
5056

5157
const browsers = getSupportedBrowsers(projectRoot, context.logger);
@@ -138,21 +144,58 @@ export async function executeBuild(
138144
await logMessages(context, { warnings: messages });
139145
}
140146

147+
/**
148+
* Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
149+
*
150+
* NOTE: we don't perform critical CSS inlining as this will be done during server rendering.
151+
*/
152+
let indexContentOutputNoCssInlining: string | undefined;
153+
141154
// Generate index HTML file
142155
if (indexHtmlOptions) {
143-
const { errors, warnings, content } = await generateIndexHtml(
156+
const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml(
144157
initialFiles,
145158
executionResult,
146-
options,
159+
{
160+
...options,
161+
optimizationOptions,
162+
},
147163
);
148-
for (const error of errors) {
149-
context.logger.error(error);
150-
}
151-
for (const warning of warnings) {
152-
context.logger.warn(warning);
153-
}
164+
165+
indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined;
166+
printWarningsAndErrorsToConsole(context, warnings, errors);
154167

155168
executionResult.addOutputFile(indexHtmlOptions.output, content);
169+
170+
if (serverEntryPoint) {
171+
// TODO only add the below file when SSR is enabled.
172+
executionResult.addOutputFile('index.server.html', contentWithoutCriticalCssInlined);
173+
}
174+
}
175+
176+
// Pre-render (SSG) and App-shell
177+
if (prerenderOptions || appShellOptions) {
178+
assert(
179+
indexContentOutputNoCssInlining,
180+
'The "index" option is required when using the "ssg" or "appShell" options.',
181+
);
182+
183+
const { output, warnings, errors } = await prerenderPages(
184+
workspaceRoot,
185+
options.tsconfig,
186+
appShellOptions,
187+
prerenderOptions,
188+
executionResult.outputFiles,
189+
indexContentOutputNoCssInlining,
190+
optimizationOptions.styles.inlineCritical,
191+
maxWorkers,
192+
);
193+
194+
printWarningsAndErrorsToConsole(context, warnings, errors);
195+
196+
for (const [path, content] of Object.entries(output)) {
197+
executionResult.addOutputFile(path, content);
198+
}
156199
}
157200

158201
// Copy assets
@@ -206,3 +249,16 @@ export async function executeBuild(
206249

207250
return executionResult;
208251
}
252+
253+
function printWarningsAndErrorsToConsole(
254+
context: BuilderContext,
255+
warnings: string[],
256+
errors: string[],
257+
): void {
258+
for (const error of errors) {
259+
context.logger.error(error);
260+
}
261+
for (const warning of warnings) {
262+
context.logger.warn(warning);
263+
}
264+
}

‎packages/angular_devkit/build_angular/src/builders/application/options.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type ApplicationBuilderInternalOptions = Omit<
6161
* @param options An object containing the options to use for the build.
6262
* @returns An object containing normalized options required to perform the build.
6363
*/
64+
// eslint-disable-next-line max-lines-per-function
6465
export async function normalizeOptions(
6566
context: BuilderContext,
6667
projectName: string,
@@ -149,7 +150,8 @@ export async function normalizeOptions(
149150
}
150151

151152
let indexHtmlOptions;
152-
if (options.index) {
153+
// index can never have a value of `true` but in the schema it's of type `boolean`.
154+
if (typeof options.index !== 'boolean') {
153155
indexHtmlOptions = {
154156
input: path.join(workspaceRoot, getIndexInputFile(options.index)),
155157
// The output file will be created within the configured output path
@@ -169,6 +171,28 @@ export async function normalizeOptions(
169171
throw new Error('`server` option cannot be an empty string.');
170172
}
171173

174+
let prerenderOptions;
175+
if (options.prerender) {
176+
const {
177+
discoverRoutes = true,
178+
routes = [],
179+
routesFile = undefined,
180+
} = options.prerender === true ? {} : options.prerender;
181+
182+
prerenderOptions = {
183+
discoverRoutes,
184+
routes,
185+
routesFile: routesFile && path.join(workspaceRoot, routesFile),
186+
};
187+
}
188+
189+
let appShellOptions;
190+
if (options.appShell) {
191+
appShellOptions = {
192+
route: 'shell',
193+
};
194+
}
195+
172196
// Initial options to keep
173197
const {
174198
allowedCommonJsDependencies,
@@ -215,6 +239,8 @@ export async function normalizeOptions(
215239
stylePreprocessorOptions,
216240
subresourceIntegrity,
217241
serverEntryPoint,
242+
prerenderOptions,
243+
appShellOptions,
218244
verbose,
219245
watch,
220246
workspaceRoot,
@@ -230,7 +256,8 @@ export async function normalizeOptions(
230256
fileReplacements,
231257
globalStyles,
232258
globalScripts,
233-
serviceWorker,
259+
serviceWorker:
260+
typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined,
234261
indexHtmlOptions,
235262
tailwindConfiguration,
236263
};

‎packages/angular_devkit/build_angular/src/builders/application/schema.json

+42
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
},
350350
{
351351
"const": false,
352+
"type": "boolean",
352353
"description": "Does not generate a service worker configuration."
353354
}
354355
]
@@ -380,6 +381,7 @@
380381
},
381382
{
382383
"const": false,
384+
"type": "boolean",
383385
"description": "Does not generate an `index.html` file."
384386
}
385387
]
@@ -414,6 +416,46 @@
414416
"type": "string"
415417
},
416418
"default": []
419+
},
420+
"prerender": {
421+
"description": "Prerender (SSG) pages of your application during build time.",
422+
"default": false,
423+
"oneOf": [
424+
{
425+
"type": "boolean",
426+
"description": "Enable prerending of pages of your application during build time."
427+
},
428+
{
429+
"type": "object",
430+
"properties": {
431+
"routesFile": {
432+
"type": "string",
433+
"description": "The path to a file containing routes separated by newlines."
434+
},
435+
"routes": {
436+
"type": "array",
437+
"description": "The routes to render.",
438+
"items": {
439+
"minItems": 1,
440+
"type": "string",
441+
"uniqueItems": true
442+
},
443+
"default": []
444+
},
445+
"discoverRoutes": {
446+
"type": "boolean",
447+
"description": "Whether the builder should statically discover routes.",
448+
"default": true
449+
}
450+
},
451+
"additionalProperties": false
452+
}
453+
]
454+
},
455+
"appShell": {
456+
"type": "boolean",
457+
"description": "Generates an application shell during build time.",
458+
"default": false
417459
}
418460
},
419461
"additionalProperties": false,

0 commit comments

Comments
 (0)