Skip to content

Commit 727b0a2

Browse files
florian-lefebvresarah11918delucis
authored
feat!: stabilize experimental.headingIdCompat (#14494)
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
1 parent 8d61777 commit 727b0a2

File tree

15 files changed

+46
-164
lines changed

15 files changed

+46
-164
lines changed

.changeset/good-camels-pull.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@astrojs/markdoc': minor
3+
'@astrojs/mdx': major
4+
'@astrojs/markdown-remark': major
5+
'astro': major
6+
---
7+
8+
Updates Markdown heading ID generation
9+
10+
In Astro 5.x, an additional default processing step to Markdown stripped trailing hyphens from the end of IDs for section headings ending in special characters. This provided a cleaner `id` value, but could lead to incompatibilities rendering your Markdown across platforms.
11+
12+
In Astro 5.5, the `experimental.headingIdCompat` flag was introduced to allow you to make the IDs generated by Astro for Markdown headings compatible with common platforms like GitHub and npm, using the popular [`github-slugger`](https://github.com/Flet/github-slugger) package.
13+
14+
Astro 6.0 removes this experimental flag and makes this the new default behavior in Astro: trailing hyphens from the end of IDs for headings ending in special characters are no longer removed.
15+
16+
#### What should I do?
17+
18+
If you have manual links to headings, you may need to update some anchor link values with a new trailing hyphen.
19+
20+
If you were previously using this experimental feature, remove this experimental flag from your configuration.
21+
22+
If you were previously using the `rehypeHeadingIds` plugin directly to enforce compatibility, remove the `headingIdCompat` option as it no longer exists.
23+
24+
See the [Astro 6.0 upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v6/#changed-markdown-heading-id-generation) for upgrade examples, and instructions to create a custom rehype plulgin if you want to keep the old ID generation for backward compatibility reasons.

packages/astro/src/core/config/schemas/base.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ export const ASTRO_CONFIG_DEFAULTS = {
9797
experimental: {
9898
clientPrerender: false,
9999
contentIntellisense: false,
100-
headingIdCompat: false,
101100
liveContentCollections: false,
102101
csp: false,
103102
chromeDevtoolsWorkspace: false,
@@ -473,10 +472,6 @@ export const AstroConfigSchema = z.object({
473472
.boolean()
474473
.optional()
475474
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
476-
headingIdCompat: z
477-
.boolean()
478-
.optional()
479-
.default(ASTRO_CONFIG_DEFAULTS.experimental.headingIdCompat),
480475
fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(),
481476
liveContentCollections: z
482477
.boolean()

packages/astro/src/types/public/config.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,20 +2139,6 @@ export interface AstroUserConfig<
21392139
*/
21402140
fonts?: FontFamily[];
21412141

2142-
/**
2143-
* @name experimental.headingIdCompat
2144-
* @type {boolean}
2145-
* @default `false`
2146-
* @version 5.5.x
2147-
* @description
2148-
*
2149-
* Enables full compatibility of Markdown headings IDs with common platforms such as GitHub and npm.
2150-
*
2151-
* When enabled, IDs for headings ending with non-alphanumeric characters, e.g. `<Picture />`, will
2152-
* include a trailing `-`, matching standard behavior in other Markdown tooling.
2153-
*/
2154-
headingIdCompat?: boolean;
2155-
21562142
/**
21572143
* @name experimental.csp
21582144
* @type {boolean | object}

packages/astro/src/vite-plugin-markdown/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
6262
if (!processor) {
6363
processor = createMarkdownProcessor({
6464
image: settings.config.image,
65-
experimentalHeadingIdCompat: settings.config.experimental.headingIdCompat,
6665
...settings.config.markdown,
6766
});
6867
}

packages/integrations/markdoc/src/content-entry-type.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export async function getContentEntryType({
5252
const markdocConfig = await setupConfig(
5353
userMarkdocConfig,
5454
options,
55-
astroConfig.experimental.headingIdCompat,
5655
);
5756
const filePath = fileURLToPath(fileUrl);
5857
raiseValidationErrors({
@@ -121,7 +120,6 @@ markdocConfig.nodes = { ...assetsConfig.nodes, ...markdocConfig.nodes };
121120
122121
${getStringifiedImports(componentConfigByTagMap, 'Tag', astroConfig.root)}
123122
${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)}
124-
const experimentalHeadingIdCompat = ${JSON.stringify(astroConfig.experimental.headingIdCompat || false)}
125123
126124
const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')};
127125
const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')};
@@ -132,15 +130,14 @@ const stringifiedAst = ${JSON.stringify(
132130
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast),
133131
)};
134132
135-
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options, experimentalHeadingIdCompat);
133+
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options);
136134
export const Content = createContentComponent(
137135
Renderer,
138136
stringifiedAst,
139137
markdocConfig,
140-
options,
138+
options,
141139
tagComponentMap,
142140
nodeComponentMap,
143-
experimentalHeadingIdCompat,
144141
)`;
145142
return { code: res };
146143
},

packages/integrations/markdoc/src/heading-ids.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,17 @@ function getSlug(
1111
attributes: Record<string, any>,
1212
children: RenderableTreeNode[],
1313
headingSlugger: Slugger,
14-
experimentalHeadingIdCompat: boolean,
1514
): string {
1615
if (attributes.id && typeof attributes.id === 'string') {
1716
return attributes.id;
1817
}
1918
const textContent = attributes.content ?? getTextContent(children);
20-
let slug = headingSlugger.slug(textContent);
21-
22-
if (!experimentalHeadingIdCompat) {
23-
if (slug.endsWith('-')) slug = slug.slice(0, -1);
24-
}
25-
return slug;
19+
return headingSlugger.slug(textContent);
2620
}
2721

28-
type HeadingIdConfig = MarkdocConfig & {
29-
ctx: { headingSlugger: Slugger; experimentalHeadingIdCompat: boolean };
30-
};
22+
interface HeadingIdConfig extends MarkdocConfig {
23+
ctx: { headingSlugger: Slugger };
24+
}
3125

3226
/*
3327
Expose standalone node for users to import in their config.
@@ -50,12 +44,7 @@ export const heading: Schema = {
5044
'Unexpected problem adding heading IDs to Markdoc file. Did you modify the `ctx.headingSlugger` property in your Markdoc config?',
5145
});
5246
}
53-
const slug = getSlug(
54-
attributes,
55-
children,
56-
config.ctx.headingSlugger,
57-
config.ctx.experimentalHeadingIdCompat,
58-
);
47+
const slug = getSlug(attributes, children, config.ctx.headingSlugger);
5948

6049
const render = config.nodes?.heading?.render ?? `h${level}`;
6150

@@ -72,12 +61,11 @@ export const heading: Schema = {
7261
};
7362

7463
// Called internally to ensure `ctx` is generated per-file, instead of per-build.
75-
export function setupHeadingConfig(experimentalHeadingIdCompat: boolean): HeadingIdConfig {
64+
export function setupHeadingConfig(): HeadingIdConfig {
7665
const headingSlugger = new Slugger();
7766
return {
7867
ctx: {
7968
headingSlugger,
80-
experimentalHeadingIdCompat,
8169
},
8270
nodes: {
8371
heading,

packages/integrations/markdoc/src/runtime.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ import type { MarkdocIntegrationOptions } from './options.js';
1919
export async function setupConfig(
2020
userConfig: AstroMarkdocConfig = {},
2121
options: MarkdocIntegrationOptions | undefined,
22-
experimentalHeadingIdCompat: boolean,
2322
): Promise<MergedConfig> {
24-
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
23+
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
2524

2625
if (userConfig.extends) {
2726
for (let extension of userConfig.extends) {
@@ -46,9 +45,8 @@ export async function setupConfig(
4645
export function setupConfigSync(
4746
userConfig: AstroMarkdocConfig = {},
4847
options: MarkdocIntegrationOptions | undefined,
49-
experimentalHeadingIdCompat: boolean,
5048
): MergedConfig {
51-
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
49+
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
5250

5351
let merged = mergeConfig(defaultConfig, userConfig);
5452

@@ -170,13 +168,12 @@ export function createGetHeadings(
170168
stringifiedAst: string,
171169
userConfig: AstroMarkdocConfig,
172170
options: MarkdocIntegrationOptions | undefined,
173-
experimentalHeadingIdCompat: boolean,
174171
) {
175172
return function getHeadings() {
176173
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
177174
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
178175
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
179-
const config = setupConfigSync(userConfig, options, experimentalHeadingIdCompat);
176+
const config = setupConfigSync(userConfig, options);
180177
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
181178
const content = Markdoc.transform(ast as Node, config as ConfigType);
182179
let collectedHeadings: MarkdownHeading[] = [];
@@ -192,13 +189,12 @@ export function createContentComponent(
192189
options: MarkdocIntegrationOptions | undefined,
193190
tagComponentMap: Record<string, AstroInstance['default']>,
194191
nodeComponentMap: Record<NodeType, AstroInstance['default']>,
195-
experimentalHeadingIdCompat: boolean,
196192
) {
197193
return createComponent({
198194
async factory(result: any, props: Record<string, any>) {
199195
const withVariables = mergeConfig(userConfig, { variables: props });
200196
const config = resolveComponentImports(
201-
await setupConfig(withVariables, options, experimentalHeadingIdCompat),
197+
await setupConfig(withVariables, options),
202198
tagComponentMap,
203199
nodeComponentMap,
204200
);

packages/integrations/markdoc/test/headings.test.js

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,6 @@ async function getFixture(name) {
99
});
1010
}
1111

12-
describe('experimental.headingIdCompat', () => {
13-
let fixture;
14-
15-
before(async () => {
16-
fixture = await loadFixture({
17-
root: new URL(`./fixtures/headings/`, import.meta.url),
18-
experimental: { headingIdCompat: true },
19-
});
20-
});
21-
22-
describe('dev', () => {
23-
let devServer;
24-
25-
before(async () => {
26-
devServer = await fixture.startDevServer();
27-
});
28-
29-
after(async () => {
30-
await devServer.stop();
31-
});
32-
33-
it('applies IDs to headings containing special characters', async () => {
34-
const res = await fixture.fetch('/headings-with-special-characters');
35-
const html = await res.text();
36-
const { document } = parseHTML(html);
37-
38-
assert.equal(document.querySelector('h2')?.id, 'picture-');
39-
assert.equal(document.querySelector('h3')?.id, '-sacrebleu--');
40-
});
41-
});
42-
});
43-
4412
describe('Markdoc - Headings', () => {
4513
let fixture;
4614

@@ -72,8 +40,8 @@ describe('Markdoc - Headings', () => {
7240
const html = await res.text();
7341
const { document } = parseHTML(html);
7442

75-
assert.equal(document.querySelector('h2')?.id, 'picture');
76-
assert.equal(document.querySelector('h3')?.id, '-sacrebleu-');
43+
assert.equal(document.querySelector('h2')?.id, 'picture-');
44+
assert.equal(document.querySelector('h3')?.id, '-sacrebleu--');
7745
});
7846

7947
it('generates the same IDs for other documents with the same headings', async () => {

packages/integrations/mdx/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
101101
Object.assign(vitePluginMdxOptions, {
102102
mdxOptions: resolvedMdxOptions,
103103
srcDir: config.srcDir,
104-
experimentalHeadingIdCompat: config.experimental.headingIdCompat,
105104
});
106105
// @ts-expect-error After we assign, we don't need to reference `mdxOptions` in this context anymore.
107106
// Re-assign it so that the garbage can be collected later.

packages/integrations/mdx/src/plugins.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@ const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK);
2323

2424
interface MdxProcessorExtraOptions {
2525
sourcemap: boolean;
26-
experimentalHeadingIdCompat: boolean;
2726
}
2827

2928
export function createMdxProcessor(mdxOptions: MdxOptions, extraOptions: MdxProcessorExtraOptions) {
3029
return createProcessor({
3130
remarkPlugins: getRemarkPlugins(mdxOptions),
32-
rehypePlugins: getRehypePlugins(mdxOptions, extraOptions),
31+
rehypePlugins: getRehypePlugins(mdxOptions),
3332
recmaPlugins: mdxOptions.recmaPlugins,
3433
remarkRehypeOptions: mdxOptions.remarkRehype,
3534
jsxImportSource: 'astro',
@@ -58,10 +57,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
5857
return remarkPlugins;
5958
}
6059

61-
function getRehypePlugins(
62-
mdxOptions: MdxOptions,
63-
{ experimentalHeadingIdCompat }: MdxProcessorExtraOptions,
64-
): PluggableList {
60+
function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
6561
let rehypePlugins: PluggableList = [
6662
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
6763
rehypeMetaString,
@@ -88,10 +84,7 @@ function getRehypePlugins(
8884
if (!isPerformanceBenchmark) {
8985
// getHeadings() is guaranteed by TS, so this must be included.
9086
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
91-
rehypePlugins.push(
92-
[rehypeHeadingIds, { experimentalHeadingIdCompat }],
93-
rehypeInjectHeadingsExport,
94-
);
87+
rehypePlugins.push([rehypeHeadingIds], rehypeInjectHeadingsExport);
9588
}
9689

9790
rehypePlugins.push(

0 commit comments

Comments
 (0)