Skip to content
Merged
68 changes: 68 additions & 0 deletions .changeset/good-camels-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
'@astrojs/markdoc': minor
'@astrojs/mdx': major
'@astrojs/markdown-remark': major
'astro': major
---

Updates Markdown heading ID generation

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.

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.

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.

#### What should I do?

If you have manual links to headings, you may need to update the anchor link value with a new trailing hyphen. For example, the following Markdown heading:

```md
## `<Picture />`
```

will now generate the following HTML with a trailing hyphen in the heading `id`:

```html
<h2 id="picture-"><code>&lt;Picture /&gt;</h2>
```

and must now be linked to as:

```markdown
See [the Picture component](/en/guides/images/#picture-) for more details.
```

If you were previously using this experimental feature, you must remove this experimental flag from your configuration as it no longer exists:

```diff
// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
experimental: {
- headingIdCompat: true,
},
})
```

If you were previously using the `rehypeHeadingIds` plugin directly to enforce compatibility, remove the `headingIdCompat` option as it no longer exists:

```diff
// 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, { headingIdCompat: true }],
+ [rehypeHeadingIds],
otherPluginThatReliesOnHeadingIDs,
],
},
});
```

If you want to keep the old ID generation for backward compatibility reasons, you can [create a custom rehype plugin that will generate headings IDs like Astro 5.x](https://docs.astro.build/en/guides/upgrade-to/v6/#changed-markdown-heading-id-generation) by following the instructions in the Astro 6 upgrade guide. This will allow you to continue to use your existing anchor links without adding trailing hyphens.
5 changes: 0 additions & 5 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export const ASTRO_CONFIG_DEFAULTS = {
experimental: {
clientPrerender: false,
contentIntellisense: false,
headingIdCompat: false,
liveContentCollections: false,
csp: false,
chromeDevtoolsWorkspace: false,
Expand Down Expand Up @@ -473,10 +472,6 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
headingIdCompat: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.headingIdCompat),
fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(),
liveContentCollections: z
.boolean()
Expand Down
14 changes: 0 additions & 14 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2139,20 +2139,6 @@ export interface AstroUserConfig<
*/
fonts?: FontFamily[];

/**
* @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;

/**
* @name experimental.csp
* @type {boolean | object}
Expand Down
1 change: 0 additions & 1 deletion packages/astro/src/vite-plugin-markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
if (!processor) {
processor = createMarkdownProcessor({
image: settings.config.image,
experimentalHeadingIdCompat: settings.config.experimental.headingIdCompat,
...settings.config.markdown,
});
}
Expand Down
7 changes: 2 additions & 5 deletions packages/integrations/markdoc/src/content-entry-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export async function getContentEntryType({
const markdocConfig = await setupConfig(
userMarkdocConfig,
options,
astroConfig.experimental.headingIdCompat,
);
const filePath = fileURLToPath(fileUrl);
raiseValidationErrors({
Expand Down Expand Up @@ -121,7 +120,6 @@ 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')};
Expand All @@ -132,15 +130,14 @@ const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast),
)};

export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options, experimentalHeadingIdCompat);
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig, options);
export const Content = createContentComponent(
Renderer,
stringifiedAst,
markdocConfig,
options,
options,
tagComponentMap,
nodeComponentMap,
experimentalHeadingIdCompat,
)`;
return { code: res };
},
Expand Down
24 changes: 6 additions & 18 deletions packages/integrations/markdoc/src/heading-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,17 @@ function getSlug(
attributes: Record<string, any>,
children: RenderableTreeNode[],
headingSlugger: Slugger,
experimentalHeadingIdCompat: boolean,
): string {
if (attributes.id && typeof attributes.id === 'string') {
return attributes.id;
}
const textContent = attributes.content ?? getTextContent(children);
let slug = headingSlugger.slug(textContent);

if (!experimentalHeadingIdCompat) {
if (slug.endsWith('-')) slug = slug.slice(0, -1);
}
return slug;
return headingSlugger.slug(textContent);
Comment on lines -20 to +19
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that I’m not familiar enough with the Markdoc integration to know if there’s a good way for users to modify our logic if they want backwards compatibility.

We have docs on using a custom heading component, but not on how to generate your own id.

Maybe they have to write their own transform() for headings? We’d need to test.

If it’s super onerous, does anyone think we should offer a backwards compat option for the Markdoc integration? Or do we just embrace that it’s a breaking change and people need to update their links. Markdoc usage is fairly low and the cases where this is breaking will be relatively infrequent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah given how low it is, I don't think it's worth investing a lof of effort in it

}

type HeadingIdConfig = MarkdocConfig & {
ctx: { headingSlugger: Slugger; experimentalHeadingIdCompat: boolean };
};
interface HeadingIdConfig extends MarkdocConfig {
ctx: { headingSlugger: Slugger };
}

/*
Expose standalone node for users to import in their config.
Expand All @@ -50,12 +44,7 @@ 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,
config.ctx.experimentalHeadingIdCompat,
);
const slug = getSlug(attributes, children, config.ctx.headingSlugger);

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

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

// Called internally to ensure `ctx` is generated per-file, instead of per-build.
export function setupHeadingConfig(experimentalHeadingIdCompat: boolean): HeadingIdConfig {
export function setupHeadingConfig(): HeadingIdConfig {
const headingSlugger = new Slugger();
return {
ctx: {
headingSlugger,
experimentalHeadingIdCompat,
},
nodes: {
heading,
Expand Down
12 changes: 4 additions & 8 deletions packages/integrations/markdoc/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ import type { MarkdocIntegrationOptions } from './options.js';
export async function setupConfig(
userConfig: AstroMarkdocConfig = {},
options: MarkdocIntegrationOptions | undefined,
experimentalHeadingIdCompat: boolean,
): Promise<MergedConfig> {
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();

if (userConfig.extends) {
for (let extension of userConfig.extends) {
Expand All @@ -46,9 +45,8 @@ export async function setupConfig(
export function setupConfigSync(
userConfig: AstroMarkdocConfig = {},
options: MarkdocIntegrationOptions | undefined,
experimentalHeadingIdCompat: boolean,
): MergedConfig {
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig(experimentalHeadingIdCompat);
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();

let merged = mergeConfig(defaultConfig, userConfig);

Expand Down Expand Up @@ -170,13 +168,12 @@ 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, experimentalHeadingIdCompat);
const config = setupConfigSync(userConfig, options);
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast as Node, config as ConfigType);
let collectedHeadings: MarkdownHeading[] = [];
Expand All @@ -192,13 +189,12 @@ 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, experimentalHeadingIdCompat),
await setupConfig(withVariables, options),
tagComponentMap,
nodeComponentMap,
);
Expand Down
36 changes: 2 additions & 34 deletions packages/integrations/markdoc/test/headings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,6 @@ 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;

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

assert.equal(document.querySelector('h2')?.id, 'picture');
assert.equal(document.querySelector('h3')?.id, '-sacrebleu-');
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 () => {
Expand Down
1 change: 0 additions & 1 deletion packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ 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.
Expand Down
13 changes: 3 additions & 10 deletions packages/integrations/mdx/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@ 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, extraOptions),
rehypePlugins: getRehypePlugins(mdxOptions),
recmaPlugins: mdxOptions.recmaPlugins,
remarkRehypeOptions: mdxOptions.remarkRehype,
jsxImportSource: 'astro',
Expand Down Expand Up @@ -58,10 +57,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
return remarkPlugins;
}

function getRehypePlugins(
mdxOptions: MdxOptions,
{ experimentalHeadingIdCompat }: MdxProcessorExtraOptions,
): PluggableList {
function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
let rehypePlugins: PluggableList = [
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
rehypeMetaString,
Expand All @@ -88,10 +84,7 @@ function getRehypePlugins(
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, { experimentalHeadingIdCompat }],
rehypeInjectHeadingsExport,
);
rehypePlugins.push([rehypeHeadingIds], rehypeInjectHeadingsExport);
}

rehypePlugins.push(
Expand Down
2 changes: 0 additions & 2 deletions packages/integrations/mdx/src/vite-plugin-mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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
Expand Down Expand Up @@ -64,7 +63,6 @@ export function vitePluginMdx(opts: VitePluginMdxOptions): Plugin {
if (!processor) {
processor = createMdxProcessor(opts.mdxOptions, {
sourcemap: sourcemapEnabled,
experimentalHeadingIdCompat: opts.experimentalHeadingIdCompat,
});
}

Expand Down
Loading
Loading