Skip to content

Commit

Permalink
feat(@angular/pwa): support customized workspace configurations with add
Browse files Browse the repository at this point in the history
  • Loading branch information
clydin authored and alexeagle committed Sep 6, 2018
1 parent 0dc36f2 commit 6595490
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 176 deletions.
5 changes: 2 additions & 3 deletions packages/angular/pwa/BUILD
Expand Up @@ -24,11 +24,10 @@ ts_library(
"**/*_spec_large.ts",
],
),
# Borrow the compile-time deps of the typescript compiler
# Just to avoid an extra npm install action.
node_modules = "@build_bazel_rules_typescript_tsc_wrapped_deps//:node_modules",
deps = [
"//packages/angular_devkit/core",
"//packages/angular_devkit/schematics",
"@rxjs",
# @typings: node
],
)
3 changes: 2 additions & 1 deletion packages/angular/pwa/package.json
Expand Up @@ -12,6 +12,7 @@
"@angular-devkit/core": "0.0.0",
"@angular-devkit/schematics": "0.0.0",
"@schematics/angular": "0.0.0",
"typescript": "~2.6.2"
"parse5-html-rewriting-stream": "^5.1.0",
"rxjs": "^6.0.0"
}
}
244 changes: 144 additions & 100 deletions packages/angular/pwa/pwa/index.ts
Expand Up @@ -5,7 +5,14 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { Path, join, normalize } from '@angular-devkit/core';
import {
JsonParseMode,
experimental,
getSystemPath,
join,
normalize,
parseJson,
} from '@angular-devkit/core';
import {
Rule,
SchematicContext,
Expand All @@ -19,153 +26,190 @@ import {
template,
url,
} from '@angular-devkit/schematics';
import { getWorkspace, getWorkspacePath } from '../utility/config';
import { Observable } from 'rxjs';
import { Readable, Writable } from 'stream';
import { Schema as PwaOptions } from './schema';

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

function addServiceWorker(options: PwaOptions): Rule {
return (host: Tree, context: SchematicContext) => {
context.logger.debug('Adding service worker...');

const swOptions = {
...options,
};
delete swOptions.title;

return externalSchematic('@schematics/angular', 'service-worker', swOptions);
};
}

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

for (const char of text) {
if (char === ' ' || char === '\t') {
indent += char;
} else {
break;
}
const configBuffer = host.read(path);
if (configBuffer === null) {
throw new SchematicsException(`Could not find (${path})`);
}

return indent;
const content = configBuffer.toString();

return {
path,
workspace: parseJson(
content,
JsonParseMode.Loose,
) as {} as experimental.workspace.WorkspaceSchema,
};
}

function updateIndexFile(options: PwaOptions): Rule {
return (host: Tree, context: SchematicContext) => {
const workspace = getWorkspace(host);
const project = workspace.projects[options.project as string];
let path: string;
const projectTargets = project.targets || project.architect;
if (project && projectTargets && projectTargets.build && projectTargets.build.options.index) {
path = projectTargets.build.options.index;
} else {
throw new SchematicsException('Could not find index file for the project');
}
function updateIndexFile(path: string): Rule {
return (host: Tree) => {
const buffer = host.read(path);
if (buffer === null) {
throw new SchematicsException(`Could not read index file: ${path}`);
}
const content = buffer.toString();
const lines = content.split('\n');
let closingHeadTagLineIndex = -1;
let closingBodyTagLineIndex = -1;
lines.forEach((line, index) => {
if (closingHeadTagLineIndex === -1 && /<\/head>/.test(line)) {
closingHeadTagLineIndex = index;
} else if (closingBodyTagLineIndex === -1 && /<\/body>/.test(line)) {
closingBodyTagLineIndex = index;
}
});

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

const bodyIndent = getIndent(lines[closingBodyTagLineIndex]) + ' ';
const itemsToAddToBody = [
'<noscript>Please enable JavaScript to continue using this application.</noscript>',
];
let needsNoScript = true;
rewriter.on('startTag', (startTag: { tagName: string }) => {
if (startTag.tagName === 'noscript') {
needsNoScript = false;
}

const updatedIndex = [
...lines.slice(0, closingHeadTagLineIndex),
...itemsToAddToHead.map(line => headIndent + line),
...lines.slice(closingHeadTagLineIndex, closingBodyTagLineIndex),
...itemsToAddToBody.map(line => bodyIndent + line),
...lines.slice(closingBodyTagLineIndex),
].join('\n');
rewriter.emitStartTag(startTag);
});

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

return host;
rewriter.emitEndTag(endTag);
});

return new Observable<Tree>(obs => {
const input = new Readable({
encoding: 'utf8',
read(): void {
this.push(buffer);
this.push(null);
},
});

const chunks: Array<Buffer> = [];
const output = new Writable({
write(chunk: string | Buffer, encoding: string, callback: Function): void {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk);
callback();
},
final(callback: (error?: Error) => void): void {
const full = Buffer.concat(chunks);
host.overwrite(path, full.toString());
callback();
obs.next(host);
obs.complete();
},
});

input.pipe(rewriter).pipe(output);
});
};
}

function addManifestToAssetsConfig(options: PwaOptions) {
export default function (options: PwaOptions): Rule {
return (host: Tree, context: SchematicContext) => {
if (!options.title) {
options.title = options.project;
}
const {path: workspacePath, workspace } = getWorkspace(host);

const workspacePath = getWorkspacePath(host);
const workspace = getWorkspace(host);
const project = workspace.projects[options.project as string];
if (!options.project) {
throw new SchematicsException('Option "project" is required.');
}

const project = workspace.projects[options.project];
if (!project) {
throw new Error(`Project is not defined in this workspace.`);
throw new SchematicsException(`Project is not defined in this workspace.`);
}

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

// Find all the relevant targets for the project
const projectTargets = project.targets || project.architect;
if (!projectTargets) {
throw new Error(`Targets are not defined for this project.`);
if (!projectTargets || Object.keys(projectTargets).length === 0) {
throw new SchematicsException(`Targets are not defined for this project.`);
}

['build', 'test'].forEach((target) => {

const applyTo = projectTargets[target].options;
const assets = applyTo.assets || (applyTo.assets = []);

assets.push(assetEntry);
const buildTargets = [];
const testTargets = [];
for (const targetName in projectTargets) {
const target = projectTargets[targetName];
if (!target) {
continue;
}

});
if (target.builder === '@angular-devkit/build-angular:browser') {
buildTargets.push(target);
} else if (target.builder === '@angular-devkit/build-angular:karma') {
testTargets.push(target);
}
}

// Add manifest to asset configuration
const assetEntry = join(normalize(project.root), 'src', 'manifest.json');
for (const target of [...buildTargets, ...testTargets]) {
if (target.options) {
if (target.options.assets) {
target.options.assets.push(assetEntry);
} else {
target.options.assets = [ assetEntry ];
}
} else {
target.options = { assets: [ assetEntry ] };
}
}
host.overwrite(workspacePath, JSON.stringify(workspace, null, 2));

return host;
};
}
// Find all index.html files in build targets
const indexFiles = new Set<string>();
for (const target of buildTargets) {
if (target.options && target.options.index) {
indexFiles.add(target.options.index);
}

export default function (options: PwaOptions): Rule {
return (host: Tree, context: SchematicContext) => {
const workspace = getWorkspace(host);
if (!options.project) {
throw new SchematicsException('Option "project" is required.');
}
const project = workspace.projects[options.project];
if (project.projectType !== 'application') {
throw new SchematicsException(`PWA requires a project type of "application".`);
if (!target.configurations) {
continue;
}
for (const configName in target.configurations) {
const configuration = target.configurations[configName];
if (configuration && configuration.index) {
indexFiles.add(configuration.index);
}
}
}

const sourcePath = join(project.root as Path, 'src');
// Setup sources for the assets files to add to the project
const sourcePath = join(normalize(project.root), 'src');
const assetsPath = join(sourcePath, 'assets');

options.title = options.title || options.project;

const rootTemplateSource = apply(url('./files/root'), [
template({ ...options }),
move(sourcePath),
move(getSystemPath(sourcePath)),
]);
const assetsTemplateSource = apply(url('./files/assets'), [
template({ ...options }),
move(assetsPath),
move(getSystemPath(assetsPath)),
]);

// Setup service worker schematic options
const swOptions = { ...options };
delete swOptions.title;

// Chain the rules and return
return chain([
addServiceWorker(options),
externalSchematic('@schematics/angular', 'service-worker', swOptions),
mergeWith(rootTemplateSource),
mergeWith(assetsTemplateSource),
updateIndexFile(options),
addManifestToAssetsConfig(options),
...[...indexFiles].map(path => updateIndexFile(path)),
])(host, context);
};
}

0 comments on commit 6595490

Please sign in to comment.