WebManifestParser.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Alexander Akait @alexander-akait
  4. */
  5. "use strict";
  6. const { parse } = require("acorn");
  7. const Parser = require("../Parser");
  8. const HtmlSourceDependency = require("../dependencies/HtmlSourceDependency");
  9. /** @import { Expression, ObjectExpression, Program, Property } from "estree" */
  10. /** @import { BuildInfo } from "../Module" */
  11. /** @import { ParserState, PreparsedAst } from "../Parser" */
  12. // acorn decorates every node with numeric source offsets (absent from the estree types).
  13. /** @typedef {{ start: number, end: number }} Positioned */
  14. // A URL carrying its own scheme (`https:`, `data:`, …) is external; it is only
  15. // bundled when `experiments.buildHttp` is enabled (like an absolute URL in HTML).
  16. const ABSOLUTE_URL_SCHEME_REGEXP = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
  17. // Manifest members whose array items carry an asset `src` (`icons[].src`,
  18. // `screenshots[].src`, and `shortcuts[].icons[].src` — the last nests under `icons`).
  19. const ICON_CONTAINER_KEYS = new Set(["icons", "screenshots"]);
  20. const BOM_BYTE_LENGTH = 3;
  21. /**
  22. * Parses a Web App Manifest (`.webmanifest`) so its icon/screenshot `src`
  23. * URLs are bundled as assets. Reuses acorn (JSON is a valid JS expression
  24. * literal) to recover the exact source offsets, then emits an
  25. * `HtmlSourceDependency` per URL so the generator can rewrite it in place.
  26. */
  27. class WebManifestParser extends Parser {
  28. /**
  29. * Parses the provided source and updates the parser state.
  30. * @param {string | Buffer | PreparsedAst} source the source to parse
  31. * @param {ParserState} state the parser state
  32. * @returns {ParserState} the parser state
  33. */
  34. parse(source, state) {
  35. // The generator replaces over the raw buffer, so the emitted ranges are
  36. // byte offsets rather than the UTF-16 ones acorn reports.
  37. const binary = Buffer.isBuffer(source);
  38. if (binary) {
  39. source = /** @type {Buffer} */ (source).toString("utf8");
  40. } else if (typeof source === "object") {
  41. throw new Error("webpackAst is unexpected for the WebManifestParser");
  42. }
  43. let leadingBytes = 0;
  44. if (source[0] === "") {
  45. source = source.slice(1);
  46. leadingBytes = binary ? BOM_BYTE_LENGTH : 1;
  47. }
  48. const text = /** @type {string} */ (source);
  49. const asciiOnly = !binary || Buffer.byteLength(text) === text.length;
  50. /**
  51. * @param {number} offset offset in UTF-16 code units into `text`
  52. * @returns {number} matching offset into the module source
  53. */
  54. const toSourceOffset = (offset) =>
  55. (asciiOnly ? offset : Buffer.byteLength(text.slice(0, offset))) +
  56. leadingBytes;
  57. const module = state.module;
  58. // Without `buildHttp` there is no handler to fetch remote icons, so an
  59. // absolute URL is left as-is rather than rewritten to an ignored asset.
  60. const buildHttp = Boolean(state.options.experiments.buildHttp);
  61. /** @type {Program | undefined} */
  62. let ast;
  63. try {
  64. // Wrap in parens so the top-level object is an expression, not a block.
  65. ast = /** @type {Program} */ (
  66. /** @type {unknown} */ (parse(`(${source})`, { ecmaVersion: "latest" }))
  67. );
  68. } catch (_err) {
  69. // Not valid JSON — leave the file untouched (still emitted as-is).
  70. ast = undefined;
  71. }
  72. const statement = ast && ast.body[0];
  73. if (
  74. statement &&
  75. statement.type === "ExpressionStatement" &&
  76. statement.expression.type === "ObjectExpression"
  77. ) {
  78. /**
  79. * @param {ObjectExpression} obj object expression node
  80. * @param {string | undefined} parentKey key of the array/object this node sits in
  81. */
  82. const walk = (obj, parentKey) => {
  83. for (const prop of obj.properties) {
  84. if (prop.type !== "Property") continue;
  85. const key =
  86. prop.key.type === "Literal" && typeof prop.key.value === "string"
  87. ? prop.key.value
  88. : prop.key.type === "Identifier"
  89. ? prop.key.name
  90. : undefined;
  91. const value = /** @type {Expression} */ (prop.value);
  92. if (value.type === "ObjectExpression") {
  93. walk(value, key);
  94. } else if (value.type === "ArrayExpression") {
  95. for (const element of value.elements) {
  96. if (element && element.type === "ObjectExpression") {
  97. walk(element, key);
  98. }
  99. }
  100. } else if (
  101. key === "src" &&
  102. value.type === "Literal" &&
  103. typeof value.value === "string" &&
  104. parentKey !== undefined &&
  105. ICON_CONTAINER_KEYS.has(parentKey)
  106. ) {
  107. const url = value.value;
  108. // Fragment-only refs aren't assets; absolute URLs need `buildHttp`.
  109. if (!url || url.startsWith("#")) continue;
  110. if (!buildHttp && ABSOLUTE_URL_SCHEME_REGEXP.test(url)) continue;
  111. // acorn offsets are into `(${source})`; the leading `(` and the
  112. // opening quote cancel, so the inner span is [start, end - 2].
  113. const { start, end } = /** @type {Positioned} */ (
  114. /** @type {unknown} */ (value)
  115. );
  116. const dep = new HtmlSourceDependency(url, [
  117. toSourceOffset(start),
  118. toSourceOffset(end - 2)
  119. ]);
  120. module.addDependency(dep);
  121. module.addCodeGenerationDependency(dep);
  122. }
  123. }
  124. };
  125. walk(statement.expression, undefined);
  126. }
  127. /** @type {BuildInfo} */
  128. (state.module.buildInfo).strict = true;
  129. return state;
  130. }
  131. }
  132. module.exports = WebManifestParser;