UNPKG

@greenwood/cli

Version:
309 lines (271 loc) 11 kB
// @ts-nocheck /* * * Manages web standard resource related operations for HTML and markdown. * This is a Greenwood default plugin. * */ import fs from "fs/promises"; import rehypeStringify from "rehype-stringify"; import rehypeRaw from "rehype-raw"; import remarkFrontmatter from "remark-frontmatter"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { getUserScripts, getPageLayout, getAppLayout } from "../../lib/layout-utils.js"; import { requestAsObject } from "../../lib/resource-utils.js"; import { unified } from "unified"; import { Worker } from "worker_threads"; import { parse as htmlparser } from "node-html-parser"; class StandardHtmlResource { constructor(compilation) { this.compilation = compilation; this.extensions = [".html", ".md"]; this.contentType = "text/html"; } async shouldServe(url, request) { const { protocol, pathname } = url; const hasMatchingPageRoute = this.compilation.graph.find((node) => node.route === pathname); const isSPA = this.compilation.graph.find((node) => node.isSPA) && pathname.indexOf(".") < 0; return ( protocol.startsWith("http") && (hasMatchingPageRoute || (isSPA && request.headers.get("Accept").indexOf("text/html") >= 0)) ); } async serve(url, request) { const { config, context } = this.compilation; const { userWorkspace } = context; const { pathname } = url; const isSpaRoute = this.compilation.graph.find((node) => node.isSPA); const matchingRoute = this.compilation.graph.find((node) => node.route === pathname) || {}; const { pageHref } = matchingRoute; const filePath = !matchingRoute.external && pageHref ? new URL(pageHref).pathname.replace(userWorkspace.pathname, "./") : ""; const isMarkdownContent = (filePath || "").split(".").pop() === "md"; let body = ""; let layout = matchingRoute.layout || null; let customImports = matchingRoute.imports || []; let ssrBody; let ssrLayout; let processedMarkdown = null; if (matchingRoute.external) { layout = matchingRoute.layout || layout; } if (isMarkdownContent) { const markdownContents = await fs.readFile(new URL(pageHref), "utf-8"); const rehypePlugins = []; const remarkPlugins = []; for (const plugin of config.markdown.plugins) { const name = typeof plugin === "string" ? plugin : plugin.name; const options = plugin?.options ?? null; const pluginTypeArray = name.indexOf("rehype-") >= 0 ? rehypePlugins : remarkPlugins; const pluginImport = (await import(name)).default; if (options) { pluginTypeArray.push([pluginImport, options]); } else { pluginTypeArray.push(pluginImport); } } processedMarkdown = await unified() .use(remarkParse) // parse markdown into AST .use(remarkFrontmatter) // extract frontmatter from AST .use(remarkPlugins) // apply userland remark plugins .use(remarkRehype, { allowDangerousHtml: true }) // convert from markdown to HTML AST .use(rehypeRaw) // support mixed HTML in markdown .use(rehypePlugins) // apply userland rehype plugins .use(rehypeStringify) // convert AST to HTML string .process(markdownContents); } if (matchingRoute.isSSR) { const routeModuleLocationUrl = new URL(pageHref); const routeWorkerUrl = this.compilation.config.plugins .find((plugin) => plugin.type === "renderer") .provider().executeModuleUrl; // eslint-disable-next-line no-async-promise-executor await new Promise(async (resolve, reject) => { const worker = new Worker(new URL("../../lib/ssr-route-worker.js", import.meta.url)); worker.on("message", (result) => { if (result.layout) { ssrLayout = result.layout; } if (result.body) { ssrBody = result.body; } resolve(); }); worker.on("error", reject); worker.on("exit", (code) => { if (code !== 0) { reject(new Error(`Worker stopped with exit code ${code}`)); } }); worker.postMessage({ executeModuleUrl: routeWorkerUrl.href, moduleUrl: routeModuleLocationUrl.href, compilation: JSON.stringify(this.compilation), page: JSON.stringify(matchingRoute), request: await requestAsObject(request), }); }); } if (isSpaRoute) { body = await fs.readFile(new URL(isSpaRoute.pageHref), "utf-8"); } else { body = ssrLayout ? ssrLayout : await getPageLayout(pageHref, this.compilation, layout); } body = await getAppLayout(body, this.compilation, customImports, matchingRoute); body = await getUserScripts(body, this.compilation); if (processedMarkdown) { const wrappedCustomElementRegex = /<p><[a-zA-Z]*-[a-zA-Z](.*)>(.*)<\/[a-zA-Z]*-[a-zA-Z](.*)><\/p>/g; const ceTest = wrappedCustomElementRegex.test(processedMarkdown.value); if (ceTest) { const ceMatches = processedMarkdown.value.match(wrappedCustomElementRegex); ceMatches.forEach((match) => { const stripWrappingTags = match.replace("<p>", "").replace("</p>", ""); processedMarkdown.value = processedMarkdown.value.replace(match, stripWrappingTags); }); } // https://github.com/ProjectEvergreen/greenwood/issues/1126 body = body.replace( /<content-outlet>(.*)<\/content-outlet>/s, processedMarkdown.value.replace(/\$/g, "$$$"), ); } else if (matchingRoute.external) { body = body.replace(/<content-outlet>(.*)<\/content-outlet>/s, matchingRoute.body); } else if (ssrBody) { body = body.replace( /<content-outlet>(.*)<\/content-outlet>/s, `<!-- greenwood-ssr-start -->${ssrBody.replace(/\$/g, "$$$")}<!-- greenwood-ssr-end -->`, ); } // clean up any empty placeholder content-outlet if (body.indexOf("<content-outlet></content-outlet>") > 0) { body = body.replace("<content-outlet></content-outlet>", ""); } return new Response(body, { headers: new Headers({ "Content-Type": this.contentType, }), }); } async shouldOptimize(url, response) { return response.headers.get("Content-Type")?.indexOf(this.contentType) >= 0; } async optimize(url, response) { const { optimization, basePath } = this.compilation.config; const { pathname } = url; const pageResources = this.compilation.graph.find((page) => page.route === pathname).resources; let body = await response.text(); const root = htmlparser(body, { blockTextElements: { script: true, style: true, }, }); for (const pageResource of pageResources) { const keyedResource = this.compilation.resources.get(pageResource); const { contents, src, type, optimizationAttr, optimizedFileContents, optimizedFileName } = keyedResource; if (src) { if (type === "script") { const tag = root .querySelectorAll("script") .find((script) => script.getAttribute("src") === src); if (!optimizationAttr && (optimization === "default" || optimization === "none")) { const optimizedFilePath = `${basePath}/${optimizedFileName}`; body = body.replace(src, optimizedFilePath); body = body.replace( "<head>", ` <head> <link rel="modulepreload" href="${optimizedFilePath}" as="script"> `, ); } else if (optimizationAttr === "inline" || optimization === "inline") { const isModule = tag.rawAttrs.indexOf('type="module') >= 0 ? ' type="module"' : ""; body = body.replace( `<script ${tag.rawAttrs}></script>`, ` <script ${isModule}> ${optimizedFileContents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, "$$$")} </script> `, ); } else if (optimizationAttr === "static" || optimization === "static") { body = body.replace(`<script ${tag.rawAttrs}></script>`, ""); } } else if (type === "link") { const tag = root .querySelectorAll("link") .find((link) => link.getAttribute("href") === src); if (optimizationAttr !== "inline" && optimization !== "inline") { const optimizedFilePath = `${basePath}/${optimizedFileName}`; body = body.replace(src, optimizedFilePath); body = body.replace( "<head>", ` <head> <link rel="preload" href="${optimizedFilePath}" as="style" crossorigin="anonymous"></link> `, ); } else if (optimizationAttr === "inline" || optimization === "inline") { // https://github.com/ProjectEvergreen/greenwood/issues/810 // when pre-rendering, puppeteer normalizes everything to <link .../> // but if not using pre-rendering, then it could come out as <link ...></link> // not great, but best we can do for now until #742 body = body .replace( `<link ${tag.rawAttrs}>`, ` <style> ${optimizedFileContents} </style> `, ) .replace( `<link ${tag.rawAttrs}/>`, ` <style> ${optimizedFileContents} </style> `, ); } } } else { if (type === "script") { const tag = root .querySelectorAll("script") .find((script) => script.innerHTML === contents); if (optimizationAttr === "static" || optimization === "static") { body = body.replace( `<script ${tag.rawAttrs}>${contents.replace(/\.\//g, "/").replace(/\$/g, "$$$")}</script>`, "", ); } else if (optimizationAttr === "none") { body = body.replace( contents, contents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, "$$$"), ); } else { body = body.replace( contents, optimizedFileContents.replace(/\.\//g, `${basePath}/`).replace(/\$/g, "$$$"), ); } } else if (type === "style") { body = body.replace(contents, optimizedFileContents); } } } return new Response(body); } } const greenwoodPluginStandardHtml = { type: "resource", name: "plugin-standard-html", provider: (compilation) => new StandardHtmlResource(compilation), }; export { greenwoodPluginStandardHtml };