@angular-schule/prerender-format 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -14
- package/application/builder.js +2 -2
- package/application/builder.js.map +1 -1
- package/application/file-format.js +2 -1
- package/application/file-format.js.map +1 -1
- package/ng-add.js +2 -2
- package/ng-add.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/angular-schule/prerender-format/actions/workflows/main.yml)
|
|
5
5
|
[](http://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
**Nice looking URLs and flawless SEO for your prerendered Angular app: `
|
|
7
|
+
**Nice looking URLs and flawless SEO for your prerendered Angular app: `blog/my-article.html` instead of `blog/my-article/index.html`, no more trailing slash redirects! 🚀**
|
|
8
8
|
|
|
9
9
|
**Table of contents:**
|
|
10
10
|
|
|
@@ -29,25 +29,29 @@ Angular's prerendering (SSG) writes every route into its own folder: the route `
|
|
|
29
29
|
Static hosts see a folder and redirect `/blog/my-article` to `/blog/my-article/`.
|
|
30
30
|
So you have to choose:
|
|
31
31
|
|
|
32
|
-
- **Nice looking URLs, but redirects:** links
|
|
33
|
-
- **No redirects, but trailing slashes everywhere:** links
|
|
32
|
+
- **Nice looking URLs, but redirects:** links have to use `/blog/my-article`. Every direct visit (search engine, bookmark, shared link) starts with a 301/308 redirect to `/blog/my-article/`, and the Angular router then removes the trailing slash again.
|
|
33
|
+
- **No redirects, but trailing slashes everywhere:** links have to use `/blog/my-article/`. Pages answer with a status code 200, but every URL ends with a slash, and Angular needs an extra provider to keep it in the address bar: `{ provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy }` (see [`TrailingSlashPathLocationStrategy`](https://angular.dev/api/common/TrailingSlashPathLocationStrategy)).
|
|
34
34
|
|
|
35
35
|
### With this builder: nice URLs and good SEO, we deserve both!
|
|
36
36
|
|
|
37
|
-
The same route becomes `blog/my-article.html`, and hosts like Cloudflare Pages serve it under `/blog/my-article` directly, with status 200.
|
|
37
|
+
The same route becomes `blog/my-article.html`, and hosts like Cloudflare Pages serve it under `/blog/my-article` directly, with a status code 200.
|
|
38
38
|
|
|
39
39
|
- **Nice looking URLs:** `/blog/my-article`, without a trailing slash, in links, in the address bar and in the server response alike.
|
|
40
|
-
- **Flawless SEO:** every page answers directly with 200. Search engines see no redirect, and the URL they crawl is the same one your
|
|
40
|
+
- **Flawless SEO:** every page answers directly with a status code 200. Search engines see no redirect, and the URL they crawl is the same one your links point to.
|
|
41
41
|
- **Old links keep working:** addresses with a trailing slash redirect to the address without it (measured on Cloudflare Pages, see [Hosts](#hosts)).
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
The idea comes from [angular/angular-cli#29173](https://github.com/angular/angular-cli/issues/29173), which asks for an option to write `<route>.html` instead of `<route>/index.html`.
|
|
44
|
+
The pull request [angular/angular-cli#34180](https://github.com/angular/angular-cli/pull/34180) adds such an option to Angular itself, with the same name and values as this builder: `prerenderFormat`, `'directory'` or `'file'`.
|
|
44
45
|
The builder is a stopgap: developed and tested for Angular 22, until Angular has a built-in option and this package is no longer needed.
|
|
46
|
+
If you want the option in Angular, please give the issue and the pull request a 👍.
|
|
47
|
+
|
|
48
|
+
**Read more:** [Static Angular SSR: Nice URLs and good SEO - you can't have both (but here's a fix!)](https://angular.schule/blog/2026-09-static-angular-ssr-trailing-slash)
|
|
45
49
|
|
|
46
50
|
## ⚠️ Prerequisites <a name="prerequisites"></a>
|
|
47
51
|
|
|
48
52
|
- Angular 22 with the application builder (`@angular/build:application`)
|
|
49
53
|
- `"outputMode": "static"`
|
|
50
|
-
- A host that serves `
|
|
54
|
+
- A host that serves `blog/my-article.html` under `/blog/my-article` without a redirect (see [Hosts](#hosts))
|
|
51
55
|
|
|
52
56
|
## 🚀 Quick Start <a name="quickstart"></a>
|
|
53
57
|
|
|
@@ -97,7 +101,7 @@ A route whose last segment is `index` (`/index`, `/docs/index`) fails the build
|
|
|
97
101
|
|
|
98
102
|
Static site generators have offered this choice for a long time. We decided to borrow the terminology from Astro: `build.format` with `'directory'` and `'file'` became `prerenderFormat` with the same values.
|
|
99
103
|
|
|
100
|
-
| Framework | Option | `
|
|
104
|
+
| Framework | Option | `blog/my-article/index.html` | `blog/my-article.html` |
|
|
101
105
|
|---|---|---|---|
|
|
102
106
|
| **Astro** | `build.format` | `'directory'` (default) | `'file'` |
|
|
103
107
|
| Next.js (static export) | `trailingSlash` | `true` | `false` (default) |
|
|
@@ -114,10 +118,10 @@ Measured on **Cloudflare Pages** with `prerenderFormat: "file"`:
|
|
|
114
118
|
|
|
115
119
|
| Request | Response |
|
|
116
120
|
|---|---|
|
|
117
|
-
| `/
|
|
118
|
-
| `/
|
|
119
|
-
| `/
|
|
120
|
-
| `/
|
|
121
|
+
| `/blog/my-article` | 200, `blog/my-article.html` |
|
|
122
|
+
| `/blog/my-article/` | 308 → `/blog/my-article` |
|
|
123
|
+
| `/blog/my-article.html` | 308 → `/blog/my-article` |
|
|
124
|
+
| `/blog` with `blog.html` next to the folder `blog/` | 200, `blog.html` |
|
|
121
125
|
|
|
122
126
|
Old addresses with a trailing slash keep working, they redirect to the address without it.
|
|
123
127
|
|
|
@@ -127,14 +131,14 @@ Check your host before switching.
|
|
|
127
131
|
## 🔧 How it works <a name="how-it-works"></a>
|
|
128
132
|
|
|
129
133
|
The builder calls `buildApplication` from `@angular/build` and wraps its internal `prerenderPages()` function, which returns the prerendered pages as a record of output paths.
|
|
130
|
-
The wrapper renames `
|
|
134
|
+
The wrapper renames `blog/my-article/index.html` to `blog/my-article.html` before anything is written, so the service worker manifest and all later build steps see the final file names.
|
|
131
135
|
|
|
132
136
|
`prerenderPages()` is internal API, so this package supports Angular 22 only.
|
|
133
137
|
After a successful build, the builder checks that the prerendered pages actually went through the wrapper, and fails otherwise.
|
|
134
138
|
|
|
135
139
|
## 📁 Known limitations <a name="limitations"></a>
|
|
136
140
|
|
|
137
|
-
- **Static builds only, by design.** `prerenderFormat: "file"` solves a problem of static hosting and makes no sense in other setups. An `ssr` entry is fine as long as `"outputMode"` is `"static"`: Angular then uses it only during `ng build` to prerender the pages, and no server is deployed. If a server is deployed, the build fails: a server needs no `.html` files, it answers `/
|
|
141
|
+
- **Static builds only, by design.** `prerenderFormat: "file"` solves a problem of static hosting and makes no sense in other setups. An `ssr` entry is fine as long as `"outputMode"` is `"static"`: Angular then uses it only during `ng build` to prerender the pages, and no server is deployed. If a server is deployed, the build fails: a server needs no `.html` files, it answers `/blog/my-article` directly without redirecting to `/blog/my-article/`, and the Angular SSR server looks up prerendered pages as `index.html`.
|
|
138
142
|
|
|
139
143
|
✅ Works: static output, the `ssr` entry only renders at build time
|
|
140
144
|
|
package/application/builder.js
CHANGED
|
@@ -46,8 +46,8 @@ async function* executeBuild(options, context) {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
if ((0, ships_server_1.shipsServer)(applicationOptions)) {
|
|
49
|
-
context.logger.error(`❌ 'prerenderFormat: "file"'
|
|
50
|
-
`the Angular SSR server looks up prerendered pages as 'index.html'.`);
|
|
49
|
+
context.logger.error(`❌ 'prerenderFormat: "file"' is not supported when the build produces a server ` +
|
|
50
|
+
`('outputMode: "server"', or 'ssr' without 'outputMode'): the Angular SSR server looks up prerendered pages as 'index.html'.`);
|
|
51
51
|
yield { success: false };
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.js","sourceRoot":"","sources":["../../application/builder.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuBA,oCAgDC;AAvED,yDAAyF;AACzF,0CAA6E;AAC7E,2CAA6B;AAE7B,+CAAqE;AACrE,iDAA6C;AAkBtC,KAAK,SAAS,CAAC,CAAC,YAAY,CACjC,OAAe,EACf,OAAuB;IAEvB,MAAM,EAAE,eAAe,GAAG,WAAW,EAAE,GAAG,kBAAkB,EAAE,GAAG,OAAO,CAAC;IAEzE,IAAI,eAAe,KAAK,MAAM,EAAE,CAAC;QAC/B,KAAK,CAAC,CAAC,IAAA,wBAAgB,EAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;QAErD,OAAO;IACT,CAAC;IAED,IAAI,IAAA,0BAAW,EAAC,kBAAkB,CAAC,EAAE,CAAC;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB
|
|
1
|
+
{"version":3,"file":"builder.js","sourceRoot":"","sources":["../../application/builder.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuBA,oCAgDC;AAvED,yDAAyF;AACzF,0CAA6E;AAC7E,2CAA6B;AAE7B,+CAAqE;AACrE,iDAA6C;AAkBtC,KAAK,SAAS,CAAC,CAAC,YAAY,CACjC,OAAe,EACf,OAAuB;IAEvB,MAAM,EAAE,eAAe,GAAG,WAAW,EAAE,GAAG,kBAAkB,EAAE,GAAG,OAAO,CAAC;IAEzE,IAAI,eAAe,KAAK,MAAM,EAAE,CAAC;QAC/B,KAAK,CAAC,CAAC,IAAA,wBAAgB,EAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;QAErD,OAAO;IACT,CAAC;IAED,IAAI,IAAA,0BAAW,EAAC,kBAAkB,CAAC,EAAE,CAAC;QACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,gFAAgF;YAC9E,6HAA6H,CAChI,CAAC;QACF,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QAEzB,OAAO;IACT,CAAC;IAED,IAAI,CAAC;QACH,IAAA,+BAAiB,EAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,6BAA6B,CAAC,CAAC,CAAC,CAAC;IAClF,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1E,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QAEzB,OAAO;IACT,CAAC;IAID,IAAI,WAAW,GAAG,IAAA,+BAAiB,GAAE,CAAC;IACtC,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI,IAAA,wBAAgB,EAAC,kBAAkB,EAAE,OAAO,CAAC,EAAE,CAAC;QACzE,MAAM,KAAK,GAAG,IAAA,+BAAiB,GAAE,CAAC;QAClC,IAAI,MAAM,CAAC,OAAO,IAAI,KAAK,KAAK,WAAW,EAAE,CAAC;YAC5C,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iHAAiH;gBAC/G,kGAAkG;gBAClG,4DAA4D,CAC/D,CAAC;YACF,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACtC,CAAC;aAAM,CAAC;YACN,MAAM,MAAM,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC;IACtB,CAAC;AACH,CAAC;AAED,kBAAe,IAAA,yBAAa,EAAS,YAAY,CAAC,CAAC","sourcesContent":["import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';\nimport { ApplicationBuilderOptions, buildApplication } from '@angular/build';\nimport * as path from 'path';\n\nimport { getPrerenderCalls, installFileFormat } from './file-format';\nimport { shipsServer } from './ships-server';\n\nexport type PrerenderFormat = 'directory' | 'file';\n\nexport interface Schema extends ApplicationBuilderOptions {\n /**\n * File layout of prerendered routes.\n * - `directory` (default): `foo/index.html`\n * - `file`: `foo.html`, the start page stays `index.html`\n */\n prerenderFormat?: PrerenderFormat;\n}\n\n/**\n * Runs `@angular/build:application` and, with `prerenderFormat: \"file\"`,\n * writes prerendered routes as `foo.html` instead of `foo/index.html`.\n * Exported separately for testing purposes.\n */\nexport async function* executeBuild(\n options: Schema,\n context: BuilderContext\n): AsyncIterable<BuilderOutput> {\n const { prerenderFormat = 'directory', ...applicationOptions } = options;\n\n if (prerenderFormat !== 'file') {\n yield* buildApplication(applicationOptions, context);\n\n return;\n }\n\n if (shipsServer(applicationOptions)) {\n context.logger.error(\n `❌ 'prerenderFormat: \"file\"' is not supported when the build produces a server ` +\n `('outputMode: \"server\"', or 'ssr' without 'outputMode'): the Angular SSR server looks up prerendered pages as 'index.html'.`\n );\n yield { success: false };\n\n return;\n }\n\n try {\n installFileFormat(path.dirname(require.resolve('@angular/build/package.json')));\n } catch (e) {\n context.logger.error('❌ ' + (e instanceof Error ? e.message : String(e)));\n yield { success: false };\n\n return;\n }\n\n // Every successful build must have passed its prerendered pages through the wrapper.\n // Otherwise nothing was prerendered, or @angular/build no longer calls the wrapped function.\n let callsBefore = getPrerenderCalls();\n for await (const result of buildApplication(applicationOptions, context)) {\n const calls = getPrerenderCalls();\n if (result.success && calls === callsBefore) {\n context.logger.error(\n `❌ 'prerenderFormat: \"file\"' had no effect: no pages were prerendered through @angular-schule/prerender-format. ` +\n `Check that prerendering is enabled ('outputMode: \"static\"' with server routes, or 'prerender'). ` +\n `If it is, this version of @angular/build is not supported.`\n );\n yield { ...result, success: false };\n } else {\n yield result;\n }\n callsBefore = calls;\n }\n}\n\nexport default createBuilder<Schema>(executeBuild);\n"]}
|
|
@@ -66,7 +66,8 @@ function toFileOutput(output) {
|
|
|
66
66
|
for (const [outPath, file] of Object.entries(output)) {
|
|
67
67
|
const route = routeOf(outPath);
|
|
68
68
|
const filePath = toFilePath(outPath);
|
|
69
|
-
|
|
69
|
+
const lowerOutPath = outPath.toLowerCase();
|
|
70
|
+
if (lowerOutPath.endsWith('/index' + INDEX_SUFFIX) || lowerOutPath === 'index' + INDEX_SUFFIX) {
|
|
70
71
|
errors.push(`Route '${route}' cannot be prerendered with 'prerenderFormat: "file"': ` +
|
|
71
72
|
`its file '${filePath}' would be served as '${route.slice(0, -'index'.length)}', not as '${route}'. ` +
|
|
72
73
|
`Rename the route or use 'prerenderFormat: "directory"'.`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-format.js","sourceRoot":"","sources":["../../application/file-format.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,8CAEC;AAMD,gCAMC;AAgBD,
|
|
1
|
+
{"version":3,"file":"file-format.js","sourceRoot":"","sources":["../../application/file-format.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\n/** A prerendered page as returned by `prerenderPages()` of `@angular/build`. */\nexport interface PrerenderedFile {\n content: string;\n appShellRoute: boolean;\n}\n\nexport type PrerenderOutput = Record<string, PrerenderedFile>;\n\nexport interface PrerenderResult {\n output: PrerenderOutput;\n errors?: string[];\n [key: string]: unknown;\n}\n\nexport type PrerenderPages = (...args: unknown[]) => Promise<PrerenderResult>;\n\nexport interface FileOutputResult {\n output: PrerenderOutput;\n errors: string[];\n}\n\nconst INDEX_FILE = 'index.html';\nconst INDEX_SUFFIX = '/' + INDEX_FILE;\nconst PATCHED = Symbol.for('@angular-schule/prerender-format:patched');\nconst PRERENDER_MODULE = 'src/utils/server-rendering/prerender.js';\n\nlet prerenderCalls = 0;\n\n/** Number of `prerenderPages()` calls that went through the wrapper in this process. */\nexport function getPrerenderCalls(): number {\n return prerenderCalls;\n}\n\n/**\n * Maps a prerender output path from `foo/index.html` to `foo.html`.\n * The root `index.html` of a build (start page, or locale start page with its base href) stays as is.\n */\nexport function toFilePath(outPath: string): string {\n if (!outPath.endsWith(INDEX_SUFFIX)) {\n return outPath;\n }\n\n return outPath.slice(0, -INDEX_SUFFIX.length) + '.html';\n}\n\n/** Route of an output path, for error messages: `blog/index/index.html` → `/blog/index`. */\nfunction routeOf(outPath: string): string {\n if (outPath === INDEX_FILE) {\n return '/';\n }\n\n return '/' + (outPath.endsWith(INDEX_SUFFIX) ? outPath.slice(0, -INDEX_SUFFIX.length) : outPath);\n}\n\n/**\n * Renames all keys of a prerender `output` record.\n * Routes whose last segment is `index` are reported as errors: as `…/index.html` they would be\n * served under the parent path, and `/index` would overwrite the start page.\n */\nexport function toFileOutput(output: PrerenderOutput): FileOutputResult {\n const renamed: PrerenderOutput = {};\n const errors: string[] = [];\n const routeByFilePath = new Map<string, string>();\n\n for (const [outPath, file] of Object.entries(output)) {\n const route = routeOf(outPath);\n const filePath = toFilePath(outPath);\n\n // Case-insensitive: on macOS and Windows, 'Index.html' is the same file as 'index.html'.\n const lowerOutPath = outPath.toLowerCase();\n if (lowerOutPath.endsWith('/index' + INDEX_SUFFIX) || lowerOutPath === 'index' + INDEX_SUFFIX) {\n errors.push(\n `Route '${route}' cannot be prerendered with 'prerenderFormat: \"file\"': ` +\n `its file '${filePath}' would be served as '${route.slice(0, -'index'.length)}', not as '${route}'. ` +\n `Rename the route or use 'prerenderFormat: \"directory\"'.`\n );\n continue;\n }\n\n const existingRoute = routeByFilePath.get(filePath);\n if (existingRoute !== undefined) {\n errors.push(\n `Routes '${existingRoute}' and '${route}' both map to the file '${filePath}' with 'prerenderFormat: \"file\"'.`\n );\n continue;\n }\n\n routeByFilePath.set(filePath, route);\n renamed[filePath] = file;\n }\n\n return { output: renamed, errors };\n}\n\n/**\n * Wraps `prerenderPages` so that its `output` record uses the \"file\" format.\n * Problems are returned as build errors of `prerenderPages`, so Angular reports them like any other build error.\n */\nexport function wrapPrerenderPages(prerenderPages: PrerenderPages): PrerenderPages {\n const wrapped = async function (this: unknown, ...args: unknown[]) {\n const result = await prerenderPages.apply(this, args);\n if (\n !result ||\n typeof result.output !== 'object' ||\n result.output === null ||\n !Array.isArray(result.errors)\n ) {\n throw new Error(\n '@angular-schule/prerender-format: prerenderPages() returned an unknown result. ' +\n 'This version of @angular/build is not supported.'\n );\n }\n prerenderCalls++;\n\n const { output, errors } = toFileOutput(result.output);\n\n return { ...result, output, errors: [...result.errors, ...errors] };\n };\n Object.defineProperty(wrapped, PATCHED, { value: true });\n\n return wrapped;\n}\n\nfunction isPatched(fn: PrerenderPages): boolean {\n return (fn as unknown as Record<symbol, unknown>)[PATCHED] === true;\n}\n\n/**\n * Replaces `prerenderPages` of the given `@angular/build` installation.\n * `execute-post-bundle.js` reads the function from the module's exports object on every call,\n * so the replacement takes effect for regular and localized builds alike.\n */\nexport function installFileFormat(angularBuildRoot: string): void {\n const modulePath = path.join(angularBuildRoot, PRERENDER_MODULE);\n let prerenderModule: { prerenderPages?: PrerenderPages };\n try {\n prerenderModule = require(modulePath);\n } catch (error) {\n throw new Error(\n `@angular-schule/prerender-format: cannot load '${modulePath}'. This version of @angular/build is not supported.`,\n { cause: error }\n );\n }\n\n const descriptor = Object.getOwnPropertyDescriptor(prerenderModule, 'prerenderPages');\n const prerenderPages = prerenderModule.prerenderPages;\n if (typeof prerenderPages !== 'function' || !descriptor?.writable) {\n throw new Error(\n `@angular-schule/prerender-format: '${modulePath}' exports no replaceable prerenderPages(). ` +\n 'This version of @angular/build is not supported.'\n );\n }\n\n if (!isPatched(prerenderPages)) {\n prerenderModule.prerenderPages = wrapPrerenderPages(prerenderPages);\n }\n}\n"]}
|
package/ng-add.js
CHANGED
|
@@ -41,8 +41,8 @@ const ngAdd = (options) => async (tree, context) => {
|
|
|
41
41
|
.map(([name]) => name);
|
|
42
42
|
if (configurationsWithServer.length) {
|
|
43
43
|
throw new schematics_1.SchematicsException(`The build target of "${options.project}" ships an Angular SSR server (${configurationsWithServer.join(', ')}). ` +
|
|
44
|
-
`prerenderFormat "file"
|
|
45
|
-
`
|
|
44
|
+
`prerenderFormat "file" is not supported when the build produces a server, because the SSR server looks up prerendered pages as 'index.html'. ` +
|
|
45
|
+
`Use a static build ("outputMode": "static", or prerendering without "ssr") and run ng add again.`);
|
|
46
46
|
}
|
|
47
47
|
buildTarget.builder = exports.BUILDER_NAME;
|
|
48
48
|
buildTarget.options = { ...buildTarget.options, prerenderFormat: 'file' };
|
package/ng-add.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ng-add.js","sourceRoot":"","sources":["../ng-add.ts"],"names":[],"mappings":";;;AAAA,+CAAkD;AAClD,2DAAyF;AAEzF,6DAAyD;AACzD,mCAAqC;AAMxB,QAAA,YAAY,GAAG,8CAA8C,CAAC;AAC3E,MAAM,oBAAoB,GAAG,4BAA4B,CAAC;AAEnD,MAAM,KAAK,GAAG,CAAC,OAAqB,EAAE,EAAE,CAAC,KAAK,EAAE,IAAU,EAAE,OAAyB,EAAE,EAAE;IAC9F,MAAM,IAAI,GAAG,IAAA,kBAAU,EAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,iBAAU,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAEhE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACrB,IAAI,SAAS,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YAElC,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,gCAAmB,CAC3B,8GAA8G,CAC/G,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,gCAAmB,CAAC,gEAAgE,CAAC,CAAC;IAClG,CAAC;IAED,IAAI,OAAO,CAAC,UAAU,CAAC,WAAW,KAAK,aAAa,EAAE,CAAC;QACrD,MAAM,IAAI,gCAAmB,CAC3B,oGAAoG,CACrG,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACjD,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,IAAI,gCAAmB,CAC3B,qDAAqD,OAAO,CAAC,OAAO,oBAAoB,CACzF,CAAC;IACJ,CAAC;IAED,IAAI,WAAW,CAAC,OAAO,KAAK,oBAAoB,IAAI,WAAW,CAAC,OAAO,KAAK,oBAAY,EAAE,CAAC;QACzF,MAAM,IAAI,gCAAmB,CAC3B,wBAAwB,OAAO,CAAC,OAAO,WAAW,WAAW,CAAC,OAAO,KAAK;YACxE,8CAA8C,oBAAoB,SAAS,CAC9E,CAAC;IACJ,CAAC;IAED,MAAM,wBAAwB,GAAG;QAC/B,CAAC,SAAS,EAAE,WAAW,CAAC,OAAO,IAAI,EAAE,CAAU;QAC/C,GAAG,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,cAAc,IAAI,EAAE,CAAC,CAAC,GAAG,CACrD,CAAC,CAAC,IAAI,EAAE,aAAa,CAAC,EAAE,EAAE,CACxB,CAAC,kBAAkB,IAAI,GAAG,EAAE,EAAE,GAAG,WAAW,CAAC,OAAO,EAAE,GAAG,aAAa,EAAE,CAAU,CACrF;KACF;SACE,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,IAAA,0BAAW,EAAC,OAAO,CAAC,CAAC;SAC7C,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAEzB,IAAI,wBAAwB,CAAC,MAAM,EAAE,CAAC;QACpC,MAAM,IAAI,gCAAmB,CAC3B,wBAAwB,OAAO,CAAC,OAAO,kCAAkC,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;YAC/G
|
|
1
|
+
{"version":3,"file":"ng-add.js","sourceRoot":"","sources":["../ng-add.ts"],"names":[],"mappings":";;;AAAA,+CAAkD;AAClD,2DAAyF;AAEzF,6DAAyD;AACzD,mCAAqC;AAMxB,QAAA,YAAY,GAAG,8CAA8C,CAAC;AAC3E,MAAM,oBAAoB,GAAG,4BAA4B,CAAC;AAEnD,MAAM,KAAK,GAAG,CAAC,OAAqB,EAAE,EAAE,CAAC,KAAK,EAAE,IAAU,EAAE,OAAyB,EAAE,EAAE;IAC9F,MAAM,IAAI,GAAG,IAAA,kBAAU,EAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,iBAAU,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAEhE,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACrB,IAAI,SAAS,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YAElC,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7D,CAAC;aAAM,CAAC;YACN,MAAM,IAAI,gCAAmB,CAC3B,8GAA8G,CAC/G,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,gCAAmB,CAAC,gEAAgE,CAAC,CAAC;IAClG,CAAC;IAED,IAAI,OAAO,CAAC,UAAU,CAAC,WAAW,KAAK,aAAa,EAAE,CAAC;QACrD,MAAM,IAAI,gCAAmB,CAC3B,oGAAoG,CACrG,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACjD,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,IAAI,gCAAmB,CAC3B,qDAAqD,OAAO,CAAC,OAAO,oBAAoB,CACzF,CAAC;IACJ,CAAC;IAED,IAAI,WAAW,CAAC,OAAO,KAAK,oBAAoB,IAAI,WAAW,CAAC,OAAO,KAAK,oBAAY,EAAE,CAAC;QACzF,MAAM,IAAI,gCAAmB,CAC3B,wBAAwB,OAAO,CAAC,OAAO,WAAW,WAAW,CAAC,OAAO,KAAK;YACxE,8CAA8C,oBAAoB,SAAS,CAC9E,CAAC;IACJ,CAAC;IAED,MAAM,wBAAwB,GAAG;QAC/B,CAAC,SAAS,EAAE,WAAW,CAAC,OAAO,IAAI,EAAE,CAAU;QAC/C,GAAG,MAAM,CAAC,OAAO,CAAC,WAAW,CAAC,cAAc,IAAI,EAAE,CAAC,CAAC,GAAG,CACrD,CAAC,CAAC,IAAI,EAAE,aAAa,CAAC,EAAE,EAAE,CACxB,CAAC,kBAAkB,IAAI,GAAG,EAAE,EAAE,GAAG,WAAW,CAAC,OAAO,EAAE,GAAG,aAAa,EAAE,CAAU,CACrF;KACF;SACE,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,IAAA,0BAAW,EAAC,OAAO,CAAC,CAAC;SAC7C,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAEzB,IAAI,wBAAwB,CAAC,MAAM,EAAE,CAAC;QACpC,MAAM,IAAI,gCAAmB,CAC3B,wBAAwB,OAAO,CAAC,OAAO,kCAAkC,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;YAC/G,+IAA+I;YAC/I,kGAAkG,CACrG,CAAC;IACJ,CAAC;IAED,WAAW,CAAC,OAAO,GAAG,oBAAY,CAAC;IACnC,WAAW,CAAC,OAAO,GAAG,EAAE,GAAG,WAAW,CAAC,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,CAAC;IAE1E,MAAM,iBAAU,CAAC,cAAc,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAEjD,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACxB,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,+CAA+C,CAAC,CAAC;IACrE,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACxB,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACnC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,yEAAyE,CAAC,CAAC;IAC/F,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IAChD,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;IAE7C,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAxEW,QAAA,KAAK,SAwEhB","sourcesContent":["import { workspaces } from '@angular-devkit/core';\nimport { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';\n\nimport { shipsServer } from './application/ships-server';\nimport { createHost } from './utils';\n\ninterface NgAddOptions {\n project: string;\n}\n\nexport const BUILDER_NAME = '@angular-schule/prerender-format:application';\nconst ANGULAR_BUILDER_NAME = '@angular/build:application';\n\nexport const ngAdd = (options: NgAddOptions) => async (tree: Tree, context: SchematicContext) => {\n const host = createHost(tree);\n const { workspace } = await workspaces.readWorkspace('/', host);\n\n if (!options.project) {\n if (workspace.projects.size === 1) {\n // If there is only one project, return that one.\n options.project = Array.from(workspace.projects.keys())[0];\n } else {\n throw new SchematicsException(\n 'There is more than one project in your workspace. Please select it manually by using the --project argument.'\n );\n }\n }\n\n const project = workspace.projects.get(options.project);\n if (!project) {\n throw new SchematicsException('The specified Angular project is not defined in this workspace');\n }\n\n if (project.extensions.projectType !== 'application') {\n throw new SchematicsException(\n `@angular-schule/prerender-format requires an Angular project type of \"application\" in angular.json`\n );\n }\n\n const buildTarget = project.targets.get('build');\n if (!buildTarget) {\n throw new SchematicsException(\n `Cannot find build target for the Angular project \"${options.project}\" in angular.json.`\n );\n }\n\n if (buildTarget.builder !== ANGULAR_BUILDER_NAME && buildTarget.builder !== BUILDER_NAME) {\n throw new SchematicsException(\n `The build target of \"${options.project}\" uses \"${buildTarget.builder}\". ` +\n `@angular-schule/prerender-format replaces \"${ANGULAR_BUILDER_NAME}\" only.`\n );\n }\n\n const configurationsWithServer = [\n ['options', buildTarget.options ?? {}] as const,\n ...Object.entries(buildTarget.configurations ?? {}).map(\n ([name, configuration]) =>\n [`configuration \"${name}\"`, { ...buildTarget.options, ...configuration }] as const\n )\n ]\n .filter(([, options]) => shipsServer(options))\n .map(([name]) => name);\n\n if (configurationsWithServer.length) {\n throw new SchematicsException(\n `The build target of \"${options.project}\" ships an Angular SSR server (${configurationsWithServer.join(', ')}). ` +\n `prerenderFormat \"file\" is not supported when the build produces a server, because the SSR server looks up prerendered pages as 'index.html'. ` +\n `Use a static build (\"outputMode\": \"static\", or prerendering without \"ssr\") and run ng add again.`\n );\n }\n\n buildTarget.builder = BUILDER_NAME;\n buildTarget.options = { ...buildTarget.options, prerenderFormat: 'file' };\n\n await workspaces.writeWorkspace(workspace, host);\n\n context.logger.info('');\n context.logger.info('🚀 @angular-schule/prerender-format is ready!');\n context.logger.info('');\n context.logger.info('Next steps:');\n context.logger.info(' 1. Make sure your host serves foo.html under /foo without a redirect.');\n context.logger.info(' 2. Build via: ng build');\n context.logger.info(' 3. Have a nice day!');\n\n return tree;\n};\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angular-schule/prerender-format",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Angular application builder that writes prerendered routes as foo.html instead of foo/index.html (no trailing slash redirects)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|