diff --git a/.changeset/giant-guests-tease.md b/.changeset/giant-guests-tease.md new file mode 100644 index 000000000..1c485caee --- /dev/null +++ b/.changeset/giant-guests-tease.md @@ -0,0 +1,5 @@ +--- +'wmr': patch +--- + +Fix prerender deleting existing attributes on -tag diff --git a/.github/workflows/compressed-size.yml b/.github/workflows/compressed-size.yml index 306bb6c66..2fd2a3a37 100644 --- a/.github/workflows/compressed-size.yml +++ b/.github/workflows/compressed-size.yml @@ -24,7 +24,7 @@ jobs: if: ${{ steps.filter.outputs.wmr == 'true' }} uses: preactjs/compressed-size-action@v2 with: - pattern: '{packages/wmr/wmr.cjs,examples/demo/dist/**/*.{js,css,html}}' + pattern: '{packages/wmr/*.cjs,examples/demo/dist/**/*.{js,css,html}}' build-script: ci strip-hash: "\\.(\\w{8})\\.(?:js|css)$" repo-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.gitignore b/.gitignore index 212b5e961..0fe00e853 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist node_modules wmr.cjs +prerender-worker.cjs package-lock.json !packages/wmr/test/fixtures/commonjs/node_modules !packages/wmr/test/fixtures/package-exports/node_modules diff --git a/packages/wmr/package.json b/packages/wmr/package.json index 9b0d139f3..4072fc1f1 100644 --- a/packages/wmr/package.json +++ b/packages/wmr/package.json @@ -19,6 +19,7 @@ "repository": "preactjs/wmr", "files": [ "wmr.cjs", + "prerender-worker.cjs", "index.js", "types.d.ts", "README.md" diff --git a/packages/wmr/rollup.config.js b/packages/wmr/rollup.config.js index e126c8903..556946c9a 100644 --- a/packages/wmr/rollup.config.js +++ b/packages/wmr/rollup.config.js @@ -174,4 +174,14 @@ const config = { ] }; -export default config; +export default [ + config, + { + ...config, + input: 'src/lib/prerender-worker.js', + output: { + ...config.output, + file: 'prerender-worker.cjs' + } + } +]; diff --git a/packages/wmr/src/lib/prerender-worker.js b/packages/wmr/src/lib/prerender-worker.js new file mode 100644 index 000000000..61a921a2f --- /dev/null +++ b/packages/wmr/src/lib/prerender-worker.js @@ -0,0 +1,219 @@ +import { parentPort, workerData } from 'worker_threads'; +import { promises as fs } from 'fs'; +import path from 'path'; +import posthtml from 'posthtml'; +import { walkHtmlNode } from './transform-html.js'; + +/** + * @param {string} str + * @returns {string} + */ +function enc(str) { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +/** @typedef {{ type: string, props: Record, children?: string } | string | null} HeadElement */ + +/** + * @typedef {{ lang: string, title: string, elements: Set}} HeadResult + */ + +/** + * @param {HeadElement|HeadElement[]} element + * @returns {string} html + */ +function serializeElement(element) { + if (element == null) return ''; + if (typeof element !== 'object') return String(element); + if (Array.isArray(element)) return element.map(serializeElement).join(''); + const type = element.type; + let s = `<${type}`; + const props = element.props || {}; + let children = element.children; + for (const prop of Object.keys(props).sort()) { + const value = props[prop]; + // Filter out empty values: + if (value == null) continue; + if (prop === 'children' || prop === 'textContent') children = value; + else s += ` ${prop}="${enc(value)}"`; + } + s += '>'; + if (!/link|meta|base/.test(type)) { + if (children) s += serializeElement(children); + s += ``; + } + return s; +} + +/** + * + * @param {{cwd: string, out: string, publicPath: string}} options + * @returns {Promise<{ routes: Array<{ url: string }> }>} + */ +async function workerCode({ cwd, out, publicPath }) { + /*global globalThis*/ + + globalThis.location = /** @type {object} */ ({}); + + globalThis.self = /** @type {any} */ (globalThis); + + // Inject a {type:module} package.json into the dist directory to enable Node's ESM loader: + try { + await fs.writeFile(path.resolve(cwd, out, 'package.json'), '{"type":"module"}'); + } catch (e) { + throw Error(`Failed to write {"type":"module"} package.json to dist directory.\n ${e}`); + } + + // Grab the generated HTML file, which we'll use as a template: + const tpl = await fs.readFile(path.resolve(cwd, out, 'index.html'), 'utf-8'); + + // The first script in the file that is not external is assumed to have a + // `prerender` export + let script; + const SCRIPT_TAG = /]*?)?\s+src=(['"]?)([^>]*?)\1(?:\s[^>]*?)?>/g; + + let match; + while ((match = SCRIPT_TAG.exec(tpl))) { + // Ignore external urls + if (!match || /^(?:https?|file|data)/.test(match[2])) continue; + + script = match[2].replace(publicPath, '').replace(/^(\.?\/)?/g, ''); + script = path.resolve(cwd, out, script); + } + + if (!script) { + throw Error(`Unable to detect `; + } else if (result.data) { + console.warn('You passed in prerender-data in a non-object format: ', result.data); + } + } else { + body = result; + } + + let html = tpl; + + const transformer = posthtml([ + tree => { + tree.walk(node => { + if (!node) return node; + + // Add "lang" attribute to + if (node.tag === 'html') { + if (!node.attrs) node.attrs = {}; + node.attrs.lang = head.lang; + } + + // Update or inject title tag + if (node.tag === 'head') { + let hasTitle = false; + + walkHtmlNode(node, headNode => { + if (headNode.tag === 'title') { + hasTitle = true; + headNode.content = [head.title]; + } + return headNode; + }); + + if (!hasTitle) { + // TODO: TS types of posthtml seem to be wrong + // @ts-ignore + node.content?.unshift({ + tag: 'title', + attrs: {}, + content: [head.title] + }); + } + } + + return node; + }); + } + ]); + html = (await transformer.process(html)).html; + + // Inject HTML links at the end of for any stylesheets injected during rendering of the page: + let headHtml = head.elements ? Array.from(new Set(Array.from(head.elements).map(serializeElement))).join('') : ''; + html = html.replace(/(<\/head>)/, headHtml + '$1'); + + // Inject pre-rendered HTML into the start of : + html = html.replace(/(]*?)?>)/, '$1' + body); + + // Write the generated HTML to disk: + await fs.mkdir(path.dirname(outFile), { recursive: true }).catch(Object); + await fs.writeFile(outFile, html); + } + await fs.unlink(path.resolve(cwd, out, 'package.json')).catch(Object); + + return { routes }; +} + +(async () => { + try { + const result = await workerCode(workerData); + parentPort?.postMessage([1, result]); + } catch (err) { + console.log(err); + parentPort?.postMessage([0, err]); + } +})(); diff --git a/packages/wmr/src/lib/prerender.js b/packages/wmr/src/lib/prerender.js index ccdbcc2b6..bd05988b1 100644 --- a/packages/wmr/src/lib/prerender.js +++ b/packages/wmr/src/lib/prerender.js @@ -1,4 +1,9 @@ import { Worker } from 'worker_threads'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { isFile } from './fs-utils.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * @param {object} options @@ -6,20 +11,19 @@ import { Worker } from 'worker_threads'; * @property {string} [out = '.cache'] * @property {string} publicPath */ -export function prerender({ cwd = '.', out = '.cache', publicPath }) { +export async function prerender({ cwd = '.', out = '.cache', publicPath }) { let w; try { - w = new Worker( - `(${workerCode})(require('worker_threads').workerData) - .then(r => require('worker_threads').parentPort.postMessage([1,r])) - .catch(err => require('worker_threads').parentPort.postMessage([0,err && err.stack || err+'']))`, - { - eval: true, - workerData: { cwd, out, publicPath }, - // execArgv: ['--experimental-modules'], - stderr: true - } - ); + // Files will have different names when we build wmr itself + const filename = (await isFile(path.join(__dirname, 'prerender-worker.cjs'))) + ? path.join(__dirname, 'prerender-worker.cjs') + : path.join(__dirname, 'prerender-worker.js'); + + w = new Worker(filename, { + workerData: { cwd, out, publicPath }, + // execArgv: ['--experimental-modules'], + stderr: true + }); } catch (e) { throw Error( `Failed to prerender, Workers aren't supported in your current Node.JS version (try v14 or later).\n ${e}` @@ -44,178 +48,3 @@ export function prerender({ cwd = '.', out = '.cache', publicPath }) { w.once('exit', resolve); }); } - -async function workerCode({ cwd, out, publicPath }) { - /*global globalThis*/ - - const path = require('path'); - const fs = require('fs').promises; - - function enc(str) { - return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); - } - - globalThis.location = /** @type {object} */ ({}); - - globalThis.self = /** @type {any} */ (globalThis); - - // Inject a {type:module} package.json into the dist directory to enable Node's ESM loader: - try { - await fs.writeFile(path.resolve(cwd, out, 'package.json'), '{"type":"module"}'); - } catch (e) { - throw Error(`Failed to write {"type":"module"} package.json to dist directory.\n ${e}`); - } - - // Grab the generated HTML file, which we'll use as a template: - const tpl = await fs.readFile(path.resolve(cwd, out, 'index.html'), 'utf-8'); - - // The first script in the file that is not external is assumed to have a - // `prerender` export - let script; - const SCRIPT_TAG = /]*?)?\s+src=(['"]?)([^>]*?)\1(?:\s[^>]*?)?>/g; - - let match; - while ((match = SCRIPT_TAG.exec(tpl))) { - // Ignore external urls - if (!match || /^(?:https?|file|data)/.test(match[2])) continue; - - script = match[2].replace(publicPath, '').replace(/^(\.?\/)?/g, ''); - script = path.resolve(cwd, out, script); - } - - if (!script) { - throw Error(`Unable to detect `; - } else if (result.data) { - console.warn('You passed in prerender-data in a non-object format: ', result.data); - } - } else { - body = result; - } - - // TODO: Use a proper HTML parser here. We should definitely not parse HTML - // with regex :S - - // Inject HTML links at the end of for any stylesheets injected during rendering of the page: - let headHtml = head.elements ? Array.from(new Set(Array.from(head.elements).map(serializeElement))).join('') : ''; - - let html = tpl; - - if (head.title) { - const title = `${enc(head.title)}`; - const matchTitle = /([^<>]*?)<\/title>/i; - if (matchTitle.test(html)) { - html = html.replace(matchTitle, title); - } else { - headHtml = title + headHtml; - } - } - - if (head.lang) { - // TODO: This removes any existing attributes, but merging them without - // a proper HTML parser is way too error prone. - html = html.replace(/(<html(\s[^>]*?)?>)/, `<html lang="${enc(head.lang)}">`); - } - - html = html.replace(/(<\/head>)/, headHtml + '$1'); - - // Inject pre-rendered HTML into the start of <body>: - html = html.replace(/(<body(\s[^>]*?)?>)/, '$1' + body); - - // Write the generated HTML to disk: - await fs.mkdir(path.dirname(outFile), { recursive: true }).catch(Object); - await fs.writeFile(outFile, html); - } - await fs.unlink(path.resolve(cwd, out, 'package.json')).catch(Object); - - return { routes }; -} diff --git a/packages/wmr/src/lib/transform-html.js b/packages/wmr/src/lib/transform-html.js index 3b89b2278..cef3e86c6 100644 --- a/packages/wmr/src/lib/transform-html.js +++ b/packages/wmr/src/lib/transform-html.js @@ -50,6 +50,24 @@ function transformUrls({ transformUrl }) { }; } +/** + * @param {posthtml.Node} node + * @param {(node: posthtml.Node) => posthtml.Node | void} cb + */ +export function walkHtmlNode(node, cb) { + if (node.content && Array.isArray(node.content)) { + for (let i = 0; i < node.content.length; i++) { + const child = node.content[i]; + if (child !== null && typeof child === 'object') { + const res = cb(child); + if (res) { + node.content[i] = res; + } + } + } + } +} + /** * @param {string} html * @param {object} options diff --git a/packages/wmr/test/fixtures/prerender-html-lang/index.html b/packages/wmr/test/fixtures/prerender-html-lang/index.html new file mode 100644 index 000000000..fcdc9b73a --- /dev/null +++ b/packages/wmr/test/fixtures/prerender-html-lang/index.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en" class="foo" data-foo="bar"> + <head> + <meta charset="utf8" /> + <title>default title + + + + + diff --git a/packages/wmr/test/fixtures/prerender-html-lang/index.js b/packages/wmr/test/fixtures/prerender-html-lang/index.js new file mode 100644 index 000000000..400dd3698 --- /dev/null +++ b/packages/wmr/test/fixtures/prerender-html-lang/index.js @@ -0,0 +1,8 @@ +export function prerender() { + return { + html: '

it works

', + links: ['/'], + data: { hello: 'world' }, + head: { lang: 'my-lang', title: 'my-title', elements: new Set() } + }; +} diff --git a/packages/wmr/test/production.test.js b/packages/wmr/test/production.test.js index 7a16fc376..ed00cd172 100644 --- a/packages/wmr/test/production.test.js +++ b/packages/wmr/test/production.test.js @@ -609,6 +609,20 @@ describe('production', () => { expect(instance.output.join('\n')).toMatch(/^\s+at\s\w+/gm); expect(code).toBe(1); }); + + it('should keep attributes on ', async () => { + await loadFixture('prerender-html-lang', env); + instance = await runWmr(env.tmp.path, 'build', '--prerender'); + const code = await instance.done; + + await withLog(instance.output, async () => { + expect(code).toBe(0); + + const indexHtml = path.join(env.tmp.path, 'dist', 'index.html'); + const index = await fs.readFile(indexHtml, 'utf8'); + expect(index).toMatch(/lang="my-lang" class="foo" data-foo="bar"/); + }); + }); }); describe('Code Splitting', () => {