Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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.
Expand Down
111 changes: 91 additions & 20 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,52 @@

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(/<head(?=[\s>])/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 */
Expand Down Expand Up @@ -711,30 +757,36 @@

if (this.options.inject) {
const htmlRegExp = /(<html[^>]*>)/i;
const headRegExp = /(<\/head\s*>)/i;
const headCloseRegExp = /(<\/head\s*>)/i;
const bodyRegExp = /(<\/body\s*>)/i;
const doctypeRegExp = /<!doctype html>/i;

const metaViewportRegExp = /<meta[^>]+name=["']viewport["'][^>]*>/i;
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));
Comment on lines +772 to +775

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve an authored charset before prepending another

When the template already contains an active charset declaration and meta generates another with a different value, this loop unconditionally places the generated declaration before the authored one. Previously the generated tag was appended near </head>, so the template's earlier declaration retained precedence; the new order changes the browser's effective encoding and leaves conflicting declarations. Detect an active template charset and avoid prepending another one.

Useful? React with 👍 / 👎.

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)) {
Expand All @@ -746,9 +798,9 @@
}
}

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(
Expand All @@ -763,8 +815,27 @@
}
}

// 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,
);
}
}
}

Expand Down
76 changes: 76 additions & 0 deletions tests/post-process-html.test.js
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><html><head>${longHeadContent}</head><body></body></html>`;
const output = await postProcessHtml(html, [
createTag('meta', { charset: 'UTF-8' }),
createTag('script', { src: 'main.js' }),
]);
const charsetMatch = /<meta charset="UTF-8">/.exec(output);

expect(charsetMatch).not.toBeNull();
expect(output).toContain('<head><meta charset="UTF-8">');
expect(output).toContain('<script src="main.js"></script></head>');
expect(
Buffer.byteLength(
output.slice(0, charsetMatch.index + charsetMatch[0].length),
),
).toBeLessThanOrEqual(1024);
});

it('handles greater-than signs inside head attributes', async () => {
const html = '<html><head data-value=">"></head><body></body></html>';
const output = await postProcessHtml(html, [
createTag('meta', { charset: 'UTF-8' }),
]);

expect(output).toBe(
'<html><head data-value=">"><meta charset="UTF-8"></head><body></body></html>',
);
});

it('creates a head before injecting a generated charset', async () => {
const output = await postProcessHtml(
'<!doctype html><html><body></body></html>',
[
createTag('meta', { charset: 'UTF-8' }),
createTag('script', { src: 'main.js' }),
],
);

expect(output).toBe(
'<!doctype html><html><head><meta charset="UTF-8"><script src="main.js"></script></head><body></body></html>',
);
});
});