summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Chris Swithinbank <swithinbank@gmail.com> 2025-03-12 14:58:59 +0100
committerGravatar GitHub <noreply@github.com> 2025-03-12 13:58:59 +0000
commitcb886dcde6c28acca286a66be46228a4d4cc52e7 (patch)
treed00898192e6a7149698c2261c7194000d1e44a3d
parenta3327ffbe6373228339824684eaa6f340a20a32e (diff)
downloadastro-cb886dcde6c28acca286a66be46228a4d4cc52e7.tar.gz
astro-cb886dcde6c28acca286a66be46228a4d4cc52e7.tar.zst
astro-cb886dcde6c28acca286a66be46228a4d4cc52e7.zip
Add `experimental.headingIdCompat` flag (#13352)
* Add `experimental.headingIdCompat` option schema & types * Markdown and MDX support * Markdoc support * Add changeset * Fix missing argument in Markdoc integration * Improve JSDoc comment Co-authored-by: Matt Kane <m@mk.gg> * Refactor to avoid global context object in Markdoc * Minor changeset tweak * Make `rehypeHeadingIds()` argument optional for backwards compatibility * Add doc comment to `rehypeHeadingIds()` * Document rehype plugin usage in changeset --------- Co-authored-by: Matt Kane <m@mk.gg>
-rw-r--r--.changeset/good-toys-refuse.md45
-rw-r--r--packages/astro/src/core/config/schema.ts5
-rw-r--r--packages/astro/src/types/public/config.ts14
-rw-r--r--packages/astro/src/vite-plugin-markdown/index.ts1
-rw-r--r--packages/integrations/markdoc/src/content-entry-type.ts10
-rw-r--r--packages/integrations/markdoc/src/heading-ids.ts17
-rw-r--r--packages/integrations/markdoc/src/runtime.ts12
-rw-r--r--packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc3
-rw-r--r--packages/integrations/markdoc/test/headings.test.js41
-rw-r--r--packages/integrations/mdx/src/index.ts1
-rw-r--r--packages/integrations/mdx/src/plugins.ts13
-rw-r--r--packages/integrations/mdx/src/vite-plugin-mdx.ts6
-rw-r--r--packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx4
-rw-r--r--packages/integrations/mdx/test/mdx-get-headings.test.js51
-rw-r--r--packages/markdown/remark/src/index.ts3
-rw-r--r--packages/markdown/remark/src/rehype-collect-headings.ts15
-rw-r--r--packages/markdown/remark/src/types.ts1
17 files changed, 225 insertions, 17 deletions
diff --git a/.changeset/good-toys-refuse.md b/.changeset/good-toys-refuse.md
new file mode 100644
index 000000000..2705bcfa8
--- /dev/null
+++ b/.changeset/good-toys-refuse.md
@@ -0,0 +1,45 @@
+---
+'@astrojs/markdoc': minor
+'@astrojs/mdx': minor
+'@astrojs/markdown-remark': minor
+'astro': minor
+---
+
+Adds support for a new `experimental.headingIdCompat` flag
+
+By default, Astro removes a trailing `-` from the end of IDs it generates for headings ending with
+special characters. This differs from the behavior of common Markdown processors.
+
+You can now disable this behavior with a new configuration flag:
+
+```js
+// astro.config.mjs
+import { defineConfig } from "astro/config";
+
+export default defineConfig({
+ experimental: {
+ headingIdCompat: true,
+ },
+});
+```
+
+This can be useful when heading IDs and anchor links need to behave consistently across your site
+and other platforms such as GitHub and npm.
+
+If you are [using the `rehypeHeadingIds` plugin directly](https://docs.astro.build/en/guides/markdown-content/#heading-ids-and-plugins), you can also pass this new option:
+
+```js
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import { rehypeHeadingIds } from '@astrojs/markdown-remark';
+import { otherPluginThatReliesOnHeadingIDs } from 'some/plugin/source';
+
+export default defineConfig({
+ markdown: {
+ rehypePlugins: [
+ [rehypeHeadingIds, { experimentalHeadingIdCompat: true }],
+ otherPluginThatReliesOnHeadingIDs,
+ ],
+ },
+});
+```
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index a24713ba3..15729f399 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -101,6 +101,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
svg: false,
serializeConfig: false,
session: false,
+ headingIdCompat: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -616,6 +617,10 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.serializeConfig),
+ headingIdCompat: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.headingIdCompat),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`,
diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts
index 4ff49cc1c..178beb370 100644
--- a/packages/astro/src/types/public/config.ts
+++ b/packages/astro/src/types/public/config.ts
@@ -2166,6 +2166,20 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* These two virtual modules contain a serializable subset of the Astro configuration.
*/
serializeConfig?: boolean;
+
+ /**
+ * @name experimental.headingIdCompat
+ * @type {boolean}
+ * @default `false`
+ * @version 5.5.x
+ * @description
+ *
+ * Enables full compatibility of Markdown headings IDs with common platforms such as GitHub and npm.
+ *
+ * When enabled, IDs for headings ending with non-alphanumeric characters, e.g. `<Picture />`, will
+ * include a trailing `-`, matching standard behavior in other Markdown tooling.
+ */
+ headingIdCompat?: boolean;
};
}
diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts
index 9cd080a03..753da7ded 100644
--- a/packages/astro/src/vite-plugin-markdown/index.ts
+++ b/packages/astro/src/vite-plugin-markdown/index.ts
@@ -62,6 +62,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
if (!processor) {
processor = createMarkdownProcessor({
image: settings.config.image,
+ experimentalHeadingIdCompat: settings.config.experimental.headingIdCompat,
...settings.config.markdown,
});
}
diff --git a/packages/integrations/markdoc/src/content-entry-type.ts b/packages/integrations/markdoc/src/content-entry-type.ts
index 998d8fbb5..a41670a7b 100644
--- a/packages/integrations/markdoc/src/content-entry-type.ts
+++ b/packages/integrations/markdoc/src/content-entry-type.ts
@@ -49,7 +49,11 @@ export async function getContentEntryType({
const userMarkdocConfig = markdocConfigResult?.config ?? {};
const markdocConfigUrl = markdocConfigResult?.fileUrl;
const pluginContext = this;
- const markdocConfig = await setupConfig(userMarkdocConfig, options);
+ const markdocConfig = await setupConfig(
+ userMarkdocConfig,
+ options,
+ astroConfig.experimental.headingIdCompat,
+ );
const filePath = fileURLToPath(fileUrl);
raiseValidationErrors({
ast,
@@ -116,6 +120,7 @@ markdocConfig.nodes = { ...assetsConfig.nodes, ...markdocConfig.nodes };
${getStringifiedImports(componentConfigByTagMap, 'Tag', astroConfig.root)}
${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)}
+const experimentalHeadingIdCompat = ${JSON.stringify(astroConfig.experimental.headingIdCompat || false)}
const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')};
const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')};
@@ -126,7 +131,7 @@ const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast),
)};
-export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options);
+export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options, experimentalHeadingIdCompat);
export const Content = createContentComponent(
Renderer,
stringifiedAst,
@@ -134,6 +139,7 @@ export const Content = createContentComponent(
options,
tagComponentMap,
nodeComponentMap,
+ experimentalHeadingIdCompat,
)`;
return { code: res };
},
diff --git a/packages/integrations/markdoc/src/heading-ids.ts b/packages/integrations/markdoc/src/heading-ids.ts
index 9290a3db6..7242e0e16 100644
--- a/packages/integrations/markdoc/src/heading-ids.ts
+++ b/packages/integrations/markdoc/src/heading-ids.ts
@@ -11,6 +11,7 @@ function getSlug(
attributes: Record<string, any>,
children: RenderableTreeNode[],
headingSlugger: Slugger,
+ experimentalHeadingIdCompat: boolean,
): string {
if (attributes.id && typeof attributes.id === 'string') {
return attributes.id;
@@ -18,12 +19,14 @@ function getSlug(
const textContent = attributes.content ?? getTextContent(children);
let slug = headingSlugger.slug(textContent);
- if (slug.endsWith('-')) slug = slug.slice(0, -1);
+ if (!experimentalHeadingIdCompat) {
+ if (slug.endsWith('-')) slug = slug.slice(0, -1);
+ }
return slug;
}
type HeadingIdConfig = MarkdocConfig & {
- ctx: { headingSlugger: Slugger };
+ ctx: { headingSlugger: Slugger; experimentalHeadingIdCompat: boolean };
};
/*
@@ -47,7 +50,12 @@ export const heading: Schema = {
'Unexpected problem adding heading IDs to Markdoc file. Did you modify the `ctx.headingSlugger` property in your Markdoc config?',
});
}
- const slug = getSlug(attributes, children, config.ctx.headingSlugger);
+ const slug = getSlug(
+ attributes,
+ children,
+ config.ctx.headingSlugger,
+ config.ctx.experimentalHeadingIdCompat,
+ );
const render = config.nodes?.heading?.render ?? `h${level}`;
@@ -64,11 +72,12 @@ export const heading: Schema = {
};
// Called internally to ensure `ctx` is generated per-file, instead of per-build.
-export function setupHeadingConfig(): HeadingIdConfig {
+export function setupHeadingConfig(experimentalHeadingIdCompat: boolean): HeadingIdConfig {
const headingSlugger = new Slugger();
return {
ctx: {
headingSlugger,
+ experimentalHeadingIdCompat,
},
nodes: {
heading,
diff --git a/packages/integrations/markdoc/src/runtime.ts b/packages/integrations/markdoc/src/runtime.ts
index f62bcec1a..44c232b79 100644
--- a/packages/integrations/markdoc/src/runtime.ts
+++ b/packages/integrations/markdoc/src/runtime.ts
@@ -19,8 +19,9 @@ import type { MarkdocIntegrationOptions } from './options.js';
export async function setupConfig(
userConfig: AstroMarkdocConfig = {},
options: MarkdocIntegrationOptions | undefined,
+ experimentalHeadingIdCompat: boolean,
): Promise<MergedConfig> {
- let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
+ let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
if (userConfig.extends) {
for (let extension of userConfig.extends) {
@@ -45,8 +46,9 @@ export async function setupConfig(
export function setupConfigSync(
userConfig: AstroMarkdocConfig = {},
options: MarkdocIntegrationOptions | undefined,
+ experimentalHeadingIdCompat: boolean,
): MergedConfig {
- const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
+ const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
let merged = mergeConfig(defaultConfig, userConfig);
@@ -168,12 +170,13 @@ export function createGetHeadings(
stringifiedAst: string,
userConfig: AstroMarkdocConfig,
options: MarkdocIntegrationOptions | undefined,
+ experimentalHeadingIdCompat: boolean,
) {
return function getHeadings() {
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
- const config = setupConfigSync(userConfig, options);
+ const config = setupConfigSync(userConfig, options, experimentalHeadingIdCompat);
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast as Node, config as ConfigType);
let collectedHeadings: MarkdownHeading[] = [];
@@ -189,12 +192,13 @@ export function createContentComponent(
options: MarkdocIntegrationOptions | undefined,
tagComponentMap: Record<string, AstroInstance['default']>,
nodeComponentMap: Record<NodeType, AstroInstance['default']>,
+ experimentalHeadingIdCompat: boolean,
) {
return createComponent({
async factory(result: any, props: Record<string, any>) {
const withVariables = mergeConfig(userConfig, { variables: props });
const config = resolveComponentImports(
- await setupConfig(withVariables, options),
+ await setupConfig(withVariables, options, experimentalHeadingIdCompat),
tagComponentMap,
nodeComponentMap,
);
diff --git a/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc
new file mode 100644
index 000000000..2d1801014
--- /dev/null
+++ b/packages/integrations/markdoc/test/fixtures/headings/src/content/docs/headings-with-special-characters.mdoc
@@ -0,0 +1,3 @@
+## `<Picture />`
+
+### « Sacrebleu ! »
diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js
index 330d3356b..1a6061aa6 100644
--- a/packages/integrations/markdoc/test/headings.test.js
+++ b/packages/integrations/markdoc/test/headings.test.js
@@ -9,6 +9,38 @@ async function getFixture(name) {
});
}
+describe('experimental.headingIdCompat', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL(`./fixtures/headings/`, import.meta.url),
+ experimental: { headingIdCompat: true },
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('applies IDs to headings containing special characters', async () => {
+ const res = await fixture.fetch('/headings-with-special-characters');
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ assert.equal(document.querySelector('h2')?.id, 'picture-');
+ assert.equal(document.querySelector('h3')?.id, '-sacrebleu--');
+ });
+ });
+});
+
describe('Markdoc - Headings', () => {
let fixture;
@@ -35,6 +67,15 @@ describe('Markdoc - Headings', () => {
idTest(document);
});
+ it('applies IDs to headings containing special characters', async () => {
+ const res = await fixture.fetch('/headings-with-special-characters');
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ assert.equal(document.querySelector('h2')?.id, 'picture');
+ assert.equal(document.querySelector('h3')?.id, '-sacrebleu-');
+ });
+
it('generates the same IDs for other documents with the same headings', async () => {
const res = await fixture.fetch('/headings-stale-cache-check');
const html = await res.text();
diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts
index fd2fab8c8..fe2cbde0b 100644
--- a/packages/integrations/mdx/src/index.ts
+++ b/packages/integrations/mdx/src/index.ts
@@ -101,6 +101,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
Object.assign(vitePluginMdxOptions, {
mdxOptions: resolvedMdxOptions,
srcDir: config.srcDir,
+ experimentalHeadingIdCompat: config.experimental.headingIdCompat,
});
// @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.
diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts
index e1640238f..c44c8fffd 100644
--- a/packages/integrations/mdx/src/plugins.ts
+++ b/packages/integrations/mdx/src/plugins.ts
@@ -23,12 +23,13 @@ const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK);
interface MdxProcessorExtraOptions {
sourcemap: boolean;
+ experimentalHeadingIdCompat: boolean;
}
export function createMdxProcessor(mdxOptions: MdxOptions, extraOptions: MdxProcessorExtraOptions) {
return createProcessor({
remarkPlugins: getRemarkPlugins(mdxOptions),
- rehypePlugins: getRehypePlugins(mdxOptions),
+ rehypePlugins: getRehypePlugins(mdxOptions, extraOptions),
recmaPlugins: mdxOptions.recmaPlugins,
remarkRehypeOptions: mdxOptions.remarkRehype,
jsxImportSource: 'astro',
@@ -57,7 +58,10 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
return remarkPlugins;
}
-function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
+function getRehypePlugins(
+ mdxOptions: MdxOptions,
+ { experimentalHeadingIdCompat }: MdxProcessorExtraOptions,
+): PluggableList {
let rehypePlugins: PluggableList = [
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
rehypeMetaString,
@@ -84,7 +88,10 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
if (!isPerformanceBenchmark) {
// getHeadings() is guaranteed by TS, so this must be included.
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
- rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport);
+ rehypePlugins.push(
+ [rehypeHeadingIds, { experimentalHeadingIdCompat }],
+ rehypeInjectHeadingsExport,
+ );
}
rehypePlugins.push(
diff --git a/packages/integrations/mdx/src/vite-plugin-mdx.ts b/packages/integrations/mdx/src/vite-plugin-mdx.ts
index 869c65d26..7dda9d714 100644
--- a/packages/integrations/mdx/src/vite-plugin-mdx.ts
+++ b/packages/integrations/mdx/src/vite-plugin-mdx.ts
@@ -9,6 +9,7 @@ import { safeParseFrontmatter } from './utils.js';
export interface VitePluginMdxOptions {
mdxOptions: MdxOptions;
srcDir: URL;
+ experimentalHeadingIdCompat: boolean;
}
// NOTE: Do not destructure `opts` as we're assigning a reference that will be mutated later
@@ -61,7 +62,10 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin {
// Lazily initialize the MDX processor
if (!processor) {
- processor = createMdxProcessor(opts.mdxOptions, { sourcemap: sourcemapEnabled });
+ processor = createMdxProcessor(opts.mdxOptions, {
+ sourcemap: sourcemapEnabled,
+ experimentalHeadingIdCompat: opts.experimentalHeadingIdCompat,
+ });
}
try {
diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx
index 2bf3677cf..97f1dd37e 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx
+++ b/packages/integrations/mdx/test/fixtures/mdx-get-headings/src/pages/test.mdx
@@ -7,3 +7,7 @@
### Subsection 2
## Section 2
+
+## `<Picture />`
+
+### « Sacrebleu ! »
diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.js b/packages/integrations/mdx/test/mdx-get-headings.test.js
index 4de8b0a6e..10ce965e1 100644
--- a/packages/integrations/mdx/test/mdx-get-headings.test.js
+++ b/packages/integrations/mdx/test/mdx-get-headings.test.js
@@ -43,6 +43,8 @@ describe('MDX getHeadings', () => {
{ depth: 3, slug: 'subsection-1', text: 'Subsection 1' },
{ depth: 3, slug: 'subsection-2', text: 'Subsection 2' },
{ depth: 2, slug: 'section-2', text: 'Section 2' },
+ { depth: 2, slug: 'picture', text: '<Picture />' },
+ { depth: 3, slug: '-sacrebleu-', text: '« Sacrebleu ! »' },
]),
);
});
@@ -114,6 +116,8 @@ describe('MDX heading IDs can be customized by user plugins', () => {
{ depth: 3, slug: '2', text: 'Subsection 1' },
{ depth: 3, slug: '3', text: 'Subsection 2' },
{ depth: 2, slug: '4', text: 'Section 2' },
+ { depth: 2, slug: '5', text: '<Picture />' },
+ { depth: 3, slug: '6', text: '« Sacrebleu ! »' },
]),
);
});
@@ -198,3 +202,50 @@ describe('MDX headings with frontmatter', () => {
);
});
});
+
+describe('experimental.headingIdCompat', () => {
+ describe('MDX getHeadings', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: new URL('./fixtures/mdx-get-headings/', import.meta.url),
+ integrations: [mdx()],
+ experimental: { headingIdCompat: true },
+ });
+
+ await fixture.build();
+ });
+
+ it('adds anchor IDs to headings', async () => {
+ const html = await fixture.readFile('/test/index.html');
+ const { document } = parseHTML(html);
+
+ const h2Ids = document.querySelectorAll('h2').map((el) => el?.id);
+ const h3Ids = document.querySelectorAll('h3').map((el) => el?.id);
+ assert.equal(document.querySelector('h1').id, 'heading-test');
+ assert.equal(h2Ids.includes('section-1'), true);
+ assert.equal(h2Ids.includes('section-2'), true);
+ assert.equal(h2Ids.includes('picture-'), true);
+ assert.equal(h3Ids.includes('subsection-1'), true);
+ assert.equal(h3Ids.includes('subsection-2'), true);
+ assert.equal(h3Ids.includes('-sacrebleu--'), true);
+ });
+
+ it('generates correct getHeadings() export', async () => {
+ const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json'));
+ assert.equal(
+ JSON.stringify(headingsByPage['./test.mdx']),
+ JSON.stringify([
+ { depth: 1, slug: 'heading-test', text: 'Heading test' },
+ { depth: 2, slug: 'section-1', text: 'Section 1' },
+ { depth: 3, slug: 'subsection-1', text: 'Subsection 1' },
+ { depth: 3, slug: 'subsection-2', text: 'Subsection 2' },
+ { depth: 2, slug: 'section-2', text: 'Section 2' },
+ { depth: 2, slug: 'picture-', text: '<Picture />' },
+ { depth: 3, slug: '-sacrebleu--', text: '« Sacrebleu ! »' },
+ ]),
+ );
+ });
+ });
+});
diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts
index 6d3261496..1aa713956 100644
--- a/packages/markdown/remark/src/index.ts
+++ b/packages/markdown/remark/src/index.ts
@@ -79,6 +79,7 @@ export async function createMarkdownProcessor(
remarkRehype: remarkRehypeOptions = markdownConfigDefaults.remarkRehype,
gfm = markdownConfigDefaults.gfm,
smartypants = markdownConfigDefaults.smartypants,
+ experimentalHeadingIdCompat = false,
} = opts ?? {};
const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins));
@@ -136,7 +137,7 @@ export async function createMarkdownProcessor(
// Headings
if (!isPerformanceBenchmark) {
- parser.use(rehypeHeadingIds);
+ parser.use(rehypeHeadingIds, { experimentalHeadingIdCompat });
}
// Stringify to HTML
diff --git a/packages/markdown/remark/src/rehype-collect-headings.ts b/packages/markdown/remark/src/rehype-collect-headings.ts
index ab2113f49..24823afc3 100644
--- a/packages/markdown/remark/src/rehype-collect-headings.ts
+++ b/packages/markdown/remark/src/rehype-collect-headings.ts
@@ -9,7 +9,16 @@ import type { MarkdownHeading, RehypePlugin } from './types.js';
const rawNodeTypes = new Set(['text', 'raw', 'mdxTextExpression']);
const codeTagNames = new Set(['code', 'pre']);
-export function rehypeHeadingIds(): ReturnType<RehypePlugin> {
+/**
+ * Rehype plugin that adds `id` attributes to headings based on their text content.
+ *
+ * @param options Optional configuration object for the plugin.
+ *
+ * @see https://docs.astro.build/en/guides/markdown-content/#heading-ids-and-plugins
+ */
+export function rehypeHeadingIds({
+ experimentalHeadingIdCompat,
+}: { experimentalHeadingIdCompat?: boolean } = {}): ReturnType<RehypePlugin> {
return function (tree, file) {
const headings: MarkdownHeading[] = [];
const frontmatter = file.data.astro?.frontmatter;
@@ -59,7 +68,9 @@ export function rehypeHeadingIds(): ReturnType<RehypePlugin> {
if (typeof node.properties.id !== 'string') {
let slug = slugger.slug(text);
- if (slug.endsWith('-')) slug = slug.slice(0, -1);
+ if (!experimentalHeadingIdCompat) {
+ if (slug.endsWith('-')) slug = slug.slice(0, -1);
+ }
node.properties.id = slug;
}
diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts
index a3efae62d..57d7ca77d 100644
--- a/packages/markdown/remark/src/types.ts
+++ b/packages/markdown/remark/src/types.ts
@@ -69,6 +69,7 @@ export interface AstroMarkdownProcessorOptions extends AstroMarkdownOptions {
domains?: string[];
remotePatterns?: RemotePattern[];
};
+ experimentalHeadingIdCompat?: boolean;
}
export interface MarkdownProcessor {