From 63e14a1b69ac6291cf90d03c3a90b9989c0966b6 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 17 Aug 2026 18:59:15 +0800 Subject: [PATCH 1/3] fix: inject charset meta tag at start of head --- README.md | 14 ++++ lib/index.js | 94 ++++++++++++++++++++------ spec/post-process-html.spec.js | 119 +++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 spec/post-process-html.spec.js diff --git a/README.md b/README.md index 99d6f6b8..5adadd38 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. If the template already contains a `charset` meta tag, the generated declaration is omitted. + #### 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 3b8185dc..07fef62c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -19,6 +19,37 @@ 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 whether the given html already contains a charset meta tag. + * Quoted attribute values are ignored to avoid treating content such as + * `content="charset=utf-8"` as a charset declaration. + * + * @param {string} html + * @returns {boolean} + */ +function hasCharsetMetaTag(html) { + const metaTags = html.match(/]*>/gi) || []; + + return metaTags.some((metaTag) => + /\scharset\s*=/i.test(metaTag.replace(/"[^"]*"|'[^']*'/g, '')), + ); +} + /** @typedef {import("../typings.js").HtmlTagObject} HtmlTagObject */ /** @typedef {import("../typings.js").Options} HtmlWebpackOptions */ /** @typedef {import("../typings.js").ProcessedOptions} ProcessedHtmlWebpackOptions */ @@ -708,7 +739,8 @@ class HtmlRspackPlugin { if (this.options.inject) { const htmlRegExp = /(]*>)/i; - const headRegExp = /(<\/head\s*>)/i; + const headOpenRegExp = /(]*)?>)/i; + const headCloseRegExp = /(<\/head\s*>)/i; const bodyRegExp = /(<\/body\s*>)/i; const doctypeRegExp = //i; @@ -716,22 +748,33 @@ 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 = []; + let hasCharset = hasCharsetMetaTag(html); + + 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. Keep a charset + // authored by the template and ignore additional generated declarations. + if (!hasCharset) { + headStart.push(htmlTagObjectToString(item, this.options.xhtml)); + hasCharset = true; } + 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)) { @@ -743,9 +786,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( @@ -760,8 +803,21 @@ 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) { + html = html.replace( + headOpenRegExp, + (match) => match + headStart.join(''), + ); + } + + // 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/spec/post-process-html.spec.js b/spec/post-process-html.spec.js new file mode 100644 index 00000000..3645b8c1 --- /dev/null +++ b/spec/post-process-html.spec.js @@ -0,0 +1,119 @@ +/* + * Unit tests for automatic tag injection during HTML post processing + */ + +/* eslint-env jest */ +'use strict'; + +const HtmlRspackPlugin = require('../lib/index.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('keeps a charset authored by the template without injecting a duplicate', async () => { + const templateCharset = ''; + const html = `${templateCharset}`; + const output = await postProcessHtml(html, [ + createTag('meta', { charset: 'UTF-8' }), + createTag('script', { src: 'main.js' }), + ]); + + expect(output).toContain(templateCharset); + expect(output.match(/]*\bcharset\s*=/gi)).toHaveLength(1); + expect(output).toContain(''); + }); + + it('does not mistake charset text in another attribute for a declaration', async () => { + const html = + ''; + const output = await postProcessHtml(html, [ + createTag('meta', { charset: 'UTF-8' }), + ]); + + expect(output).toContain( + '', + ); + }); + + it('recognizes and deduplicates case-insensitive charset tags in the final head tag list', async () => { + const output = await postProcessHtml( + '', + [ + createTag('META', { CHARSET: 'UTF-8' }), + createTag('meta', { charset: 'iso-8859-1' }), + ], + ); + + expect(output).toContain(''); + expect(output.match(/]*\bcharset\s*=/gi)).toHaveLength(1); + }); + + 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( + '', + ); + }); + + it('does not inject a generated charset when injection is disabled', async () => { + const html = ''; + const output = await postProcessHtml( + html, + [createTag('meta', { charset: 'UTF-8' })], + { inject: false }, + ); + + expect(output).toBe(html); + }); +}); From 8d33b389e325a3a5df792ce8a33aae7e18b3eece Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 18 Aug 2026 14:54:52 +0800 Subject: [PATCH 2/3] fix: simplify charset tag injection --- README.md | 2 +- lib/index.js | 87 ++++++++++++++----- .../post-process-html.test.js | 56 +++--------- 3 files changed, 79 insertions(+), 66 deletions(-) rename spec/post-process-html.spec.js => tests/post-process-html.test.js (54%) diff --git a/README.md b/README.md index 5adadd38..47e5ceac 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ new HtmlRspackPlugin({ }); ``` -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. If the template already contains a `charset` meta tag, the generated declaration is omitted. +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 diff --git a/lib/index.js b/lib/index.js index ee7de6a0..b1713a7c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -35,19 +35,64 @@ function isCharsetMetaTag(tag) { } /** - * Returns whether the given html already contains a charset meta tag. - * Quoted attribute values are ignored to avoid treating content such as - * `content="charset=utf-8"` as a charset declaration. + * Returns the end offset of the opening head tag. * * @param {string} html - * @returns {boolean} + * @returns {number} */ -function hasCharsetMetaTag(html) { - const metaTags = html.match(/]*>/gi) || []; +function findHeadTagEnd(html) { + let index = 0; - return metaTags.some((metaTag) => - /\scharset\s*=/i.test(metaTag.replace(/"[^"]*"|'[^']*'/g, '')), - ); + while (index < html.length) { + const tagStart = html.indexOf('<', index); + if (tagStart === -1) { + return -1; + } + + if (html.startsWith('', tagStart + 4); + if (commentEnd === -1) { + return -1; + } + + index = commentEnd + 3; + continue; + } + + let quote; + let tagEnd = tagStart + 1; + + for (; tagEnd < html.length; tagEnd++) { + const character = html[tagEnd]; + + if (quote) { + if (character === quote) { + quote = undefined; + } + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + break; + } + } + + if (tagEnd === html.length) { + return -1; + } + + let tagNameEnd = tagStart + 1; + while (tagNameEnd < tagEnd && !' \t\n\f\r/>'.includes(html[tagNameEnd])) { + tagNameEnd++; + } + + if (html.slice(tagStart + 1, tagNameEnd).toLowerCase() === 'head') { + return tagEnd + 1; + } + + index = tagEnd + 1; + } + + return -1; } /** @typedef {import("../typings.js").HtmlTagObject} HtmlTagObject */ @@ -742,7 +787,6 @@ class HtmlRspackPlugin { if (this.options.inject) { const htmlRegExp = /(]*>)/i; - const headOpenRegExp = /(]*)?>)/i; const headCloseRegExp = /(<\/head\s*>)/i; const bodyRegExp = /(<\/body\s*>)/i; const doctypeRegExp = //i; @@ -753,17 +797,12 @@ class HtmlRspackPlugin { ); const headStart = []; const headEnd = []; - let hasCharset = hasCharsetMetaTag(html); 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. Keep a charset - // authored by the template and ignore additional generated declarations. - if (!hasCharset) { - headStart.push(htmlTagObjectToString(item, this.options.xhtml)); - hasCharset = true; - } + // of the document, so inject it at the start of the head. + headStart.push(htmlTagObjectToString(item, this.options.xhtml)); continue; } @@ -808,10 +847,16 @@ class HtmlRspackPlugin { // A charset declaration must be as early as possible in the document. if (headStart.length) { - html = html.replace( - headOpenRegExp, - (match) => match + headStart.join(''), - ); + 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. diff --git a/spec/post-process-html.spec.js b/tests/post-process-html.test.js similarity index 54% rename from spec/post-process-html.spec.js rename to tests/post-process-html.test.js index 3645b8c1..d82b6af7 100644 --- a/spec/post-process-html.spec.js +++ b/tests/post-process-html.test.js @@ -1,11 +1,6 @@ -/* - * Unit tests for automatic tag injection during HTML post processing - */ - -/* eslint-env jest */ -'use strict'; - -const HtmlRspackPlugin = require('../lib/index.js'); +import { Buffer } from 'node:buffer'; +import { describe, expect, it } from '@rstest/core'; +import { HtmlRspackPlugin } from './helpers/compile.js'; const compiler = { options: { @@ -54,42 +49,26 @@ describe('HtmlRspackPlugin HTML post processing', () => { ).toBeLessThanOrEqual(1024); }); - it('keeps a charset authored by the template without injecting a duplicate', async () => { - const templateCharset = ''; - const html = `${templateCharset}`; + it('ignores head tags inside comments', async () => { + const html = ''; const output = await postProcessHtml(html, [ createTag('meta', { charset: 'UTF-8' }), - createTag('script', { src: 'main.js' }), ]); - expect(output).toContain(templateCharset); - expect(output.match(/]*\bcharset\s*=/gi)).toHaveLength(1); - expect(output).toContain(''); + expect(output).toBe( + '', + ); }); - it('does not mistake charset text in another attribute for a declaration', async () => { - const html = - ''; + it('handles greater-than signs inside head attributes', async () => { + const html = ''; const output = await postProcessHtml(html, [ createTag('meta', { charset: 'UTF-8' }), ]); - expect(output).toContain( - '', - ); - }); - - it('recognizes and deduplicates case-insensitive charset tags in the final head tag list', async () => { - const output = await postProcessHtml( - '', - [ - createTag('META', { CHARSET: 'UTF-8' }), - createTag('meta', { charset: 'iso-8859-1' }), - ], + expect(output).toBe( + '', ); - - expect(output).toContain(''); - expect(output.match(/]*\bcharset\s*=/gi)).toHaveLength(1); }); it('creates a head before injecting a generated charset', async () => { @@ -105,15 +84,4 @@ describe('HtmlRspackPlugin HTML post processing', () => { '', ); }); - - it('does not inject a generated charset when injection is disabled', async () => { - const html = ''; - const output = await postProcessHtml( - html, - [createTag('meta', { charset: 'UTF-8' })], - { inject: false }, - ); - - expect(output).toBe(html); - }); }); From 74a6c13fd87588f4ab621543fdfbba8a115d4dd1 Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 18 Aug 2026 15:10:33 +0800 Subject: [PATCH 3/3] refactor: simplify head tag lookup --- lib/index.js | 58 ++++++++------------------------- tests/post-process-html.test.js | 11 ------- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/lib/index.js b/lib/index.js index b1713a7c..41b9bb4a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -41,55 +41,25 @@ function isCharsetMetaTag(tag) { * @returns {number} */ function findHeadTagEnd(html) { - let index = 0; - - while (index < html.length) { - const tagStart = html.indexOf('<', index); - if (tagStart === -1) { - return -1; - } - - if (html.startsWith('', tagStart + 4); - if (commentEnd === -1) { - return -1; - } - - index = commentEnd + 3; - continue; - } + const headTagStart = html.search(/])/i); + if (headTagStart === -1) { + return -1; + } - let quote; - let tagEnd = tagStart + 1; + let quote; - for (; tagEnd < html.length; tagEnd++) { - const character = html[tagEnd]; + 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 === '>') { - break; + if (quote) { + if (character === quote) { + quote = undefined; } + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + return index + 1; } - - if (tagEnd === html.length) { - return -1; - } - - let tagNameEnd = tagStart + 1; - while (tagNameEnd < tagEnd && !' \t\n\f\r/>'.includes(html[tagNameEnd])) { - tagNameEnd++; - } - - if (html.slice(tagStart + 1, tagNameEnd).toLowerCase() === 'head') { - return tagEnd + 1; - } - - index = tagEnd + 1; } return -1; diff --git a/tests/post-process-html.test.js b/tests/post-process-html.test.js index d82b6af7..719f8bd3 100644 --- a/tests/post-process-html.test.js +++ b/tests/post-process-html.test.js @@ -49,17 +49,6 @@ describe('HtmlRspackPlugin HTML post processing', () => { ).toBeLessThanOrEqual(1024); }); - it('ignores head tags inside comments', async () => { - const html = ''; - const output = await postProcessHtml(html, [ - createTag('meta', { charset: 'UTF-8' }), - ]); - - expect(output).toBe( - '', - ); - }); - it('handles greater-than signs inside head attributes', async () => { const html = ''; const output = await postProcessHtml(html, [