dataURL.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Natsu @xiaoxiaojx
  4. */
  5. "use strict";
  6. const path = require("path");
  7. const memoize = require("./memoize");
  8. // data URL scheme: "data:text/javascript;charset=utf-8;base64,some-string"
  9. // http://www.ietf.org/rfc/rfc2397.txt
  10. const URIRegEx = /^data:([^;,]+)?((?:;[^;,]+)*?)(?:;(base64)?)?,(.*)$/i;
  11. /**
  12. * Decodes the provided uri.
  13. * @param {string} uri data URI
  14. * @returns {Buffer | null} decoded data
  15. */
  16. const decodeDataURI = (uri) => {
  17. const match = URIRegEx.exec(uri);
  18. if (!match) return null;
  19. const isBase64 = match[3];
  20. const body = match[4];
  21. if (isBase64) {
  22. return Buffer.from(body, "base64");
  23. }
  24. // CSS allows to use `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" style="stroke: rgb(223,224,225); stroke-width: 2px; fill: none; stroke-dasharray: 6px 3px" /></svg>`
  25. // so we return original body if we can't `decodeURIComponent`
  26. try {
  27. return Buffer.from(decodeURIComponent(body), "utf8");
  28. } catch (_) {
  29. return Buffer.from(body, "utf8");
  30. }
  31. };
  32. /**
  33. * A `data:` URI split into the parts a caller has to put back together.
  34. * `payload` is the text after the comma, still in the form it was written.
  35. * @typedef {{ mediaType: string, base64: boolean, payload: string }} ParsedDataURI
  36. */
  37. // `URIRegEx`'s `.` stops at a newline, which a raw `<svg>` payload carries, and
  38. // its `base64` group is optional in a way that reads `;charset=utf8` as one.
  39. // This one is the strict split a rewriter needs; that one stays as the matcher
  40. // module requests are recognized by.
  41. const STRICT_URI_REGEXP = /^data:([^,;]*)((?:;[^,;]*)*),([\s\S]*)$/i;
  42. const JSON_TYPE = "json";
  43. const SVG_TYPE = "svg";
  44. const CSS_TYPE = "css";
  45. const HTML_TYPE = "html";
  46. const JAVASCRIPT_TYPE = "javascript";
  47. /**
  48. * Every language `languageOfMediaType` can name, for a consumer that has to say
  49. * up front which of them it handles. Stated here rather than at each such
  50. * consumer: adding one below adds it to every ability that names this.
  51. * @type {string[]}
  52. */
  53. const EMBEDDED_LANGUAGES = [
  54. SVG_TYPE,
  55. CSS_TYPE,
  56. HTML_TYPE,
  57. JSON_TYPE,
  58. JAVASCRIPT_TYPE
  59. ];
  60. /**
  61. * The language name a media type carries, for a consumer that dispatches on it
  62. * — a `renderEmbeddedSource` renderer today. Not a grammar table read out of a
  63. * dataset: it is the handful of essences webpack itself has a notion of, so an
  64. * unknown one declines rather than guessing. The `+json` structured suffix is
  65. * read off the subtype.
  66. * @param {string} mediaType a media type, e.g. `image/svg+xml`
  67. * @returns {string | undefined} the language, or undefined when it names none
  68. */
  69. const languageOfMediaType = (mediaType) => {
  70. const essence = mediaType.toLowerCase().trim();
  71. if (essence === "") return undefined;
  72. if (essence === "image/svg+xml") return SVG_TYPE;
  73. if (essence === "text/css") return CSS_TYPE;
  74. if (essence === "text/html") return HTML_TYPE;
  75. if (essence === "application/json" || essence.endsWith("+json")) {
  76. return JSON_TYPE;
  77. }
  78. if (/^(?:text|application)\/(?:x-)?(?:ecma|java)script$/.test(essence)) {
  79. return JAVASCRIPT_TYPE;
  80. }
  81. return undefined;
  82. };
  83. const getMimeTypes = memoize(() => require("./mimeTypes"));
  84. /**
  85. * The language a file's name says it holds, for a module that embeds the file's
  86. * text rather than a `data:` URI — the same question `languageOfMediaType`
  87. * answers, read off the extension through webpack's own mime lookup.
  88. * @param {string | null} filename the file's name, or null when it has none
  89. * @returns {string | undefined} the language, or undefined when it names none
  90. */
  91. const languageOfFilename = (filename) => {
  92. if (!filename) return undefined;
  93. const mediaType = getMimeTypes().lookup(path.extname(filename));
  94. return typeof mediaType === "string"
  95. ? languageOfMediaType(mediaType)
  96. : undefined;
  97. };
  98. /**
  99. * Split a `data:` URI, or `null` when it is not one.
  100. * @param {string} uri the URI, unquoted and unescaped
  101. * @returns {ParsedDataURI | null} its parts
  102. */
  103. const parseDataURI = (uri) => {
  104. const match = STRICT_URI_REGEXP.exec(uri);
  105. if (match === null) return null;
  106. return {
  107. mediaType: match[1],
  108. base64: /;base64$/i.test(match[2]),
  109. payload: match[3]
  110. };
  111. };
  112. /**
  113. * The payload as text, or `null` when reading it would not round-trip. A
  114. * percent-escaped payload is declined rather than decoded: how much of it the
  115. * author escaped is not recorded anywhere in the URI, so re-escaping would
  116. * rewrite bytes nothing asked to change.
  117. * @param {ParsedDataURI} parsed the split URI
  118. * @returns {string | null} the payload as text
  119. */
  120. const decodeDataURIPayload = (parsed) => {
  121. if (!parsed.base64) {
  122. return parsed.payload.includes("%") ? null : parsed.payload;
  123. }
  124. // Decoding is lenient rather than throwing, so garbage decodes to something
  125. // that does not round-trip, which is the same answer: leave it alone.
  126. const text = Buffer.from(parsed.payload, "base64").toString("utf8");
  127. return Buffer.from(text, "utf8").toString("base64") === parsed.payload
  128. ? text
  129. : null;
  130. };
  131. /**
  132. * Rebuild a `data:` URI around a new payload, in the form it was written in.
  133. * @param {ParsedDataURI} parsed the split URI
  134. * @param {string} text the new payload as text
  135. * @returns {string} the rebuilt URI
  136. */
  137. const buildDataURI = (parsed, text) => {
  138. const head = `data:${parsed.mediaType}${parsed.base64 ? ";base64" : ""},`;
  139. if (parsed.base64) {
  140. return head + Buffer.from(text, "utf8").toString("base64");
  141. }
  142. // Only the two that would change what the URI means: `%` starts an escape and
  143. // `#` starts a fragment. Everything else stays as the renderer wrote it, and
  144. // the url token's own quoting is the caller's to apply.
  145. return head + text.replace(/%/g, "%25").replace(/#/g, "%23");
  146. };
  147. /**
  148. * The minified text a renderer answered with, for one that may answer with a
  149. * whole result instead. `undefined` is a renderer that declined.
  150. * @param {string | { code?: string } | undefined} answer what it answered
  151. * @returns {string | undefined} the text, or undefined
  152. */
  153. const embeddedText = (answer) =>
  154. answer === undefined || typeof answer === "string" ? answer : answer.code;
  155. /**
  156. * Everything a run's renderers reported, in the shape a minifier returns —
  157. * an empty list is left off, so a run nothing was reported over is exactly
  158. * what it always was.
  159. * @param {{ warnings?: (Error | string)[], errors?: (Error | string)[] }[]} reported what each answered with
  160. * @returns {{ warnings?: (Error | string)[], errors?: (Error | string)[] }} the collected diagnostics
  161. */
  162. const collectEmbeddedDiagnostics = (reported) => {
  163. /** @type {(Error | string)[]} */
  164. const warnings = [];
  165. /** @type {(Error | string)[]} */
  166. const errors = [];
  167. for (const entry of reported) {
  168. if (entry.warnings !== undefined) warnings.push(...entry.warnings);
  169. if (entry.errors !== undefined) errors.push(...entry.errors);
  170. }
  171. /** @type {{ warnings?: (Error | string)[], errors?: (Error | string)[] }} */
  172. const out = {};
  173. if (warnings.length !== 0) out.warnings = warnings;
  174. if (errors.length !== 0) out.errors = errors;
  175. return out;
  176. };
  177. /**
  178. * One embedded body a print offered but could not wait for, and the text to
  179. * print around the answer once it is in: {@link import("./SourceProcessor").DeferredWrite},
  180. * plus what the grammar has to say about what it offered. `as` names which of
  181. * that language's productions the body is, where it has more than one — an HTML
  182. * `style=""` is `"block-contents"` rather than a whole stylesheet.
  183. * @typedef {import("./SourceProcessor").DeferredWrite & { type: string, hostType: string, as?: string }} DeferredEmbeddedSource
  184. */
  185. /**
  186. * What a renderer made of one embedded body: the minified text, and anything it
  187. * has to report about it. A bare string is the text alone, `undefined` a
  188. * renderer that declined.
  189. * @typedef {{ code?: string, warnings?: (Error | string)[], errors?: (Error | string)[] }} EmbeddedSourceResult
  190. */
  191. /**
  192. * Ask a renderer for one embedded body and keep what it reported. Anything it
  193. * throws is a renderer that did not answer rather than an asset that fails to
  194. * serialize: the rest of the print stands, the body is spelled as an untapped
  195. * run spells it, and why it was is not lost.
  196. * @param {(source: string, info: { type: string, hostType: string, as?: string }) => Promise<string | EmbeddedSourceResult | undefined> | string | EmbeddedSourceResult | undefined} render the caller's renderer
  197. * @param {{ source: string, type: string, hostType: string, as?: string }} hole the body to offer
  198. * @param {EmbeddedSourceResult[]} reported collects what each says about them
  199. * @returns {Promise<string | EmbeddedSourceResult | undefined>} what it answered, or undefined where it declined or threw
  200. */
  201. const askEmbeddedRenderer = async (render, hole, reported) => {
  202. const { source, type, hostType, as } = hole;
  203. try {
  204. const answer = await render(source, {
  205. type,
  206. hostType,
  207. ...(as === undefined ? undefined : { as })
  208. });
  209. if (
  210. answer !== undefined &&
  211. typeof answer !== "string" &&
  212. (answer.warnings !== undefined || answer.errors !== undefined)
  213. ) {
  214. reported.push(answer);
  215. }
  216. return answer;
  217. } catch (error) {
  218. reported.push({ errors: [/** @type {Error} */ (error)] });
  219. return undefined;
  220. }
  221. };
  222. module.exports = {
  223. EMBEDDED_LANGUAGES,
  224. URIRegEx,
  225. askEmbeddedRenderer,
  226. buildDataURI,
  227. collectEmbeddedDiagnostics,
  228. decodeDataURI,
  229. decodeDataURIPayload,
  230. embeddedText,
  231. languageOfFilename,
  232. languageOfMediaType,
  233. parseDataURI
  234. };