Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unset charset=utf-8 content-type for md/mdx pages #12231

Merged
merged 10 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
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.
bluwy marked this conversation as resolved.
Show resolved Hide resolved

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.
bluwy marked this conversation as resolved.
Show resolved Hide resolved

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
Loading