diff --git a/README.md b/README.md index 99d6f6b..47e5cea 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,20 @@ For the default template the html-rspack-plugin will already provide a default f Please take a look at this well maintained list of almost all [possible meta tags](https://github.com/joshbuchea/HEAD#meta). +#### charset meta tag + +Use object notation to add a character encoding declaration: + +```js +new HtmlRspackPlugin({ + meta: { + charset: { charset: 'utf-8' }, + }, +}); +``` + +With automatic injection enabled, a generated `charset` meta tag is placed immediately after the opening `
` tag so it is not pushed behind other head content. + #### name/content meta tags Most meta tags are configured by setting a `name` and a `content` attribute. diff --git a/lib/index.js b/lib/index.js index 115fa6e..41b9bb4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -19,6 +19,52 @@ const getHtmlRspackPluginHooks = require('./hooks.js').getHtmlRspackPluginHooks; const WITH_PLACEHOLDER = 'function __with_placeholder__'; +/** + * Returns whether the given tag is a charset meta tag. + * + * @param {HtmlTagObject} tag + * @returns {boolean} + */ +function isCharsetMetaTag(tag) { + return ( + tag.tagName.toLowerCase() === 'meta' && + Object.keys(tag.attributes || {}).some( + (attributeName) => attributeName.toLowerCase() === 'charset', + ) + ); +} + +/** + * Returns the end offset of the opening head tag. + * + * @param {string} html + * @returns {number} + */ +function findHeadTagEnd(html) { + const headTagStart = html.search(/])/i); + if (headTagStart === -1) { + return -1; + } + + let quote; + + for (let index = headTagStart + 5; index < html.length; index++) { + const character = html[index]; + + if (quote) { + if (character === quote) { + quote = undefined; + } + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + return index + 1; + } + } + + return -1; +} + /** @typedef {import("../typings.js").HtmlTagObject} HtmlTagObject */ /** @typedef {import("../typings.js").Options} HtmlWebpackOptions */ /** @typedef {import("../typings.js").ProcessedOptions} ProcessedHtmlWebpackOptions */ @@ -711,7 +757,7 @@ class HtmlRspackPlugin { if (this.options.inject) { const htmlRegExp = /(]*>)/i; - const headRegExp = /(<\/head\s*>)/i; + const headCloseRegExp = /(<\/head\s*>)/i; const bodyRegExp = /(<\/body\s*>)/i; const doctypeRegExp = //i; @@ -719,22 +765,28 @@ class HtmlRspackPlugin { const body = assetTags.bodyTags.map((assetTagObject) => htmlTagObjectToString(assetTagObject, this.options.xhtml), ); - const head = assetTags.headTags - .filter((item) => { - if ( - item.tagName === 'meta' && - item.attributes && - item.attributes.name === 'viewport' && - metaViewportRegExp.test(html) - ) { - return false; - } + const headStart = []; + const headEnd = []; + + for (const item of assetTags.headTags) { + if (isCharsetMetaTag(item)) { + // A charset declaration has to be entirely within the first 1024 bytes + // of the document, so inject it at the start of the head. + headStart.push(htmlTagObjectToString(item, this.options.xhtml)); + continue; + } - return true; - }) - .map((assetTagObject) => - htmlTagObjectToString(assetTagObject, this.options.xhtml), - ); + if ( + item.tagName === 'meta' && + item.attributes && + item.attributes.name === 'viewport' && + metaViewportRegExp.test(html) + ) { + continue; + } + + headEnd.push(htmlTagObjectToString(item, this.options.xhtml)); + } if (body.length) { if (bodyRegExp.test(html)) { @@ -746,9 +798,9 @@ class HtmlRspackPlugin { } } - if (head.length) { + if (headStart.length || headEnd.length) { // Create a head tag if none exists - if (!headRegExp.test(html)) { + if (!headCloseRegExp.test(html)) { if (!htmlRegExp.test(html)) { if (doctypeRegExp.test(html)) { html = html.replace( @@ -763,8 +815,27 @@ class HtmlRspackPlugin { } } - // Append assets to head element - html = html.replace(headRegExp, (match) => head.join('') + match); + // A charset declaration must be as early as possible in the document. + if (headStart.length) { + const headTagEnd = findHeadTagEnd(html); + + if (headTagEnd === -1) { + headEnd.unshift(...headStart); + } else { + html = + html.slice(0, headTagEnd) + + headStart.join('') + + html.slice(headTagEnd); + } + } + + // Keep all other assets at the end of the head element. + if (headEnd.length) { + html = html.replace( + headCloseRegExp, + (match) => headEnd.join('') + match, + ); + } } } diff --git a/tests/post-process-html.test.js b/tests/post-process-html.test.js new file mode 100644 index 0000000..719f8bd --- /dev/null +++ b/tests/post-process-html.test.js @@ -0,0 +1,76 @@ +import { Buffer } from 'node:buffer'; +import { describe, expect, it } from '@rstest/core'; +import { HtmlRspackPlugin } from './helpers/compile.js'; + +const compiler = { + options: { + mode: 'development', + }, +}; + +function createTag(tagName, attributes, innerHTML) { + return { + tagName, + voidTag: tagName.toLowerCase() === 'meta', + attributes: attributes || {}, + innerHTML: innerHTML || '', + meta: {}, + }; +} + +function postProcessHtml(html, headTags, options) { + const plugin = new HtmlRspackPlugin({ inject: true, ...options }); + + return plugin.postProcessHtml( + compiler, + html, + { publicPath: '', js: [], css: [] }, + { headTags, bodyTags: [] }, + ); +} + +describe('HtmlRspackPlugin HTML post processing', () => { + it('injects a generated charset at the start of head and within the first 1024 bytes', async () => { + const longHeadContent = '你好'.repeat(300); + const html = `${longHeadContent}`; + const output = await postProcessHtml(html, [ + createTag('meta', { charset: 'UTF-8' }), + createTag('script', { src: 'main.js' }), + ]); + const charsetMatch = //.exec(output); + + expect(charsetMatch).not.toBeNull(); + expect(output).toContain(''); + expect(output).toContain(''); + expect( + Buffer.byteLength( + output.slice(0, charsetMatch.index + charsetMatch[0].length), + ), + ).toBeLessThanOrEqual(1024); + }); + + it('handles greater-than signs inside head attributes', async () => { + const html = ''; + const output = await postProcessHtml(html, [ + createTag('meta', { charset: 'UTF-8' }), + ]); + + expect(output).toBe( + '', + ); + }); + + it('creates a head before injecting a generated charset', async () => { + const output = await postProcessHtml( + '', + [ + createTag('meta', { charset: 'UTF-8' }), + createTag('script', { src: 'main.js' }), + ], + ); + + expect(output).toBe( + '', + ); + }); +});