diff --git a/.changeset/humble-friends-clean.md b/.changeset/humble-friends-clean.md new file mode 100644 index 00000000000..d863d03c17e --- /dev/null +++ b/.changeset/humble-friends-clean.md @@ -0,0 +1,13 @@ +--- +'@astrojs/starlight': minor +--- + +Updates internal `@astrojs/mdx`, `@astrojs/markdown-satteri`, and `satteri` dependencies. + +⚠️ **BREAKING CHANGE:** The minimum supported version of Astro is now v7.2.5. + +Please update Starlight and Astro together: + +```sh +npx @astrojs/upgrade +``` diff --git a/docs/package.json b/docs/package.json index ee8b8b3dcee..8efdb5b2949 100644 --- a/docs/package.json +++ b/docs/package.json @@ -21,7 +21,7 @@ "@astrojs/starlight": "workspace:*", "@lunariajs/core": "^0.1.1", "@types/culori": "^4.0.1", - "astro": "^7.1.6", + "astro": "^7.2.5", "culori": "^4.0.2", "sharp": "^0.35.3" }, diff --git a/examples/basics/package.json b/examples/basics/package.json index 61086fcd9b7..f80e660f888 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.41.11", - "astro": "^7.1.6", + "astro": "^7.2.5", "sharp": "^0.35.3" } } diff --git a/examples/markdoc/package.json b/examples/markdoc/package.json index c71bd2e111e..4e0b6706699 100644 --- a/examples/markdoc/package.json +++ b/examples/markdoc/package.json @@ -14,7 +14,7 @@ "@astrojs/markdoc": "^2.0.6", "@astrojs/starlight": "^0.41.11", "@astrojs/starlight-markdoc": "^0.7.0", - "astro": "^7.1.6", + "astro": "^7.2.5", "sharp": "^0.35.3" } } diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json index ad78d2ab7e3..8deae6d27e2 100644 --- a/examples/tailwind/package.json +++ b/examples/tailwind/package.json @@ -14,7 +14,7 @@ "@astrojs/starlight": "^0.41.11", "@astrojs/starlight-tailwind": "^5.0.0", "@tailwindcss/vite": "^4.2.4", - "astro": "^7.1.6", + "astro": "^7.2.5", "sharp": "^0.35.3", "tailwindcss": "^4.2.4" } diff --git a/package.json b/package.json index d1b2e08a0f7..b523e622eaa 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@changesets/cli": "^3.0.1", "@eslint/js": "^10.0.1", "@size-limit/file": "^12.1.0", - "astro": "^7.1.6", + "astro": "^7.2.5", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", diff --git a/packages/file-icons-generator/config.ts b/packages/file-icons-generator/config.ts index 7563a7ca978..e0bbc0f9b28 100644 --- a/packages/file-icons-generator/config.ts +++ b/packages/file-icons-generator/config.ts @@ -1,4 +1,4 @@ -import type { Definitions } from '../starlight/src/user-components/rehype-file-tree'; +import type { Definitions } from '../starlight/src/user-components/file-tree-processor'; export const seti = { /** The GitHub repository containing the Seti UI theme using the `username/repo` format. */ diff --git a/packages/file-icons-generator/utils/file.ts b/packages/file-icons-generator/utils/file.ts index eea3edcd610..c4af596cff8 100644 --- a/packages/file-icons-generator/utils/file.ts +++ b/packages/file-icons-generator/utils/file.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { starlight } from '../config.ts'; -import type { Definitions } from '../../starlight/src/user-components/rehype-file-tree.ts'; +import type { Definitions } from '../../starlight/src/user-components/file-tree-processor.ts'; const copyrightNotice = `/** * Based on https://github.com/elviswolcott/seti-icons which @@ -42,7 +42,7 @@ export async function writeDefinitionsAndSVGs( ) { const content = `${generatedFileHeader} -import type { Definitions } from './rehype-file-tree.ts'; +import type { Definitions } from './file-tree-processor.ts'; ${copyrightNotice} export const definitions: Definitions = ${JSON.stringify(definitions)}; diff --git a/packages/file-icons-generator/utils/font.ts b/packages/file-icons-generator/utils/font.ts index fdf14b89985..7e659975769 100644 --- a/packages/file-icons-generator/utils/font.ts +++ b/packages/file-icons-generator/utils/font.ts @@ -1,6 +1,6 @@ import opentype, { type Font, Glyph } from 'opentype.js'; import { seti, starlight } from '../config.ts'; -import type { Definitions } from '../../starlight/src/user-components/rehype-file-tree.ts'; +import type { Definitions } from '../../starlight/src/user-components/file-tree-processor.ts'; import { getFont, getSetiIconName } from './seti.ts'; // This matches the default precision used by the SVGO default preset. diff --git a/packages/file-icons-generator/utils/seti.ts b/packages/file-icons-generator/utils/seti.ts index 164b88a8b8f..a687270e53f 100644 --- a/packages/file-icons-generator/utils/seti.ts +++ b/packages/file-icons-generator/utils/seti.ts @@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { seti, starlight } from '../config.ts'; -import type { Definitions } from '../../starlight/src/user-components/rehype-file-tree.ts'; +import type { Definitions } from '../../starlight/src/user-components/file-tree-processor.ts'; // https://github.com/jesseweed/seti-ui/blob/master/styles/components/icons/mapping.less // .icon-set(".bsl", "bsl", @red); diff --git a/packages/starlight/__e2e__/fixtures/basics/package.json b/packages/starlight/__e2e__/fixtures/basics/package.json index ca8d4bb11d1..c12304e3606 100644 --- a/packages/starlight/__e2e__/fixtures/basics/package.json +++ b/packages/starlight/__e2e__/fixtures/basics/package.json @@ -4,6 +4,6 @@ "private": true, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__e2e__/fixtures/custom src-dir/package.json b/packages/starlight/__e2e__/fixtures/custom src-dir/package.json index 8a4f0e2394d..62e30de3f79 100644 --- a/packages/starlight/__e2e__/fixtures/custom src-dir/package.json +++ b/packages/starlight/__e2e__/fixtures/custom src-dir/package.json @@ -4,6 +4,6 @@ "private": true, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__e2e__/fixtures/git/package.json b/packages/starlight/__e2e__/fixtures/git/package.json index b739822e8d6..bc616e88a6e 100644 --- a/packages/starlight/__e2e__/fixtures/git/package.json +++ b/packages/starlight/__e2e__/fixtures/git/package.json @@ -4,6 +4,6 @@ "private": true, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json index 65d218a901a..9b97592c1a4 100644 --- a/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json +++ b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json @@ -4,6 +4,6 @@ "private": true, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__e2e__/fixtures/no-mdx-optimization/package.json b/packages/starlight/__e2e__/fixtures/no-mdx-optimization/package.json index 27df071a410..94140f936d0 100644 --- a/packages/starlight/__e2e__/fixtures/no-mdx-optimization/package.json +++ b/packages/starlight/__e2e__/fixtures/no-mdx-optimization/package.json @@ -4,6 +4,6 @@ "private": true, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__e2e__/fixtures/ssr/package.json b/packages/starlight/__e2e__/fixtures/ssr/package.json index adc96bea694..e509598039f 100644 --- a/packages/starlight/__e2e__/fixtures/ssr/package.json +++ b/packages/starlight/__e2e__/fixtures/ssr/package.json @@ -5,6 +5,6 @@ "dependencies": { "@astrojs/node": "^11.0.0", "@astrojs/starlight": "workspace:*", - "astro": "^7.1.6" + "astro": "^7.2.5" } } diff --git a/packages/starlight/__tests__/markdown-processor/code-rtl-support.test.ts b/packages/starlight/__tests__/markdown-processor/code-rtl-support.test.ts index 36988474cc8..697e02d9932 100644 --- a/packages/starlight/__tests__/markdown-processor/code-rtl-support.test.ts +++ b/packages/starlight/__tests__/markdown-processor/code-rtl-support.test.ts @@ -34,16 +34,10 @@ describeEachProcessor('code RTL support', (ctx, name) => { expect(res.code).not.includes('dir="auto"'); }); - test('handles `dir="ltr"` on raw `
` HTML per processor', async () => {
+	test('does not apply `dir="ltr"` on raw `
` HTML', async () => {
 		const res = await ctx().render('
manual
', { processor: plain }); - // Since Astro's Sätteri implementation for Shiki returns raw nodes, it, by accident, applies - // `dir="ltr"` to raw `
` blocks even if they're not coming from a plugin generating HAST.
-		if (name === 'satteri') {
-			expect(res.code).includes('
manual
'); - } else { - expect(res.code).includes('
manual
'); - } + expect(res.code).includes('
manual
'); }); test('does not override an existing `dir` on inline code', async () => { diff --git a/packages/starlight/__tests__/markdown-processor/rehype-file-tree.test.ts b/packages/starlight/__tests__/markdown-processor/file-tree-processor.test.ts similarity index 98% rename from packages/starlight/__tests__/markdown-processor/rehype-file-tree.test.ts rename to packages/starlight/__tests__/markdown-processor/file-tree-processor.test.ts index e5401f163ee..25d9215509c 100644 --- a/packages/starlight/__tests__/markdown-processor/rehype-file-tree.test.ts +++ b/packages/starlight/__tests__/markdown-processor/file-tree-processor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest'; -import { processFileTree } from '../../src/user-components/rehype-file-tree'; +import { processFileTree } from '../../src/user-components/file-tree-processor'; import { Icons, type StarlightIcon } from '../../src/components-internals/Icons'; describe('validation', () => { diff --git a/packages/starlight/__tests__/markdown-processor/plugin-registration.test.ts b/packages/starlight/__tests__/markdown-processor/plugin-registration.test.ts index a35bc00904d..5da6776e127 100644 --- a/packages/starlight/__tests__/markdown-processor/plugin-registration.test.ts +++ b/packages/starlight/__tests__/markdown-processor/plugin-registration.test.ts @@ -66,8 +66,8 @@ describe('registerDirectivesRestoration', () => { test('registers the mdast restoration plugin on a Sätteri processor', () => { const processor = satteri(); registerDirectivesRestoration(processor, unifiedIntegration); - expect(processor.options.mdastPlugins.map((plugin) => plugin.name)).toContain( - 'starlight-directives-restoration' + expect(processor.options.mdastPlugins).toContainEqual( + expect.objectContaining({ name: 'starlight-directives-restoration' }) ); }); diff --git a/packages/starlight/__tests__/markdown-processor/rehype-steps.test.ts b/packages/starlight/__tests__/markdown-processor/steps-processor.test.ts similarity index 97% rename from packages/starlight/__tests__/markdown-processor/rehype-steps.test.ts rename to packages/starlight/__tests__/markdown-processor/steps-processor.test.ts index a6c413f173e..980d7675997 100644 --- a/packages/starlight/__tests__/markdown-processor/rehype-steps.test.ts +++ b/packages/starlight/__tests__/markdown-processor/steps-processor.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { processSteps } from '../../src/user-components/rehype-steps'; +import { processSteps } from '../../src/user-components/steps-processor'; test('empty component throws an error', () => { expect(() => processSteps('')).toThrowErrorMatchingInlineSnapshot( @@ -63,7 +63,7 @@ test('component with multiple children throws an error', () => { }); // This tests a workaround added for https://github.com/withastro/astro/issues/15627 -// See rehype-steps.ts for more details. +// See steps-processor.ts for more details. test('component with top-level script child is processed successfully', () => { const { html } = processSteps( '
  1. List item
' diff --git a/packages/starlight/__tests__/markdown-processor/rehype-tabs.test.ts b/packages/starlight/__tests__/markdown-processor/tabs-processor.test.ts similarity index 92% rename from packages/starlight/__tests__/markdown-processor/rehype-tabs.test.ts rename to packages/starlight/__tests__/markdown-processor/tabs-processor.test.ts index 02b2c5d8488..eaf0deda731 100644 --- a/packages/starlight/__tests__/markdown-processor/rehype-tabs.test.ts +++ b/packages/starlight/__tests__/markdown-processor/tabs-processor.test.ts @@ -1,21 +1,19 @@ import { expect, test } from 'vitest'; -import { fromHtml } from 'hast-util-from-html'; +import { toHtml } from 'hast-util-to-html'; import { selectAll } from 'hast-util-select'; -import { rehype } from 'rehype'; -import { processPanels, TabItemTagname } from '../../src/user-components/rehype-tabs'; - -const processor = rehype().data('settings', { fragment: true }); +import { htmlToHast } from 'satteri'; +import { processPanels, TabItemTagname } from '../../src/user-components/tabs-processor'; const TabItem = ({ label, slot, icon }: { label: string; slot: string; icon?: string }) => { const iconAttr = icon ? ` data-icon="${icon}"` : ''; return `<${TabItemTagname} data-label="${label}"${iconAttr}>${slot}`; }; -/** Get an array of HTML strings, one for each `
` created by rehype-tabs for each tab item. */ +/** Get an array of HTML strings, one for each `
` created by tabs-processor for each tab item. */ const extractTabPanels = (html: string) => { - const tree = fromHtml(html, { fragment: true }); + const tree = htmlToHast(html, { fragment: true }); const tabPanels = selectAll('div[role="tabpanel"]', tree); - return tabPanels.map((tabPanel) => processor.stringify({ type: 'root', children: [tabPanel] })); + return tabPanels.map((tabPanel) => toHtml(tabPanel)); }; test('empty component returns no html or panels', () => { diff --git a/packages/starlight/__tests__/test-utils.ts b/packages/starlight/__tests__/test-utils.ts index 34285a20661..f87ef8f21f7 100644 --- a/packages/starlight/__tests__/test-utils.ts +++ b/packages/starlight/__tests__/test-utils.ts @@ -62,6 +62,7 @@ const frontmatterSchema = docsSchema()({ z.literal('gif'), z.literal('svg'), z.literal('avif'), + z.literal('apng'), ]), }), }); diff --git a/packages/starlight/package.json b/packages/starlight/package.json index 41a129c19f1..5f17a199c89 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -48,7 +48,7 @@ }, "peerDependencies": { "@astrojs/markdown-remark": "^7.2.0", - "astro": "^7.0.2" + "astro": "^7.2.5" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -56,27 +56,28 @@ } }, "devDependencies": { - "@astrojs/markdown-remark": "^7.2.2", + "@astrojs/markdown-remark": "^7.2.4", "@playwright/test": "^1.61.1", "@codspeed/vitest-plugin": "^5.7.1", "@types/node": "^22.19.17", "@vitest/coverage-v8": "^4.1.5", - "astro": "^7.1.6", + "astro": "^7.2.5", "linkedom": "^0.18.12", "vitest": "^4.1.5" }, "dependencies": { - "@astrojs/markdown-satteri": "^0.3.5", - "@astrojs/mdx": "^7.0.5", + "@astrojs/markdown-satteri": "^0.3.8", + "@astrojs/mdx": "^7.0.8", "@astrojs/sitemap": "^3.7.3", "@pagefind/default-ui": "^1.3.0", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", - "hast-util-from-html": "^2.0.3", + "hast-util-format": "^1.1.0", "hast-util-select": "^6.0.4", + "hast-util-to-html": "^9.0.5", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", @@ -87,10 +88,8 @@ "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.5.2", - "rehype": "^13.0.2", - "rehype-format": "^5.0.1", "remark-directive": "^4.0.0", - "satteri": "^0.9.1", + "satteri": "^0.10.3", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", diff --git a/packages/starlight/src/integrations/markdown-icon.ts b/packages/starlight/src/integrations/markdown-icon.ts index 9bcf8eb075c..11bfd6c12c8 100644 --- a/packages/starlight/src/integrations/markdown-icon.ts +++ b/packages/starlight/src/integrations/markdown-icon.ts @@ -1,5 +1,5 @@ -import type { Element, ElementContent } from 'hast'; -import { fromHtml } from 'hast-util-from-html'; +import type { ElementContent, Root } from 'hast'; +import { htmlToHast } from 'satteri'; import { Icons } from '../components-internals/Icons'; export const headingLinkIconSvgChildrenHtml = Icons['link-alt']; @@ -7,7 +7,6 @@ export const headingLinkIconChildren = parseIconChildren(headingLinkIconSvgChild /** Parse a Starlight icon SVG fragment as children of an `` element. */ export function parseIconChildren(icon: string): ElementContent[] { - const svg = fromHtml(`${icon}`, { fragment: true, space: 'svg' }) - .children[0] as Element; - return svg.children; + const tree = htmlToHast(icon, { fragment: true, space: 'svg' }) as Root; + return tree.children as ElementContent[]; } diff --git a/packages/starlight/src/integrations/satteri.ts b/packages/starlight/src/integrations/satteri.ts index d93c5f17f1d..07a2d702b2e 100644 --- a/packages/starlight/src/integrations/satteri.ts +++ b/packages/starlight/src/integrations/satteri.ts @@ -6,10 +6,11 @@ import { directiveToMarkdown } from 'mdast-util-directive'; import { toMarkdown } from 'mdast-util-to-markdown'; import type { HastPluginDefinition, - HastPluginInput, HastVisitorContext, - MdastPluginInput, MdastPluginDefinition, + MdastPluginEntry, + HastPluginEntry, + SourceFormat, } from 'satteri'; import { headingLinkIconChildren } from './markdown-icon'; import { getAsideIcon, isAsideVariant } from './aside-utils'; @@ -20,17 +21,39 @@ import { } from './markdown-processor'; export function starlightSatteriPlugins(options: MarkdownProcessorPluginOptions): { - mdastPlugins: MdastPluginInput[]; - hastPlugins: HastPluginInput[]; + mdastPlugins: MdastPluginEntry[]; + hastPlugins: HastPluginEntry[]; } { const allowedPaths = getMarkdownProcessorPaths(options); + return { - mdastPlugins: [satteriAsidesPlugin(options, allowedPaths)], + mdastPlugins: [ + ({ fileURL, sourceFormat }) => { + if (!shouldTransformPath(fileURL, allowedPaths)) return; + + // `shouldTransformPath` above already returned for a missing `fileURL`. + const lang = options.absolutePathToLang(fileURLToPath(fileURL!)); + const t = options.useTranslations(lang); + + return satteriAsidesPlugin(t, sourceFormat); + }, + ], hastPlugins: [ - satteriRtlCodeSupportPlugin(allowedPaths), - ...(options.starlightConfig.markdown.headingLinks - ? [() => satteriHeadingIdsPlugin(), satteriAutolinkHeadingsPlugin(options, allowedPaths)] - : []), + ({ fileURL }) => { + if (!shouldTransformPath(fileURL, allowedPaths)) return; + + const plugins = [satteriRtlCodeSupportPlugin()]; + + if (options.starlightConfig.markdown.headingLinks) { + // `shouldTransformPath` above already returned for a missing `fileURL`. + const lang = options.absolutePathToLang(fileURLToPath(fileURL!)); + const t = options.useTranslations(lang); + + plugins.push(satteriHeadingIdsPlugin(), satteriAutolinkHeadingsPlugin(t)); + } + + return plugins; + }, ], }; } @@ -73,20 +96,13 @@ function paragraphElement( } /** Convert `:::variant` directive blocks into styled asides. */ -function satteriAsidesPlugin( - options: MarkdownProcessorPluginOptions, - allowedPaths: string[] -): MdastPluginDefinition { +function satteriAsidesPlugin(t: I18nT, sourceFormat: SourceFormat): MdastPluginDefinition { return { name: 'starlight-asides', containerDirective(node, ctx) { - if (!shouldTransformPath(ctx.fileURL, allowedPaths)) return; if (!isAsideVariant(node.name)) return; const variant = node.name; - // `shouldTransformPath` above already returned for a missing `fileURL`. - const filename = fileURLToPath(ctx.fileURL!); - const t = options.useTranslations(options.absolutePathToLang(filename)); let title = t(`aside.${variant}`); let titleNode: unknown[] = [{ type: 'text', value: title }]; @@ -105,14 +121,14 @@ function satteriAsidesPlugin( const icon = getAsideIcon(variant, node.attributes?.['icon']); const iconSvg = `${icon}`; // Markdown and MDX require different AST shapes for raw HTML content. - // TODO: replace endsWith check with an official Sätteri API once available. - const iconNode = ctx.fileURL?.pathname.endsWith('.mdx') - ? { - type: 'mdxJsxTextElement', - name: 'Fragment', - attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: iconSvg }], - } - : { type: 'html', value: iconSvg }; + const iconNode = + sourceFormat === 'mdx' + ? { + type: 'mdxJsxTextElement', + name: 'Fragment', + attributes: [{ type: 'mdxJsxAttribute', name: 'set:html', value: iconSvg }], + } + : { type: 'html', value: iconSvg }; return paragraphElement( 'aside', @@ -137,14 +153,13 @@ function serializeDirective(node: Parameters[0]): string { return md.at(-1) === '\n' ? md.slice(0, -1) : md; } -function satteriRtlCodeSupportPlugin(allowedPaths: string[]): HastPluginDefinition { +function satteriRtlCodeSupportPlugin(): HastPluginDefinition { return { name: 'starlight-rtl-code-support', element: [ { filter: ['pre'], visit(node, ctx) { - if (!shouldTransformPath(ctx.fileURL, allowedPaths)) return; if (node.properties && 'dir' in node.properties) return; ctx.setProperty(node, 'dir', 'ltr'); }, @@ -152,24 +167,12 @@ function satteriRtlCodeSupportPlugin(allowedPaths: string[]): HastPluginDefiniti { filter: ['code'], visit(node, ctx) { - if ( - shouldTransformPath(ctx.fileURL, allowedPaths) && - !(node.properties && 'dir' in node.properties) && - !hasPreParent(node, ctx) - ) { + if (!(node.properties && 'dir' in node.properties) && !hasPreParent(node, ctx)) { ctx.setProperty(node, 'dir', 'auto'); } }, }, ], - // Shiki runs ahead of us and replaces the highlighted `
` element with a raw HTML
-		// node, so the `pre` element visitor above never sees it. Patch the raw markup instead.
-		raw(node, ctx) {
-			if (!shouldTransformPath(ctx.fileURL, allowedPaths)) return undefined;
-			const value = ltrRawPre(node.value);
-			if (value === null) return undefined;
-			return { type: 'raw', value };
-		},
 	};
 }
 
@@ -185,36 +188,16 @@ function hasPreParent(child: Readonly | undefined, ctx: HastVisitorCont
 	return false;
 }
 
-const rawPreOpenTag = /])[^>]*>/;
-
-/**
- * Add `dir="ltr"` to the opening tag of a raw `
` HTML string, unless it already declares a
- * `dir`. Returns `null` when the value isn’t a `
`, leaving unrelated raw HTML untouched.
- */
-function ltrRawPre(value: string): string | null {
-	const openTag = value.match(rawPreOpenTag)?.[0];
-	if (!openTag || /\sdir\s*=/.test(openTag)) return null;
-	return value.replace(openTag, () => `
;
diff --git a/packages/starlight/src/user-components/FileTree.astro b/packages/starlight/src/user-components/FileTree.astro
index 2bd532d66da..5d1aee416b6 100644
--- a/packages/starlight/src/user-components/FileTree.astro
+++ b/packages/starlight/src/user-components/FileTree.astro
@@ -1,5 +1,5 @@
 ---
-import { processFileTree } from './rehype-file-tree';
+import { processFileTree } from './file-tree-processor';
 
 const fileTreeHtml = await Astro.slots.render('default');
 const html = processFileTree(fileTreeHtml, Astro.locals.t('fileTree.directory'));
diff --git a/packages/starlight/src/user-components/Steps.astro b/packages/starlight/src/user-components/Steps.astro
index bd193e73d5f..43fbc56856a 100644
--- a/packages/starlight/src/user-components/Steps.astro
+++ b/packages/starlight/src/user-components/Steps.astro
@@ -1,5 +1,5 @@
 ---
-import { processSteps } from './rehype-steps';
+import { processSteps } from './steps-processor';
 
 const content = await Astro.slots.render('default');
 const { html } = processSteps(content);
diff --git a/packages/starlight/src/user-components/TabItem.astro b/packages/starlight/src/user-components/TabItem.astro
index 0fa53d37e1b..c1723744520 100644
--- a/packages/starlight/src/user-components/TabItem.astro
+++ b/packages/starlight/src/user-components/TabItem.astro
@@ -1,5 +1,5 @@
 ---
-import { TabItemTagname } from './rehype-tabs';
+import { TabItemTagname } from './tabs-processor';
 import type { StarlightIcon } from '../components-internals/Icons';
 
 interface Props {
diff --git a/packages/starlight/src/user-components/Tabs.astro b/packages/starlight/src/user-components/Tabs.astro
index fa25d428f01..dae0b05e2c0 100644
--- a/packages/starlight/src/user-components/Tabs.astro
+++ b/packages/starlight/src/user-components/Tabs.astro
@@ -1,6 +1,6 @@
 ---
 import Icon from './Icon.astro';
-import { processPanels } from './rehype-tabs';
+import { processPanels } from './tabs-processor';
 
 interface Props {
 	syncKey?: string;
diff --git a/packages/starlight/src/user-components/file-tree-icons.ts b/packages/starlight/src/user-components/file-tree-icons.ts
index 1de02992b3b..2ac73f7ed4a 100644
--- a/packages/starlight/src/user-components/file-tree-icons.ts
+++ b/packages/starlight/src/user-components/file-tree-icons.ts
@@ -3,7 +3,7 @@
  * Do not edit this file directly as it will be overwritten.
  */
 
-import type { Definitions } from './rehype-file-tree.ts';
+import type { Definitions } from './file-tree-processor.ts';
 
 /**
  * Based on https://github.com/elviswolcott/seti-icons which
diff --git a/packages/starlight/src/user-components/file-tree-processor.ts b/packages/starlight/src/user-components/file-tree-processor.ts
new file mode 100644
index 00000000000..065aed8924f
--- /dev/null
+++ b/packages/starlight/src/user-components/file-tree-processor.ts
@@ -0,0 +1,236 @@
+import { AstroError } from 'astro/errors';
+import type { Element, Root, RootContent, Text } from 'hast';
+import { type Child, h, s } from 'hastscript';
+import { select } from 'hast-util-select';
+import { toHtml } from 'hast-util-to-html';
+import { toString } from 'hast-util-to-string';
+import { htmlToHast } from 'satteri';
+import { CONTINUE, SKIP, visit } from 'unist-util-visit';
+import { Icons, type StarlightIcon } from '../components-internals/Icons';
+import { definitions } from './file-tree-icons';
+
+const folderIcon = makeSVGIcon(Icons['seti:folder']);
+const defaultFileIcon = makeSVGIcon(Icons['seti:default']);
+
+/**
+ * Process the HTML for a file tree to create the necessary markup for each file and directory
+ * including icons.
+ * @param html Inner HTML passed to the `` component.
+ * @param directoryLabel The localized label for a directory.
+ * @returns The processed HTML for the file tree.
+ */
+export function processFileTree(html: string, directoryLabel: string) {
+	const tree = htmlToHast(html, { fragment: true }) as Root;
+
+	validateFileTree(tree);
+
+	visit(tree, 'element', (node) => {
+		// Strip nodes that only contain newlines.
+		node.children = node.children.filter(
+			(child) => child.type === 'comment' || child.type !== 'text' || !/^\n+$/.test(child.value)
+		);
+
+		// Skip over non-list items.
+		if (node.tagName !== 'li') return CONTINUE;
+
+		const [firstChild, ...otherChildren] = node.children;
+
+		// Keep track of comments associated with the current file or directory.
+		const comment: Child[] = [];
+
+		// Extract text comment that follows the file name, e.g. `README.md This is a comment`
+		if (firstChild?.type === 'text') {
+			const [filename, ...fragments] = firstChild.value.split(' ');
+			firstChild.value = filename || '';
+			const textComment = fragments.join(' ').trim();
+			if (textComment.length > 0) {
+				comment.push(fragments.join(' '));
+			}
+		}
+
+		// Comments may not always be entirely part of the first child text node,
+		// e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
+		// nodes would also be children of the list item node.
+		const subTreeIndex = otherChildren.findIndex(
+			(child) => child.type === 'element' && child.tagName === 'ul'
+		);
+		const commentNodes =
+			subTreeIndex > -1 ? otherChildren.slice(0, subTreeIndex) : [...otherChildren];
+		otherChildren.splice(0, subTreeIndex > -1 ? subTreeIndex : otherChildren.length);
+		comment.push(...commentNodes);
+
+		const firstChildTextContent = firstChild ? toString(firstChild) : '';
+
+		// Decide a node is a directory if it ends in a `/` or contains another list.
+		const isDirectory =
+			/\/\s*$/.test(firstChildTextContent) ||
+			otherChildren.some((child) => child.type === 'element' && child.tagName === 'ul');
+		// A placeholder is a node that only contains 3 dots or an ellipsis.
+		const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
+		// A node is highlighted if its first child is bold text, e.g. `**README.md**`.
+		const isHighlighted = firstChild?.type === 'element' && firstChild.tagName === 'strong';
+
+		// Create an icon for the file or directory (placeholder do not have icons).
+		const icon = h('span', isDirectory ? folderIcon : getFileIcon(firstChildTextContent));
+		if (isDirectory) {
+			// Add a screen reader only label for directories before the icon so that it is announced
+			// as such before reading the directory name.
+			icon.children.unshift(h('span', { class: 'sr-only' }, directoryLabel));
+		}
+
+		// Add classes and data attributes to the list item node.
+		node.properties.class = isDirectory ? 'directory' : 'file';
+		if (isPlaceholder) node.properties.class += ' empty';
+
+		// Create the tree entry node that contains the icon, file name and comment which will end up
+		// as the list item’s children.
+		const treeEntryChildren: Child[] = [
+			h('span', { class: isHighlighted ? 'highlight' : '' }, [
+				isPlaceholder ? null : icon,
+				firstChild,
+			]),
+		];
+
+		if (comment.length > 0) {
+			treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
+		}
+
+		const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);
+
+		if (isDirectory) {
+			const hasContents = otherChildren.length > 0;
+
+			node.children = [
+				h('details', { open: hasContents }, [
+					h('summary', treeEntry),
+					...(hasContents ? otherChildren : [h('ul', h('li', '…'))]),
+				]),
+			];
+
+			// Continue down the tree.
+			return CONTINUE;
+		}
+
+		node.children = [treeEntry, ...otherChildren];
+
+		// Files can’t contain further files or directories, so skip iterating children.
+		return SKIP;
+	});
+
+	return toHtml(tree);
+}
+
+/** Make a text node with the pass string as its contents. */
+function makeText(value = ''): Text {
+	return { type: 'text', value };
+}
+
+/** Make a node containing an SVG icon from the passed HTML string. */
+function makeSVGIcon(svgString: string) {
+	return s(
+		'svg',
+		{
+			width: 16,
+			height: 16,
+			class: 'tree-icon',
+			'aria-hidden': 'true',
+			viewBox: '0 0 24 24',
+		},
+		(htmlToHast(svgString, { fragment: true, space: 'svg' }) as Root).children
+	);
+}
+
+/** Return the icon for a file based on its file name. */
+function getFileIcon(fileName: string) {
+	const name = getFileIconName(fileName);
+	if (!name) return defaultFileIcon;
+	if (name in Icons) {
+		const path = Icons[name as StarlightIcon];
+		return makeSVGIcon(path);
+	}
+	return defaultFileIcon;
+}
+
+/** Return the icon name for a file based on its file name. */
+function getFileIconName(fileName: string) {
+	let icon: string | undefined = definitions.files[fileName];
+	if (icon) return icon;
+	icon = getFileIconTypeFromExtension(fileName);
+	if (icon) return icon;
+	for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
+		if (fileName.includes(partial)) return partialIcon;
+	}
+	return icon;
+}
+
+/**
+ * Get an icon from a file name based on its extension.
+ * Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
+ * `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
+ * is not found.
+ */
+function getFileIconTypeFromExtension(fileName: string) {
+	const firstDotIndex = fileName.indexOf('.');
+	if (firstDotIndex === -1) return;
+	let extension = fileName.slice(firstDotIndex);
+	while (extension !== '') {
+		const icon = definitions.extensions[extension];
+		if (icon) return icon;
+		const nextDotIndex = extension.indexOf('.', 1);
+		if (nextDotIndex === -1) return;
+		extension = extension.slice(nextDotIndex);
+	}
+	return;
+}
+
+/** Validate that the user provided HTML for a file tree is valid. */
+function validateFileTree(tree: Root) {
+	const rootElements = tree.children.filter(isElementNode);
+	const [rootElement] = rootElements;
+
+	if (rootElements.length === 0) {
+		throwFileTreeValidationError(
+			'The `` component expects its content to be a single unordered list but found no child elements.'
+		);
+	}
+
+	if (rootElements.length !== 1) {
+		throwFileTreeValidationError(
+			`The \`\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
+				.map((element) => `\`<${element.tagName}>\``)
+				.join(' - ')}.`
+		);
+	}
+
+	if (!rootElement || rootElement.tagName !== 'ul') {
+		throwFileTreeValidationError(
+			`The \`\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
+		);
+	}
+
+	const listItemElement = select('li', rootElement);
+
+	if (!listItemElement) {
+		throwFileTreeValidationError(
+			'The `` component expects its content to be an unordered list with at least one list item.'
+		);
+	}
+}
+
+function isElementNode(node: RootContent): node is Element {
+	return node.type === 'element';
+}
+
+/** Throw a validation error for a file tree linking to the documentation. */
+function throwFileTreeValidationError(message: string): never {
+	throw new AstroError(
+		message,
+		'To learn more about the `` component, see https://starlight.astro.build/components/file-tree/'
+	);
+}
+
+export interface Definitions {
+	files: Record;
+	extensions: Record;
+	partials: Record;
+}
diff --git a/packages/starlight/src/user-components/rehype-file-tree.ts b/packages/starlight/src/user-components/rehype-file-tree.ts
deleted file mode 100644
index cc8ba431df6..00000000000
--- a/packages/starlight/src/user-components/rehype-file-tree.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { AstroError } from 'astro/errors';
-import type { Element, ElementContent, Text } from 'hast';
-import { type Child, h, s } from 'hastscript';
-import { select } from 'hast-util-select';
-import { fromHtml } from 'hast-util-from-html';
-import { toString } from 'hast-util-to-string';
-import { rehype } from 'rehype';
-import { CONTINUE, SKIP, visit } from 'unist-util-visit';
-import { Icons, type StarlightIcon } from '../components-internals/Icons';
-import { definitions } from './file-tree-icons';
-
-declare module 'vfile' {
-	interface DataMap {
-		directoryLabel: string;
-	}
-}
-
-const folderIcon = makeSVGIcon(Icons['seti:folder']);
-const defaultFileIcon = makeSVGIcon(Icons['seti:default']);
-
-/**
- * Process the HTML for a file tree to create the necessary markup for each file and directory
- * including icons.
- * @param html Inner HTML passed to the `` component.
- * @param directoryLabel The localized label for a directory.
- * @returns The processed HTML for the file tree.
- */
-export function processFileTree(html: string, directoryLabel: string) {
-	const file = fileTreeProcessor.processSync({ data: { directoryLabel }, value: html });
-
-	return file.toString();
-}
-
-/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
-const fileTreeProcessor = rehype()
-	.data('settings', { fragment: true })
-	.use(function fileTree() {
-		return (tree: Element, file) => {
-			const { directoryLabel } = file.data;
-
-			validateFileTree(tree);
-
-			visit(tree, 'element', (node) => {
-				// Strip nodes that only contain newlines.
-				node.children = node.children.filter(
-					(child) => child.type === 'comment' || child.type !== 'text' || !/^\n+$/.test(child.value)
-				);
-
-				// Skip over non-list items.
-				if (node.tagName !== 'li') return CONTINUE;
-
-				const [firstChild, ...otherChildren] = node.children;
-
-				// Keep track of comments associated with the current file or directory.
-				const comment: Child[] = [];
-
-				// Extract text comment that follows the file name, e.g. `README.md This is a comment`
-				if (firstChild?.type === 'text') {
-					const [filename, ...fragments] = firstChild.value.split(' ');
-					firstChild.value = filename || '';
-					const textComment = fragments.join(' ').trim();
-					if (textComment.length > 0) {
-						comment.push(fragments.join(' '));
-					}
-				}
-
-				// Comments may not always be entirely part of the first child text node,
-				// e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
-				// nodes would also be children of the list item node.
-				const subTreeIndex = otherChildren.findIndex(
-					(child) => child.type === 'element' && child.tagName === 'ul'
-				);
-				const commentNodes =
-					subTreeIndex > -1 ? otherChildren.slice(0, subTreeIndex) : [...otherChildren];
-				otherChildren.splice(0, subTreeIndex > -1 ? subTreeIndex : otherChildren.length);
-				comment.push(...commentNodes);
-
-				const firstChildTextContent = firstChild ? toString(firstChild) : '';
-
-				// Decide a node is a directory if it ends in a `/` or contains another list.
-				const isDirectory =
-					/\/\s*$/.test(firstChildTextContent) ||
-					otherChildren.some((child) => child.type === 'element' && child.tagName === 'ul');
-				// A placeholder is a node that only contains 3 dots or an ellipsis.
-				const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
-				// A node is highlighted if its first child is bold text, e.g. `**README.md**`.
-				const isHighlighted = firstChild?.type === 'element' && firstChild.tagName === 'strong';
-
-				// Create an icon for the file or directory (placeholder do not have icons).
-				const icon = h('span', isDirectory ? folderIcon : getFileIcon(firstChildTextContent));
-				if (isDirectory) {
-					// Add a screen reader only label for directories before the icon so that it is announced
-					// as such before reading the directory name.
-					icon.children.unshift(h('span', { class: 'sr-only' }, directoryLabel));
-				}
-
-				// Add classes and data attributes to the list item node.
-				node.properties.class = isDirectory ? 'directory' : 'file';
-				if (isPlaceholder) node.properties.class += ' empty';
-
-				// Create the tree entry node that contains the icon, file name and comment which will end up
-				// as the list item’s children.
-				const treeEntryChildren: Child[] = [
-					h('span', { class: isHighlighted ? 'highlight' : '' }, [
-						isPlaceholder ? null : icon,
-						firstChild,
-					]),
-				];
-
-				if (comment.length > 0) {
-					treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
-				}
-
-				const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);
-
-				if (isDirectory) {
-					const hasContents = otherChildren.length > 0;
-
-					node.children = [
-						h('details', { open: hasContents }, [
-							h('summary', treeEntry),
-							...(hasContents ? otherChildren : [h('ul', h('li', '…'))]),
-						]),
-					];
-
-					// Continue down the tree.
-					return CONTINUE;
-				}
-
-				node.children = [treeEntry, ...otherChildren];
-
-				// Files can’t contain further files or directories, so skip iterating children.
-				return SKIP;
-			});
-		};
-	});
-
-/** Make a text node with the pass string as its contents. */
-function makeText(value = ''): Text {
-	return { type: 'text', value };
-}
-
-/** Make a node containing an SVG icon from the passed HTML string. */
-function makeSVGIcon(svgString: string) {
-	return s(
-		'svg',
-		{
-			width: 16,
-			height: 16,
-			class: 'tree-icon',
-			'aria-hidden': 'true',
-			viewBox: '0 0 24 24',
-		},
-		fromHtml(svgString, { fragment: true })
-	);
-}
-
-/** Return the icon for a file based on its file name. */
-function getFileIcon(fileName: string) {
-	const name = getFileIconName(fileName);
-	if (!name) return defaultFileIcon;
-	if (name in Icons) {
-		const path = Icons[name as StarlightIcon];
-		return makeSVGIcon(path);
-	}
-	return defaultFileIcon;
-}
-
-/** Return the icon name for a file based on its file name. */
-function getFileIconName(fileName: string) {
-	let icon: string | undefined = definitions.files[fileName];
-	if (icon) return icon;
-	icon = getFileIconTypeFromExtension(fileName);
-	if (icon) return icon;
-	for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
-		if (fileName.includes(partial)) return partialIcon;
-	}
-	return icon;
-}
-
-/**
- * Get an icon from a file name based on its extension.
- * Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
- * `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
- * is not found.
- */
-function getFileIconTypeFromExtension(fileName: string) {
-	const firstDotIndex = fileName.indexOf('.');
-	if (firstDotIndex === -1) return;
-	let extension = fileName.slice(firstDotIndex);
-	while (extension !== '') {
-		const icon = definitions.extensions[extension];
-		if (icon) return icon;
-		const nextDotIndex = extension.indexOf('.', 1);
-		if (nextDotIndex === -1) return;
-		extension = extension.slice(nextDotIndex);
-	}
-	return;
-}
-
-/** Validate that the user provided HTML for a file tree is valid. */
-function validateFileTree(tree: Element) {
-	const rootElements = tree.children.filter(isElementNode);
-	const [rootElement] = rootElements;
-
-	if (rootElements.length === 0) {
-		throwFileTreeValidationError(
-			'The `` component expects its content to be a single unordered list but found no child elements.'
-		);
-	}
-
-	if (rootElements.length !== 1) {
-		throwFileTreeValidationError(
-			`The \`\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
-				.map((element) => `\`<${element.tagName}>\``)
-				.join(' - ')}.`
-		);
-	}
-
-	if (!rootElement || rootElement.tagName !== 'ul') {
-		throwFileTreeValidationError(
-			`The \`\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
-		);
-	}
-
-	const listItemElement = select('li', rootElement);
-
-	if (!listItemElement) {
-		throwFileTreeValidationError(
-			'The `` component expects its content to be an unordered list with at least one list item.'
-		);
-	}
-}
-
-function isElementNode(node: ElementContent): node is Element {
-	return node.type === 'element';
-}
-
-/** Throw a validation error for a file tree linking to the documentation. */
-function throwFileTreeValidationError(message: string): never {
-	throw new AstroError(
-		message,
-		'To learn more about the `` component, see https://starlight.astro.build/components/file-tree/'
-	);
-}
-
-export interface Definitions {
-	files: Record;
-	extensions: Record;
-	partials: Record;
-}
diff --git a/packages/starlight/src/user-components/rehype-steps.ts b/packages/starlight/src/user-components/rehype-steps.ts
deleted file mode 100644
index 0c6030704b7..00000000000
--- a/packages/starlight/src/user-components/rehype-steps.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { AstroError } from 'astro/errors';
-import type { Element, Root } from 'hast';
-import { rehype } from 'rehype';
-import rehypeFormat from 'rehype-format';
-import type { VFile } from 'vfile';
-
-const prettyPrintProcessor = rehype().data('settings', { fragment: true }).use(rehypeFormat);
-const prettyPrintHtml = (html: string) =>
-	prettyPrintProcessor.processSync({ value: html }).toString();
-
-const stepsProcessor = rehype()
-	.data('settings', { fragment: true })
-	.use(function steps() {
-		return (tree: Root, vfile: VFile) => {
-			const rootElements = tree.children.filter(
-				(item): item is Element =>
-					item.type === 'element' &&
-					// Since Astro 5.16.9, `