diff --git a/.changeset/dirty-cooks-explode.md b/.changeset/dirty-cooks-explode.md
new file mode 100644
index 000000000000..770ef17999d9
--- /dev/null
+++ b/.changeset/dirty-cooks-explode.md
@@ -0,0 +1,9 @@
+---
+'@astrojs/mdx': major
+---
+
+Handles the breaking change in Astro where content pages (including `.mdx` pages located within `src/pages/`) no longer respond with `charset=utf-8` in the `Content-Type` header.
+
+For MDX pages without layouts, `@astrojs/mdx` will automatically add the `` tag to the page by default. This reduces the boilerplate needed to write with non-ASCII characters. If your MDX pages have a layout, the layout component should include the `` tag.
+
+If you require `charset=utf-8` to render your page correctly, make sure that your layout components have the `` tag added.
diff --git a/.changeset/strong-months-grab.md b/.changeset/strong-months-grab.md
new file mode 100644
index 000000000000..193031f185c9
--- /dev/null
+++ b/.changeset/strong-months-grab.md
@@ -0,0 +1,11 @@
+---
+'astro': major
+---
+
+Updates the automatic `charset=utf-8` behavior for Markdown pages, where instead of responding with `charset=utf-8` in the `Content-Type` header, Astro will now automatically add the `` tag instead.
+
+This behaviour only applies to Markdown pages (`.md` or similar Markdown files located within `src/pages/`) that do not use Astro's special `layout` frontmatter property. It matches the rendering behaviour of other non-content pages, and retains the minimal boilerplate needed to write with non-ASCII characters when adding individual Markdown pages to your site.
+
+If your Markdown pages use the `layout` frontmatter property, then HTML encoding will be handled by the designated layout component instead, and the `` tag will not be added to your page by default.
+
+If you require `charset=utf-8` to render your page correctly, make sure that your layout components contain the `` tag. You may need to add this if you have not already done so.
diff --git a/packages/astro/src/core/preview/vite-plugin-astro-preview.ts b/packages/astro/src/core/preview/vite-plugin-astro-preview.ts
index 992f82f143dc..a8043192dfc9 100644
--- a/packages/astro/src/core/preview/vite-plugin-astro-preview.ts
+++ b/packages/astro/src/core/preview/vite-plugin-astro-preview.ts
@@ -16,7 +16,7 @@ export function vitePluginAstroPreview(settings: AstroSettings): Plugin {
const errorPagePath = fileURLToPath(outDir + '/404.html');
if (fs.existsSync(errorPagePath)) {
res.statusCode = 404;
- res.setHeader('Content-Type', 'text/html;charset=utf-8');
+ res.setHeader('Content-Type', 'text/html');
res.end(fs.readFileSync(errorPagePath));
} else {
res.statusCode = 404;
diff --git a/packages/astro/src/core/routing/astro-designed-error-pages.ts b/packages/astro/src/core/routing/astro-designed-error-pages.ts
index 2c1c1f77c5a3..671221b5d68a 100644
--- a/packages/astro/src/core/routing/astro-designed-error-pages.ts
+++ b/packages/astro/src/core/routing/astro-designed-error-pages.ts
@@ -46,7 +46,7 @@ async function default404Page({ pathname }: { pathname: string }) {
tabTitle: '404: Not Found',
pathname,
}),
- { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
+ { status: 404, headers: { 'Content-Type': 'text/html' } },
);
}
// mark the function as an AstroComponentFactory for the rendering internals
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
index 0e0bcf295ad8..5ac05f741347 100644
--- a/packages/astro/src/runtime/server/render/page.ts
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -35,7 +35,7 @@ export async function renderPage(
return new Response(bytes, {
headers: new Headers([
- ['Content-Type', 'text/html; charset=utf-8'],
+ ['Content-Type', 'text/html'],
['Content-Length', bytes.byteLength.toString()],
]),
});
@@ -80,11 +80,6 @@ export async function renderPage(
body = encoder.encode(body);
headers.set('Content-Length', body.byteLength.toString());
}
- // TODO: Revisit if user should manually set charset by themselves in Astro 4
- // This code preserves the existing behaviour for markdown pages since Astro 2
- if (route?.component.endsWith('.md')) {
- headers.set('Content-Type', 'text/html; charset=utf-8');
- }
let status = init.status;
// Custom 404.astro and 500.astro are particular routes that must return a fixed status code
if (route?.route === '/404') {
diff --git a/packages/astro/src/vite-plugin-astro-server/response.ts b/packages/astro/src/vite-plugin-astro-server/response.ts
index a6257508ee6d..ef3c8247aac9 100644
--- a/packages/astro/src/vite-plugin-astro-server/response.ts
+++ b/packages/astro/src/vite-plugin-astro-server/response.ts
@@ -46,7 +46,7 @@ export async function handle500Response(
export function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) {
res.writeHead(statusCode, {
- 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html, 'utf-8'),
});
res.write(html);
diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts
index b728ffb7048d..9b7c80330a05 100644
--- a/packages/astro/src/vite-plugin-markdown/index.ts
+++ b/packages/astro/src/vite-plugin-markdown/index.ts
@@ -10,7 +10,7 @@ import { normalizePath } from 'vite';
import { safeParseFrontmatter } from '../content/utils.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { Logger } from '../core/logger/core.js';
-import { isMarkdownFile } from '../core/util.js';
+import { isMarkdownFile, isPage } from '../core/util.js';
import { shorthash } from '../runtime/server/shorthash.js';
import type { AstroSettings } from '../types/astro.js';
import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js';
@@ -77,6 +77,10 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
let html = renderResult.code;
const { headings, imagePaths: rawImagePaths, frontmatter } = renderResult.metadata;
+ // Add default charset for markdown pages
+ const isMarkdownPage = isPage(fileURL, settings);
+ const charset = isMarkdownPage ? '' : '';
+
// Resolve all the extracted images from the content
const imagePaths: MarkdownImagePath[] = [];
for (const imagePath of rawImagePaths) {
@@ -141,7 +145,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
}, {
'default': () => render\`\${unescapeHTML(html())}\`
})}\`;`
- : `render\`\${maybeRenderHead(result)}\${unescapeHTML(html())}\`;`
+ : `render\`${charset}\${maybeRenderHead(result)}\${unescapeHTML(html())}\`;`
}
});
export default Content;
diff --git a/packages/astro/test/astro-basic.test.js b/packages/astro/test/astro-basic.test.js
index 0fc87dd6068e..144eec810125 100644
--- a/packages/astro/test/astro-basic.test.js
+++ b/packages/astro/test/astro-basic.test.js
@@ -115,12 +115,7 @@ describe('Astro basic build', () => {
const html = await fixture.readFile('/chinese-encoding-md/index.html');
const $ = cheerio.load(html);
assert.equal($('h1').text(), '我的第一篇博客文章');
- });
-
- it('renders MDX in utf-8 by default', async () => {
- const html = await fixture.readFile('/chinese-encoding-mdx/index.html');
- const $ = cheerio.load(html);
- assert.equal($('h1').text(), '我的第一篇博客文章');
+ assert.match(html, / {
@@ -207,22 +202,8 @@ describe('Astro basic development', () => {
const html = await res.text();
const $ = cheerio.load(html);
assert.equal($('h1').text(), '我的第一篇博客文章');
- const isUtf8 =
- res.headers.get('content-type').includes('charset=utf-8') ||
- html.includes('');
- assert.ok(isUtf8);
- });
-
- it('Renders MDX in utf-8 by default', async () => {
- const res = await fixture.fetch('/chinese-encoding-mdx');
- assert.equal(res.status, 200);
- const html = await res.text();
- const $ = cheerio.load(html);
- assert.equal($('h1').text(), '我的第一篇博客文章');
- const isUtf8 =
- res.headers.get('content-type').includes('charset=utf-8') ||
- html.includes('');
- assert.ok(isUtf8);
+ assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/);
+ assert.match(html, / {
diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts
index dcb13bc62f70..fb6766e5f721 100644
--- a/packages/integrations/mdx/src/index.ts
+++ b/packages/integrations/mdx/src/index.ts
@@ -13,7 +13,7 @@ import type { PluggableList } from 'unified';
import type { OptimizeOptions } from './rehype-optimize-static.js';
import { ignoreStringPlugins, safeParseFrontmatter } from './utils.js';
import { vitePluginMdxPostprocess } from './vite-plugin-mdx-postprocess.js';
-import { vitePluginMdx } from './vite-plugin-mdx.js';
+import { type VitePluginMdxOptions, vitePluginMdx } from './vite-plugin-mdx.js';
export type MdxOptions = Omit & {
extendMarkdownConfig: boolean;
@@ -43,7 +43,7 @@ export function getContainerRenderer(): ContainerRenderer {
export default function mdx(partialMdxOptions: Partial = {}): AstroIntegration {
// @ts-expect-error Temporarily assign an empty object here, which will be re-assigned by the
// `astro:config:done` hook later. This is so that `vitePluginMdx` can get hold of a reference earlier.
- let mdxOptions: MdxOptions = {};
+ let vitePluginMdxOptions: VitePluginMdxOptions = {};
return {
name: '@astrojs/mdx',
@@ -79,7 +79,7 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI
updateConfig({
vite: {
- plugins: [vitePluginMdx(mdxOptions), vitePluginMdxPostprocess(config)],
+ plugins: [vitePluginMdx(vitePluginMdxOptions), vitePluginMdxPostprocess(config)],
},
});
},
@@ -98,10 +98,13 @@ export default function mdx(partialMdxOptions: Partial = {}): AstroI
});
// Mutate `mdxOptions` so that `vitePluginMdx` can reference the actual options
- Object.assign(mdxOptions, resolvedMdxOptions);
+ Object.assign(vitePluginMdxOptions, {
+ mdxOptions: resolvedMdxOptions,
+ srcDir: config.srcDir,
+ });
// @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore.
// Re-assign it so that the garbage can be collected later.
- mdxOptions = {};
+ vitePluginMdxOptions = {};
},
},
};
diff --git a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts b/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
index cc1f4d141bc4..5880c30b318b 100644
--- a/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
+++ b/packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
@@ -1,9 +1,23 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import { isFrontmatterValid } from '@astrojs/markdown-remark';
+import type { Root, RootContent } from 'hast';
import type { VFile } from 'vfile';
import { jsToTreeNode } from './utils.js';
+// Passed metadata to help determine adding charset utf8 by default
+declare module 'vfile' {
+ interface DataMap {
+ applyFrontmatterExport?: {
+ srcDir?: URL;
+ };
+ }
+}
+
+const exportConstPartialTrueRe = /export\s+const\s+partial\s*=\s*true/;
+
export function rehypeApplyFrontmatterExport() {
- return function (tree: any, vfile: VFile) {
+ return function (tree: Root, vfile: VFile) {
const frontmatter = vfile.data.astro?.frontmatter;
if (!frontmatter || !isFrontmatterValid(frontmatter))
throw new Error(
@@ -11,11 +25,11 @@ export function rehypeApplyFrontmatterExport() {
// TODO: find way to import error data from core
'[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.',
);
- const exportNodes = [
+ const extraChildren: RootContent[] = [
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
];
if (frontmatter.layout) {
- exportNodes.unshift(
+ extraChildren.unshift(
jsToTreeNode(
// NOTE: Use `__astro_*` import names to prevent conflicts with user code
/** @see 'vite-plugin-markdown' for layout props reference */
@@ -39,7 +53,61 @@ export default function ({ children }) {
};`,
),
);
+ } else if (shouldAddCharset(tree, vfile)) {
+ extraChildren.unshift({
+ type: 'mdxJsxFlowElement',
+ name: 'meta',
+ attributes: [
+ {
+ type: 'mdxJsxAttribute',
+ name: 'charset',
+ value: 'utf-8',
+ },
+ ],
+ children: [],
+ });
}
- tree.children = exportNodes.concat(tree.children);
+ tree.children = extraChildren.concat(tree.children);
};
}
+
+/**
+ * If this is a page (e.g. in src/pages), has no layout frontmatter (handled before calling this function),
+ * has no leading component that looks like a wrapping layout, and `partial` isn't set to true, we default to
+ * adding charset=utf-8 like markdown so that users don't have to worry about it for MDX pages without layouts.
+ */
+function shouldAddCharset(tree: Root, vfile: VFile) {
+ const srcDirUrl = vfile.data.applyFrontmatterExport?.srcDir;
+ if (!srcDirUrl) return false;
+
+ const hasConstPartialTrue = tree.children.some(
+ (node) => node.type === 'mdxjsEsm' && exportConstPartialTrueRe.test(node.value),
+ );
+ if (hasConstPartialTrue) return false;
+
+ // NOTE: the pages directory is a non-configurable Astro behaviour
+ const pagesDir = path.join(fileURLToPath(srcDirUrl), 'pages').replace(/\\/g, '/');
+ // `vfile.path` comes from Vite, which is a normalized path (no backslashes)
+ const filePath = vfile.path;
+ if (!filePath.startsWith(pagesDir)) return false;
+
+ const hasLeadingUnderscoreInPath = filePath
+ .slice(pagesDir.length)
+ .replace(/\\/g, '/')
+ .split('/')
+ .some((part) => part.startsWith('_'));
+ if (hasLeadingUnderscoreInPath) return false;
+
+ // Bail if the first content found is a wrapping layout component
+ for (const child of tree.children) {
+ if (child.type === 'element') break;
+ if (child.type === 'mdxJsxFlowElement') {
+ // If is fragment or lowercase tag name (html tags), skip and assume there's no layout
+ if (child.name == null) break;
+ if (child.name[0] === child.name[0].toLowerCase()) break;
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts
index eea530c1c31b..869c65d26502 100644
--- a/packages/integrations/mdx/src/vite-plugin-mdx.ts
+++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts
@@ -6,7 +6,13 @@ import type { MdxOptions } from './index.js';
import { createMdxProcessor } from './plugins.js';
import { safeParseFrontmatter } from './utils.js';
-export function vitePluginMdx(mdxOptions: MdxOptions): Plugin {
+export interface VitePluginMdxOptions {
+ mdxOptions: MdxOptions;
+ srcDir: URL;
+}
+
+// NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later
+export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin {
let processor: ReturnType | undefined;
let sourcemapEnabled: boolean;
@@ -47,12 +53,15 @@ export function vitePluginMdx(mdxOptions: MdxOptions): Plugin {
astro: {
frontmatter,
},
+ applyFrontmatterExport: {
+ srcDir: opts.srcDir,
+ },
},
});
// Lazily initialize the MDX processor
if (!processor) {
- processor = createMdxProcessor(mdxOptions, { sourcemap: sourcemapEnabled });
+ processor = createMdxProcessor(opts.mdxOptions, { sourcemap: sourcemapEnabled });
}
try {
diff --git a/packages/integrations/mdx/test/fixtures/mdx-page/src/layouts/EncodingLayout.astro b/packages/integrations/mdx/test/fixtures/mdx-page/src/layouts/EncodingLayout.astro
new file mode 100644
index 000000000000..13e0e91ed627
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-page/src/layouts/EncodingLayout.astro
@@ -0,0 +1 @@
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-frontmatter.mdx b/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-frontmatter.mdx
new file mode 100644
index 000000000000..471827de01a9
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-frontmatter.mdx
@@ -0,0 +1,7 @@
+---
+layout: ../layouts/EncodingLayout.astro
+---
+
+# 我的第一篇博客文章
+
+发表于:2022-07-01
diff --git a/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-manual.mdx b/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-manual.mdx
new file mode 100644
index 000000000000..1c8c78630788
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding-layout-manual.mdx
@@ -0,0 +1,12 @@
+import EncodingLayout from '../layouts/EncodingLayout.astro'
+
+{/* Ensure random stuff preceding the wrapper layout is ignored when detecting a wrapper layout */}
+export const foo = {}
+
+
+
+# 我的第一篇博客文章
+
+发表于:2022-07-01
+
+
diff --git a/packages/astro/test/fixtures/astro-basic/src/pages/chinese-encoding-mdx.mdx b/packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding.mdx
similarity index 100%
rename from packages/astro/test/fixtures/astro-basic/src/pages/chinese-encoding-mdx.mdx
rename to packages/integrations/mdx/test/fixtures/mdx-page/src/pages/chinese-encoding.mdx
diff --git a/packages/integrations/mdx/test/mdx-page.test.js b/packages/integrations/mdx/test/mdx-page.test.js
index 7948de65312a..b58781efca21 100644
--- a/packages/integrations/mdx/test/mdx-page.test.js
+++ b/packages/integrations/mdx/test/mdx-page.test.js
@@ -1,5 +1,6 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
import { parseHTML } from 'linkedom';
import { loadFixture } from '../../../astro/test/test-utils.js';
@@ -36,6 +37,23 @@ describe('MDX Page', () => {
assert.notEqual(stylesheet, null);
});
+
+ it('Renders MDX in utf-8 by default', async () => {
+ const html = await fixture.readFile('/chinese-encoding/index.html');
+ const $ = cheerio.load(html);
+ assert.equal($('h1').text(), '我的第一篇博客文章');
+ assert.match(html, / {
+ const html = await fixture.readFile('/chinese-encoding-layout-frontmatter/index.html');
+ assert.doesNotMatch(html, / {
+ const html = await fixture.readFile('/chinese-encoding-layout-manual/index.html');
+ assert.doesNotMatch(html, / {
@@ -61,5 +79,31 @@ describe('MDX Page', () => {
assert.equal(h1.textContent, 'Hello page!');
});
+
+ it('Renders MDX in utf-8 by default', async () => {
+ const res = await fixture.fetch('/chinese-encoding/');
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ assert.equal($('h1').text(), '我的第一篇博客文章');
+ assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/);
+ assert.match(html, / {
+ const res = await fixture.fetch('/chinese-encoding-layout-frontmatter/');
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/);
+ assert.doesNotMatch(html, / {
+ const res = await fixture.fetch('/chinese-encoding-layout-manual/');
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/);
+ assert.doesNotMatch(html, /