Skip to content

Commit 6595490

Browse files
clydinalexeagle
authored andcommitted
feat(@angular/pwa): support customized workspace configurations with add
1 parent 0dc36f2 commit 6595490

File tree

6 files changed

+240
-176
lines changed

6 files changed

+240
-176
lines changed

packages/angular/pwa/BUILD

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ ts_library(
2424
"**/*_spec_large.ts",
2525
],
2626
),
27-
# Borrow the compile-time deps of the typescript compiler
28-
# Just to avoid an extra npm install action.
29-
node_modules = "@build_bazel_rules_typescript_tsc_wrapped_deps//:node_modules",
3027
deps = [
3128
"//packages/angular_devkit/core",
3229
"//packages/angular_devkit/schematics",
30+
"@rxjs",
31+
# @typings: node
3332
],
3433
)

packages/angular/pwa/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@angular-devkit/core": "0.0.0",
1313
"@angular-devkit/schematics": "0.0.0",
1414
"@schematics/angular": "0.0.0",
15-
"typescript": "~2.6.2"
15+
"parse5-html-rewriting-stream": "^5.1.0",
16+
"rxjs": "^6.0.0"
1617
}
1718
}

packages/angular/pwa/pwa/index.ts

Lines changed: 144 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import { Path, join, normalize } from '@angular-devkit/core';
8+
import {
9+
JsonParseMode,
10+
experimental,
11+
getSystemPath,
12+
join,
13+
normalize,
14+
parseJson,
15+
} from '@angular-devkit/core';
916
import {
1017
Rule,
1118
SchematicContext,
@@ -19,153 +26,190 @@ import {
1926
template,
2027
url,
2128
} from '@angular-devkit/schematics';
22-
import { getWorkspace, getWorkspacePath } from '../utility/config';
29+
import { Observable } from 'rxjs';
30+
import { Readable, Writable } from 'stream';
2331
import { Schema as PwaOptions } from './schema';
2432

33+
const RewritingStream = require('parse5-html-rewriting-stream');
2534

26-
function addServiceWorker(options: PwaOptions): Rule {
27-
return (host: Tree, context: SchematicContext) => {
28-
context.logger.debug('Adding service worker...');
29-
30-
const swOptions = {
31-
...options,
32-
};
33-
delete swOptions.title;
34-
35-
return externalSchematic('@schematics/angular', 'service-worker', swOptions);
36-
};
37-
}
3835

39-
function getIndent(text: string): string {
40-
let indent = '';
36+
function getWorkspace(
37+
host: Tree,
38+
): { path: string, workspace: experimental.workspace.WorkspaceSchema } {
39+
const possibleFiles = [ '/angular.json', '/.angular.json' ];
40+
const path = possibleFiles.filter(path => host.exists(path))[0];
4141

42-
for (const char of text) {
43-
if (char === ' ' || char === '\t') {
44-
indent += char;
45-
} else {
46-
break;
47-
}
42+
const configBuffer = host.read(path);
43+
if (configBuffer === null) {
44+
throw new SchematicsException(`Could not find (${path})`);
4845
}
49-
50-
return indent;
46+
const content = configBuffer.toString();
47+
48+
return {
49+
path,
50+
workspace: parseJson(
51+
content,
52+
JsonParseMode.Loose,
53+
) as {} as experimental.workspace.WorkspaceSchema,
54+
};
5155
}
5256

53-
function updateIndexFile(options: PwaOptions): Rule {
54-
return (host: Tree, context: SchematicContext) => {
55-
const workspace = getWorkspace(host);
56-
const project = workspace.projects[options.project as string];
57-
let path: string;
58-
const projectTargets = project.targets || project.architect;
59-
if (project && projectTargets && projectTargets.build && projectTargets.build.options.index) {
60-
path = projectTargets.build.options.index;
61-
} else {
62-
throw new SchematicsException('Could not find index file for the project');
63-
}
57+
function updateIndexFile(path: string): Rule {
58+
return (host: Tree) => {
6459
const buffer = host.read(path);
6560
if (buffer === null) {
6661
throw new SchematicsException(`Could not read index file: ${path}`);
6762
}
68-
const content = buffer.toString();
69-
const lines = content.split('\n');
70-
let closingHeadTagLineIndex = -1;
71-
let closingBodyTagLineIndex = -1;
72-
lines.forEach((line, index) => {
73-
if (closingHeadTagLineIndex === -1 && /<\/head>/.test(line)) {
74-
closingHeadTagLineIndex = index;
75-
} else if (closingBodyTagLineIndex === -1 && /<\/body>/.test(line)) {
76-
closingBodyTagLineIndex = index;
77-
}
78-
});
7963

80-
const headIndent = getIndent(lines[closingHeadTagLineIndex]) + ' ';
81-
const itemsToAddToHead = [
82-
'<link rel="manifest" href="manifest.json">',
83-
'<meta name="theme-color" content="#1976d2">',
84-
];
64+
const rewriter = new RewritingStream();
8565

86-
const bodyIndent = getIndent(lines[closingBodyTagLineIndex]) + ' ';
87-
const itemsToAddToBody = [
88-
'<noscript>Please enable JavaScript to continue using this application.</noscript>',
89-
];
66+
let needsNoScript = true;
67+
rewriter.on('startTag', (startTag: { tagName: string }) => {
68+
if (startTag.tagName === 'noscript') {
69+
needsNoScript = false;
70+
}
9071

91-
const updatedIndex = [
92-
...lines.slice(0, closingHeadTagLineIndex),
93-
...itemsToAddToHead.map(line => headIndent + line),
94-
...lines.slice(closingHeadTagLineIndex, closingBodyTagLineIndex),
95-
...itemsToAddToBody.map(line => bodyIndent + line),
96-
...lines.slice(closingBodyTagLineIndex),
97-
].join('\n');
72+
rewriter.emitStartTag(startTag);
73+
});
9874

99-
host.overwrite(path, updatedIndex);
75+
rewriter.on('endTag', (endTag: { tagName: string }) => {
76+
if (endTag.tagName === 'head') {
77+
rewriter.emitRaw(' <link rel="manifest" href="manifest.json">\n');
78+
rewriter.emitRaw(' <meta name="theme-color" content="#1976d2">\n');
79+
} else if (endTag.tagName === 'body' && needsNoScript) {
80+
rewriter.emitRaw(
81+
' <noscript>Please enable JavaScript to continue using this application.</noscript>\n',
82+
);
83+
}
10084

101-
return host;
85+
rewriter.emitEndTag(endTag);
86+
});
87+
88+
return new Observable<Tree>(obs => {
89+
const input = new Readable({
90+
encoding: 'utf8',
91+
read(): void {
92+
this.push(buffer);
93+
this.push(null);
94+
},
95+
});
96+
97+
const chunks: Array<Buffer> = [];
98+
const output = new Writable({
99+
write(chunk: string | Buffer, encoding: string, callback: Function): void {
100+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk);
101+
callback();
102+
},
103+
final(callback: (error?: Error) => void): void {
104+
const full = Buffer.concat(chunks);
105+
host.overwrite(path, full.toString());
106+
callback();
107+
obs.next(host);
108+
obs.complete();
109+
},
110+
});
111+
112+
input.pipe(rewriter).pipe(output);
113+
});
102114
};
103115
}
104116

105-
function addManifestToAssetsConfig(options: PwaOptions) {
117+
export default function (options: PwaOptions): Rule {
106118
return (host: Tree, context: SchematicContext) => {
119+
if (!options.title) {
120+
options.title = options.project;
121+
}
122+
const {path: workspacePath, workspace } = getWorkspace(host);
107123

108-
const workspacePath = getWorkspacePath(host);
109-
const workspace = getWorkspace(host);
110-
const project = workspace.projects[options.project as string];
124+
if (!options.project) {
125+
throw new SchematicsException('Option "project" is required.');
126+
}
111127

128+
const project = workspace.projects[options.project];
112129
if (!project) {
113-
throw new Error(`Project is not defined in this workspace.`);
130+
throw new SchematicsException(`Project is not defined in this workspace.`);
114131
}
115132

116-
const assetEntry = join(normalize(project.root), 'src', 'manifest.json');
133+
if (project.projectType !== 'application') {
134+
throw new SchematicsException(`PWA requires a project type of "application".`);
135+
}
117136

137+
// Find all the relevant targets for the project
118138
const projectTargets = project.targets || project.architect;
119-
if (!projectTargets) {
120-
throw new Error(`Targets are not defined for this project.`);
139+
if (!projectTargets || Object.keys(projectTargets).length === 0) {
140+
throw new SchematicsException(`Targets are not defined for this project.`);
121141
}
122142

123-
['build', 'test'].forEach((target) => {
124-
125-
const applyTo = projectTargets[target].options;
126-
const assets = applyTo.assets || (applyTo.assets = []);
127-
128-
assets.push(assetEntry);
143+
const buildTargets = [];
144+
const testTargets = [];
145+
for (const targetName in projectTargets) {
146+
const target = projectTargets[targetName];
147+
if (!target) {
148+
continue;
149+
}
129150

130-
});
151+
if (target.builder === '@angular-devkit/build-angular:browser') {
152+
buildTargets.push(target);
153+
} else if (target.builder === '@angular-devkit/build-angular:karma') {
154+
testTargets.push(target);
155+
}
156+
}
131157

158+
// Add manifest to asset configuration
159+
const assetEntry = join(normalize(project.root), 'src', 'manifest.json');
160+
for (const target of [...buildTargets, ...testTargets]) {
161+
if (target.options) {
162+
if (target.options.assets) {
163+
target.options.assets.push(assetEntry);
164+
} else {
165+
target.options.assets = [ assetEntry ];
166+
}
167+
} else {
168+
target.options = { assets: [ assetEntry ] };
169+
}
170+
}
132171
host.overwrite(workspacePath, JSON.stringify(workspace, null, 2));
133172

134-
return host;
135-
};
136-
}
173+
// Find all index.html files in build targets
174+
const indexFiles = new Set<string>();
175+
for (const target of buildTargets) {
176+
if (target.options && target.options.index) {
177+
indexFiles.add(target.options.index);
178+
}
137179

138-
export default function (options: PwaOptions): Rule {
139-
return (host: Tree, context: SchematicContext) => {
140-
const workspace = getWorkspace(host);
141-
if (!options.project) {
142-
throw new SchematicsException('Option "project" is required.');
143-
}
144-
const project = workspace.projects[options.project];
145-
if (project.projectType !== 'application') {
146-
throw new SchematicsException(`PWA requires a project type of "application".`);
180+
if (!target.configurations) {
181+
continue;
182+
}
183+
for (const configName in target.configurations) {
184+
const configuration = target.configurations[configName];
185+
if (configuration && configuration.index) {
186+
indexFiles.add(configuration.index);
187+
}
188+
}
147189
}
148190

149-
const sourcePath = join(project.root as Path, 'src');
191+
// Setup sources for the assets files to add to the project
192+
const sourcePath = join(normalize(project.root), 'src');
150193
const assetsPath = join(sourcePath, 'assets');
151-
152-
options.title = options.title || options.project;
153-
154194
const rootTemplateSource = apply(url('./files/root'), [
155195
template({ ...options }),
156-
move(sourcePath),
196+
move(getSystemPath(sourcePath)),
157197
]);
158198
const assetsTemplateSource = apply(url('./files/assets'), [
159199
template({ ...options }),
160-
move(assetsPath),
200+
move(getSystemPath(assetsPath)),
161201
]);
162202

203+
// Setup service worker schematic options
204+
const swOptions = { ...options };
205+
delete swOptions.title;
206+
207+
// Chain the rules and return
163208
return chain([
164-
addServiceWorker(options),
209+
externalSchematic('@schematics/angular', 'service-worker', swOptions),
165210
mergeWith(rootTemplateSource),
166211
mergeWith(assetsTemplateSource),
167-
updateIndexFile(options),
168-
addManifestToAssetsConfig(options),
212+
...[...indexFiles].map(path => updateIndexFile(path)),
169213
])(host, context);
170214
};
171215
}

0 commit comments

Comments
 (0)