5
5
* Use of this source code is governed by an MIT-style license that can be
6
6
* found in the LICENSE file at https://angular.io/license
7
7
*/
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' ;
9
16
import {
10
17
Rule ,
11
18
SchematicContext ,
@@ -19,153 +26,190 @@ import {
19
26
template ,
20
27
url ,
21
28
} from '@angular-devkit/schematics' ;
22
- import { getWorkspace , getWorkspacePath } from '../utility/config' ;
29
+ import { Observable } from 'rxjs' ;
30
+ import { Readable , Writable } from 'stream' ;
23
31
import { Schema as PwaOptions } from './schema' ;
24
32
33
+ const RewritingStream = require ( 'parse5-html-rewriting-stream' ) ;
25
34
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
- }
38
35
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 ] ;
41
41
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 } )` ) ;
48
45
}
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
+ } ;
51
55
}
52
56
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 ) => {
64
59
const buffer = host . read ( path ) ;
65
60
if ( buffer === null ) {
66
61
throw new SchematicsException ( `Could not read index file: ${ path } ` ) ;
67
62
}
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 && / < \/ h e a d > / . test ( line ) ) {
74
- closingHeadTagLineIndex = index ;
75
- } else if ( closingBodyTagLineIndex === - 1 && / < \/ b o d y > / . test ( line ) ) {
76
- closingBodyTagLineIndex = index ;
77
- }
78
- } ) ;
79
63
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 ( ) ;
85
65
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
+ }
90
71
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
+ } ) ;
98
74
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
+ }
100
84
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
+ } ) ;
102
114
} ;
103
115
}
104
116
105
- function addManifestToAssetsConfig ( options : PwaOptions ) {
117
+ export default function ( options : PwaOptions ) : Rule {
106
118
return ( host : Tree , context : SchematicContext ) => {
119
+ if ( ! options . title ) {
120
+ options . title = options . project ;
121
+ }
122
+ const { path : workspacePath , workspace } = getWorkspace ( host ) ;
107
123
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
+ }
111
127
128
+ const project = workspace . projects [ options . project ] ;
112
129
if ( ! project ) {
113
- throw new Error ( `Project is not defined in this workspace.` ) ;
130
+ throw new SchematicsException ( `Project is not defined in this workspace.` ) ;
114
131
}
115
132
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
+ }
117
136
137
+ // Find all the relevant targets for the project
118
138
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.` ) ;
121
141
}
122
142
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
+ }
129
150
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
+ }
131
157
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
+ }
132
171
host . overwrite ( workspacePath , JSON . stringify ( workspace , null , 2 ) ) ;
133
172
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
+ }
137
179
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
+ }
147
189
}
148
190
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' ) ;
150
193
const assetsPath = join ( sourcePath , 'assets' ) ;
151
-
152
- options . title = options . title || options . project ;
153
-
154
194
const rootTemplateSource = apply ( url ( './files/root' ) , [
155
195
template ( { ...options } ) ,
156
- move ( sourcePath ) ,
196
+ move ( getSystemPath ( sourcePath ) ) ,
157
197
] ) ;
158
198
const assetsTemplateSource = apply ( url ( './files/assets' ) , [
159
199
template ( { ...options } ) ,
160
- move ( assetsPath ) ,
200
+ move ( getSystemPath ( assetsPath ) ) ,
161
201
] ) ;
162
202
203
+ // Setup service worker schematic options
204
+ const swOptions = { ...options } ;
205
+ delete swOptions . title ;
206
+
207
+ // Chain the rules and return
163
208
return chain ( [
164
- addServiceWorker ( options ) ,
209
+ externalSchematic ( '@schematics/angular' , 'service-worker' , swOptions ) ,
165
210
mergeWith ( rootTemplateSource ) ,
166
211
mergeWith ( assetsTemplateSource ) ,
167
- updateIndexFile ( options ) ,
168
- addManifestToAssetsConfig ( options ) ,
212
+ ...[ ...indexFiles ] . map ( path => updateIndexFile ( path ) ) ,
169
213
] ) ( host , context ) ;
170
214
} ;
171
215
}
0 commit comments