blob: a8863731efbf8c429bb26b183d0bdb2416e761fe [file] [log] [blame]
/**
* @license
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from "fs";
import * as parse5 from "parse5";
import * as dom5 from "dom5";
import * as path from "path";
import {Node} from 'dom5';
import {fail, unexpectedSwitchValue} from "../utils/common";
import {readMultilineParamFile} from "../utils/command-line";
import {
HtmlSrcFilePath,
JsSrcFilePath,
HtmlTargetFilePath,
JsTargetFilePath,
FileUtils,
FilePath
} from "../utils/file-utils";
import {
AbsoluteWebPath,
getRelativeImport,
NodeModuleImportPath,
SrcWebSite
} from "../utils/web-site-utils";
/**
* Update source code by moving all scripts out of HTML files.
* Input:
* input_output_html_param_file - list of file paths, each file path on a separate line
* The first 3 line contains the path to the first input HTML file and 2 output paths
* (for HTML and JS files)
* The second 3 line contains paths for the second HTML file, and so on.
*
* input_output_js_param_file - similar to input_output_html_param_file, but has only 2 lines
* per file (input JS file and output JS file)
*
* input_web_root_path - path (in filesystem) which should be treated as a web-site root path.
* For each HTML file it creates 2 output files - HTML and JS file.
* HTML file contains everything from HTML input file, except <script> tags.
* JS file contains (in the same order, as in original HTML):
* - inline javascript code from HTML file
* - each <script src = "path/to/file.js" > from HTML is converted to
* import 'path/to/output/file.js'
* statement. Such import statement run all side-effects in file.js (i.e. it run all #
* global code).
* - each <link rel="import" href = "path/to/file.html"> adds to .js file as
* import 'path/to/output/file.html.js
* i.e. instead of html, the .js script imports
* Because output JS keeps the order of imports, all global variables are
* initialized in a correct order (this is important for gerrit; it is impossible to use
* AMD modules here).
*/
enum RefType {
Html,
InlineJS,
JSFile
}
type LinkOrScript = HtmlFileRef | HtmlFileNodeModuleRef | JsFileReference | JsFileNodeModuleReference | InlineJS;
interface HtmlFileRef {
type: RefType.Html,
path: HtmlSrcFilePath;
isNodeModule: false;
}
interface HtmlFileNodeModuleRef {
type: RefType.Html,
path: NodeModuleImportPath;
isNodeModule: true;
}
function isHtmlFileRef(ref: LinkOrScript): ref is HtmlFileRef {
return ref.type === RefType.Html;
}
interface JsFileReference {
type: RefType.JSFile,
path: JsSrcFilePath;
isModule: boolean;
isNodeModule: false;
}
interface JsFileNodeModuleReference {
type: RefType.JSFile,
path: NodeModuleImportPath;
isModule: boolean;
isNodeModule: true;
}
interface InlineJS {
type: RefType.InlineJS,
isModule: boolean;
content: string;
}
interface HtmlOutputs {
html: HtmlTargetFilePath;
js: JsTargetFilePath;
}
interface JsOutputs {
js: JsTargetFilePath;
}
type HtmlSrcToOutputMap = Map<HtmlSrcFilePath, HtmlOutputs>;
type JsSrcToOutputMap = Map<JsSrcFilePath, JsOutputs>;
interface HtmlFileInfo {
src: HtmlSrcFilePath;
ast: parse5.AST.Document;
linksAndScripts: LinkOrScript[]
}
/** HtmlScriptAndLinksCollector walks through HTML file and collect
* all links and inline scripts.
*/
class HtmlScriptAndLinksCollector {
public constructor(private readonly webSite: SrcWebSite) {
}
public collect(src: HtmlSrcFilePath): HtmlFileInfo {
const ast = HtmlScriptAndLinksCollector.getAst(src);
const isHtmlImport = (node: Node) => node.tagName == "link" &&
dom5.getAttribute(node, "rel") == "import";
const isScriptTag = (node: Node) => node.tagName == "script";
const linksAndScripts: LinkOrScript[] = dom5
.nodeWalkAll(ast as Node, (node) => isHtmlImport(node) || isScriptTag(node))
.map((node) => {
if (isHtmlImport(node)) {
const href = dom5.getAttribute(node, "href");
if (!href) {
fail(`Tag <link rel="import...> in the file '${src}' doesn't have href attribute`);
}
if(this.webSite.isNodeModuleReference(href)) {
return {
type: RefType.Html,
path: this.webSite.getNodeModuleImport(href),
isNodeModule: true,
}
} else {
return {
type: RefType.Html,
path: this.webSite.resolveHtmlImport(src, href),
isNodeModule: false,
}
}
} else {
const isModule = dom5.getAttribute(node, "type") === "module";
if (dom5.hasAttribute(node, "src")) {
let srcPath = dom5.getAttribute(node, "src")!;
if(this.webSite.isNodeModuleReference(srcPath)) {
return {
type: RefType.JSFile,
isModule: isModule,
path: this.webSite.getNodeModuleImport(srcPath),
isNodeModule: true
};
} else {
return {
type: RefType.JSFile,
isModule: isModule,
path: this.webSite.resolveScriptSrc(src, srcPath),
isNodeModule: false
};
}
}
return {
type: RefType.InlineJS,
isModule: isModule,
content: dom5.getTextContent(node)
};
}
});
return {
src,
ast,
linksAndScripts
};
};
private static getAst(file: string): parse5.AST.Document {
const html = fs.readFileSync(file, "utf-8");
return parse5.parse(html, {locationInfo: true});
}
}
/** Generate js files */
class ScriptGenerator {
public constructor(private readonly pathMapper: SrcToTargetPathMapper) {
}
public generateFromJs(src: JsSrcFilePath) {
FileUtils.copyFile(src, this.pathMapper.getJsTargetForJs(src));
}
public generateFromHtml(html: HtmlFileInfo) {
const content: string[] = [];
const src = html.src;
const targetJsFile: JsTargetFilePath = this.pathMapper.getJsTargetForHtml(src);
html.linksAndScripts.forEach((linkOrScript) => {
switch (linkOrScript.type) {
case RefType.Html:
if(linkOrScript.isNodeModule) {
const importPath = this.pathMapper.getJsTargetForHtmlInNodeModule(linkOrScript.path)
content.push(`import '${importPath}';`);
} else {
const importPath = this.pathMapper.getJsTargetForHtml(linkOrScript.path);
const htmlRelativePath = getRelativeImport(targetJsFile, importPath);
content.push(`import '${htmlRelativePath}';`);
}
break;
case RefType.JSFile:
if(linkOrScript.isNodeModule) {
content.push(`import '${linkOrScript.path}'`);
} else {
const importFromJs = this.pathMapper.getJsTargetForJs(linkOrScript.path);
const scriptRelativePath = getRelativeImport(targetJsFile, importFromJs);
content.push(`import '${scriptRelativePath}';`);
}
break;
case RefType.InlineJS:
content.push(linkOrScript.content);
break;
default:
unexpectedSwitchValue(linkOrScript);
}
});
FileUtils.writeContent(targetJsFile, content.join("\n"));
}
}
/** Generate html files*/
class HtmlGenerator {
constructor(private readonly pathMapper: SrcToTargetPathMapper) {
}
public generateFromHtml(html: HtmlFileInfo) {
const ast = html.ast;
dom5.nodeWalkAll(ast as Node, (node) => node.tagName === "script")
.forEach((scriptNode) => dom5.remove(scriptNode));
const newContent = parse5.serialize(ast);
if(newContent.indexOf("<script") >= 0) {
fail(`Has content ${html.src}`);
}
FileUtils.writeContent(this.pathMapper.getHtmlTargetForHtml(html.src), newContent);
}
}
function readHtmlSrcToTargetMap(paramFile: string): HtmlSrcToOutputMap {
const htmlSrcToTarget: HtmlSrcToOutputMap = new Map();
const input = readMultilineParamFile(paramFile);
for(let i = 0; i < input.length; i += 3) {
const srcHtmlFile = path.resolve(input[i]) as HtmlSrcFilePath;
const targetHtmlFile = path.resolve(input[i + 1]) as HtmlTargetFilePath;
const targetJsFile = path.resolve(input[i + 2]) as JsTargetFilePath;
htmlSrcToTarget.set(srcHtmlFile, {
html: targetHtmlFile,
js: targetJsFile
});
}
return htmlSrcToTarget;
}
function readJsSrcToTargetMap(paramFile: string): JsSrcToOutputMap {
const jsSrcToTarget: JsSrcToOutputMap = new Map();
const input = readMultilineParamFile(paramFile);
for(let i = 0; i < input.length; i += 2) {
const srcJsFile = path.resolve(input[i]) as JsSrcFilePath;
const targetJsFile = path.resolve(input[i + 1]) as JsTargetFilePath;
jsSrcToTarget.set(srcJsFile as JsSrcFilePath, {
js: targetJsFile as JsTargetFilePath
});
}
return jsSrcToTarget;
}
class SrcToTargetPathMapper {
public constructor(
private readonly htmlSrcToTarget: HtmlSrcToOutputMap,
private readonly jsSrcToTarget: JsSrcToOutputMap) {
}
public getJsTargetForHtmlInNodeModule(file: NodeModuleImportPath): JsTargetFilePath {
return `${file}_gen.js` as JsTargetFilePath;
}
public getJsTargetForHtml(html: HtmlSrcFilePath): JsTargetFilePath {
return this.getHtmlOutputs(html).js;
}
public getHtmlTargetForHtml(html: HtmlSrcFilePath): HtmlTargetFilePath {
return this.getHtmlOutputs(html).html;
}
public getJsTargetForJs(js: JsSrcFilePath): JsTargetFilePath {
return this.getJsOutputs(js).js;
}
private getHtmlOutputs(html: HtmlSrcFilePath): HtmlOutputs {
if(!this.htmlSrcToTarget.has(html)) {
fail(`There are no outputs for the file '${html}'`);
}
return this.htmlSrcToTarget.get(html)!;
}
private getJsOutputs(js: JsSrcFilePath): JsOutputs {
if(!this.jsSrcToTarget.has(js)) {
fail(`There are no outputs for the file '${js}'`);
}
return this.jsSrcToTarget.get(js)!;
}
}
function main() {
if(process.argv.length < 5) {
const execFileName = path.basename(__filename);
fail(`Usage:\nnode ${execFileName} input_web_root_path input_output_html_param_file input_output_js_param_file\n`);
}
const srcWebSite = new SrcWebSite(path.resolve(process.argv[2]) as FilePath);
const htmlSrcToTarget: HtmlSrcToOutputMap = readHtmlSrcToTargetMap(process.argv[3]);
const jsSrcToTarget: JsSrcToOutputMap = readJsSrcToTargetMap(process.argv[4]);
const pathMapper = new SrcToTargetPathMapper(htmlSrcToTarget, jsSrcToTarget);
const scriptGenerator = new ScriptGenerator(pathMapper);
const htmlGenerator = new HtmlGenerator(pathMapper);
const scriptAndLinksCollector = new HtmlScriptAndLinksCollector(srcWebSite);
htmlSrcToTarget.forEach((targets, src) => {
const htmlFileInfo = scriptAndLinksCollector.collect(src);
scriptGenerator.generateFromHtml(htmlFileInfo);
htmlGenerator.generateFromHtml(htmlFileInfo);
});
jsSrcToTarget.forEach((targets, src) => {
scriptGenerator.generateFromJs(src);
});
}
main();