| /** |
| * @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(); |