Skip to content

Commit

Permalink
Unset charset=utf-8 content-type for md/mdx pages (#12231)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
bluwy and sarah11918 authored Oct 24, 2024
1 parent 6469518 commit 90ae100
Show file tree
Hide file tree
Showing 16 changed files with 188 additions and 44 deletions.
9 changes: 9 additions & 0 deletions .changeset/dirty-cooks-explode.md
Original file line number Diff line number Diff line change
@@ -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 `<meta charset="utf-8">` 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 `<meta charset="utf-8">` tag.

If you require `charset=utf-8` to render your page correctly, make sure that your layout components have the `<meta charset="utf-8">` tag added.
11 changes: 11 additions & 0 deletions .changeset/strong-months-grab.md
Original file line number Diff line number Diff line change
@@ -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 `<meta charset="utf-8">` 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 `<meta charset="utf-8">` 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 `<meta charset="utf-8">` tag. You may need to add this if you have not already done so.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
]),
});
Expand Down Expand Up @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/vite-plugin-astro-server/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions packages/astro/src/vite-plugin-markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ? '<meta charset="utf-8">' : '';

// Resolve all the extracted images from the content
const imagePaths: MarkdownImagePath[] = [];
for (const imagePath of rawImagePaths) {
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 3 additions & 22 deletions packages/astro/test/astro-basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<meta charset="utf-8"/);
});

it('Supports void elements whose name is a string (#2062)', async () => {
Expand Down Expand Up @@ -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('<meta charset="utf-8">');
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('<meta charset="utf-8">');
assert.ok(isUtf8);
assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/);
assert.match(html, /<meta charset="utf-8"/);
});

it('Handles importing .astro?raw correctly', async () => {
Expand Down
13 changes: 8 additions & 5 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof markdownConfigDefaults, 'remarkPlugins' | 'rehypePlugins'> & {
extendMarkdownConfig: boolean;
Expand Down Expand Up @@ -43,7 +43,7 @@ export function getContainerRenderer(): ContainerRenderer {
export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): 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',
Expand Down Expand Up @@ -79,7 +79,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI

updateConfig({
vite: {
plugins: [vitePluginMdx(mdxOptions), vitePluginMdxPostprocess(config)],
plugins: [vitePluginMdx(vitePluginMdxOptions), vitePluginMdxPostprocess(config)],
},
});
},
Expand All @@ -98,10 +98,13 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): 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 = {};
},
},
};
Expand Down
76 changes: 72 additions & 4 deletions packages/integrations/mdx/src/rehype-apply-frontmatter-export.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
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(
// Copied from Astro core `errors-data`
// 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 */
Expand All @@ -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;
}
13 changes: 11 additions & 2 deletions packages/integrations/mdx/src/vite-plugin-mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMdxProcessor> | undefined;
let sourcemapEnabled: boolean;

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<slot></slot>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
layout: ../layouts/EncodingLayout.astro
---

# 我的第一篇博客文章

发表于:2022-07-01
Original file line number Diff line number Diff line change
@@ -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 = {}

<EncodingLayout>

# 我的第一篇博客文章

发表于:2022-07-01

</EncodingLayout>
Loading

0 comments on commit 90ae100

Please sign in to comment.