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

TODO:
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 @@ -96,7 +96,6 @@ export const ASTRO_CONFIG_DEFAULTS = {
experimental: {
clientPrerender: false,
contentIntellisense: false,
headingIdCompat: false,
liveContentCollections: false,
csp: false,
chromeDevtoolsWorkspace: false,
Expand Down Expand Up @@ -462,10 +461,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 @@ -2097,20 +2097,6 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
*/
fonts?: [TFontFamilies] extends [never] ? FontFamily[] : TFontFamilies;

/**
* @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
51 changes: 2 additions & 49 deletions packages/integrations/mdx/test/mdx-get-headings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,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 ! »' },
{ depth: 2, slug: 'picture-', text: '<Picture />' },
{ depth: 3, slug: '-sacrebleu--', text: '« Sacrebleu ! »' },
]),
);
});
Expand Down Expand Up @@ -201,50 +201,3 @@ 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 ! »' },
]),
);
});
});
});
3 changes: 1 addition & 2 deletions packages/markdown/remark/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export async function createMarkdownProcessor(
remarkRehype: remarkRehypeOptions = markdownConfigDefaults.remarkRehype,
gfm = markdownConfigDefaults.gfm,
smartypants = markdownConfigDefaults.smartypants,
experimentalHeadingIdCompat = false,
} = opts ?? {};

const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins));
Expand Down Expand Up @@ -136,7 +135,7 @@ export async function createMarkdownProcessor(

// Headings
if (!isPerformanceBenchmark) {
parser.use(rehypeHeadingIds, { experimentalHeadingIdCompat });
parser.use(rehypeHeadingIds);
}

// Stringify to HTML
Expand Down
Loading
Loading