UNPKG

@greenwood/cli

Version:
310 lines (265 loc) 9.3 kB
// @ts-nocheck import fs from "fs/promises"; import { hashString } from "./hashing-utils.js"; import { getResolvedHrefFromPathnameShortcut } from "../lib/node-modules-utils.js"; import htmlparser from "node-html-parser"; import { asyncMap } from "./async-utils.js"; async function modelResource( context, type, src = undefined, contents = undefined, optimizationAttr = undefined, rawAttributes = undefined, ) { const { scratchDir, userWorkspace, projectDirectory } = context; const extension = type === "script" ? "js" : "css"; let sourcePathURL; if (src) { sourcePathURL = src.startsWith("/node_modules/") ? new URL(getResolvedHrefFromPathnameShortcut(src, projectDirectory)) : src.startsWith("/") ? new URL(`.${src}`, userWorkspace) : new URL(`./${src.replace(/\.\.\//g, "").replace("./", "")}`, userWorkspace); contents = await fs.readFile(sourcePathURL, "utf-8"); } else { const scratchFileName = hashString(contents); sourcePathURL = new URL(`./${scratchFileName}.${extension}`, scratchDir); await fs.writeFile(sourcePathURL, contents); } return { src, // if <script src="..."></script> or <link href="..."></link> sourcePathURL, // src as a URL type, contents, optimizedFileName: undefined, optimizedFileContents: undefined, optimizationAttr, rawAttributes, }; } function mergeResponse(destination, source) { const headers = destination.headers || new Headers(); const status = source.status || destination.status; const statusText = source.statusText || destination.statusText; source.headers.forEach((value, key) => { // TODO better way to handle Response automatically setting content-type // https://github.com/ProjectEvergreen/greenwood/issues/1049 const isDefaultHeader = key.toLowerCase() === "content-type" && value === "text/plain;charset=UTF-8"; if (!isDefaultHeader) { headers.set(key, value); } }); return new Response(source.body, { headers, status, statusText, }); } // On Windows, a URL with a drive letter like C:/ thinks it is a protocol and so prepends a /, e.g. /C:/ // This is fine with newer fs methods that Greenwood uses, but tools like Rollup and PostCSS will need this handled manually // https://github.com/rollup/rollup/issues/3779 function normalizePathnameForWindows(url) { const windowsDriveRegex = /\/[a-zA-Z]{1}:\//; const { pathname = "", searchParams } = url; const params = searchParams.size > 0 ? `?${searchParams.toString()}` : ""; if (windowsDriveRegex.test(pathname)) { const driveMatch = pathname.match(windowsDriveRegex)[0]; return `${pathname.replace(driveMatch, driveMatch.replace("/", ""))}${params}`; } return `${pathname}${params}`; } async function checkResourceExists(url) { try { await fs.access(url); return true; } catch { return false; } } // turn relative paths into relatively absolute based on a known root directory // * deep link route - /blog/releases/some-post // * and a nested path in the layout - ../../styles/theme.css // so will get resolved as `${rootUrl}/styles/theme.css` async function resolveForRelativeUrl(url, rootUrl) { const search = url.search || ""; let reducedUrl; if (await checkResourceExists(new URL(`.${url.pathname}`, rootUrl))) { return new URL(`.${url.pathname}${search}`, rootUrl); } const segments = url.pathname.split("/").filter((segment) => segment !== ""); segments.shift(); for (let i = 0, l = segments.length; i < l; i += 1) { const nextSegments = segments.slice(i); const urlToCheck = new URL(`./${nextSegments.join("/")}`, rootUrl); if (await checkResourceExists(urlToCheck)) { reducedUrl = new URL(`${urlToCheck}${search}`); } } return reducedUrl; } async function trackResourcesForRoute(html, compilation, route) { const { context } = compilation; const root = htmlparser.parse(html, { script: true, style: true, }); // intentionally support <script> tags from the <head> or <body> const scripts = await asyncMap( Array.from(root.querySelectorAll("script")).filter( (script) => (isLocalLink(script.getAttribute("src")) || script.rawText) && script.rawAttrs.indexOf("importmap") < 0 && script.getAttribute("type") !== "application/json", ), async (script) => { const src = script.getAttribute("src"); const optimizationAttr = script.getAttribute("data-gwd-opt"); const { rawAttrs } = script; if (src) { // <script src="...."></script> return await modelResource(context, "script", src, null, optimizationAttr, rawAttrs); } else if (script.rawText) { // <script>...</script> return await modelResource( context, "script", null, script.rawText, optimizationAttr, rawAttrs, ); } }, ); const styles = await asyncMap( Array.from(root.querySelectorAll("style")).filter( (style) => !/\$/.test(style.rawText) && !/<!-- Shady DOM styles for -->/.test(style.rawText), ), // filter out Shady DOM <style> tags that happen when using puppeteer async (style) => await modelResource( context, "style", null, style.rawText, null, style.getAttribute("data-gwd-opt"), ), ); const links = await asyncMap( Array.from(root.querySelectorAll("link")).filter((link) => { // <link rel="stylesheet" href="..."></link> return ( link.getAttribute("rel") === "stylesheet" && link.getAttribute("href") && isLocalLink(link.getAttribute("href")) ); }), async (link) => { return await modelResource( context, "link", link.getAttribute("href"), null, link.getAttribute("data-gwd-opt"), link.rawAttrs, ); }, ); const resources = [...scripts, ...styles, ...links]; resources.forEach((resource) => { compilation.resources.set(resource.sourcePathURL.pathname, resource); }); compilation.graph.find((page) => page.route === route).resources = resources.map( (resource) => resource.sourcePathURL.pathname, ); return resources; } function isLocalLink(url = "") { return url !== "" && !url.startsWith("http") && !url.startsWith("//"); } // TODO handle full request // https://github.com/ProjectEvergreen/greenwood/discussions/1146 function transformKoaRequestIntoStandardRequest(url, request) { const { body, method, header } = request; const headers = new Headers(header); const contentType = headers.get("content-type") || ""; let format; if (contentType.includes("application/x-www-form-urlencoded")) { const formData = new FormData(); for (const key of Object.keys(body)) { formData.append(key, body[key]); } // when using FormData, let Request set the correct headers // or else it will come out as multipart/form-data // https://stackoverflow.com/a/43521052/417806 headers.delete("content-type"); format = formData; } else if (contentType.includes("application/json")) { format = JSON.stringify(body); } else { format = body; } // https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#parameters return new Request(url, { body: ["GET", "HEAD"].includes(method.toUpperCase()) ? null : format, method, headers, }); } // https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript async function requestAsObject(_request) { if (!(_request instanceof Request)) { throw Object.assign(new Error(), { name: "TypeError", message: "Argument must be a Request object", }); } const request = _request.clone(); const contentType = request.headers.get("content-type") || ""; let headers = Object.fromEntries(request.headers); let format; function stringifiableObject(obj) { const filtered = {}; for (const key in obj) { if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) { filtered[key] = obj[key]; } } return filtered; } if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await request.formData(); const params = {}; for (const entry of formData.entries()) { params[entry[0]] = entry[1]; } // when using FormData, let Request set the correct headers // or else it will come out as multipart/form-data // for serialization between route workers, leave a special marker for Greenwood // https://stackoverflow.com/a/43521052/417806 headers["content-type"] = "x-greenwood/www-form-urlencoded"; format = JSON.stringify(params); } else if (contentType.includes("application/json")) { format = JSON.stringify(await request.json()); } else { format = await request.text(); } return { ...stringifiableObject(request), body: format, headers, }; } export { checkResourceExists, mergeResponse, modelResource, normalizePathnameForWindows, requestAsObject, resolveForRelativeUrl, trackResourcesForRoute, transformKoaRequestIntoStandardRequest, isLocalLink, };