diff --git a/.changeset/agile-eyes-fold.md b/.changeset/agile-eyes-fold.md new file mode 100644 index 00000000000..82669ab60f8 --- /dev/null +++ b/.changeset/agile-eyes-fold.md @@ -0,0 +1,27 @@ +--- +'@astrojs/starlight': minor +--- + +Adds support for Astro v6, drops support for Astro v5. + +#### Upgrade Astro and dependencies + +⚠️ **BREAKING CHANGE:** Astro v5 is no longer supported. Make sure you [update Astro](https://docs.astro.build/en/guides/upgrade-to/v6/) and any other official integrations at the same time as updating Starlight: + +```sh +npx @astrojs/upgrade +``` + +_Community Starlight plugins and Astro integrations may also need to be manually updated to work with Astro v6. If you encounter any issues, please reach out to the plugin or integration author to see if it is a known issue or if an updated version is being worked on._ + +#### Update your collections + +⚠️ **BREAKING CHANGE:** Drops support for content collections backwards compatibility. + +In Astro 5.x, projects could delay upgrading to the new Content Layer API introduced for content collections because of some existing automatic backwards compatibility that was not previously behind a flag. This meant that it was possible to upgrade from Astro 4 to Astro 5 without updating your content collections, even if you had not enabled the `legacy.collections` flag. Projects would continue to build, and no errors or warnings would be displayed. + +Astro v6.0 now removes this automatic legacy content collections support, along with the `legacy.collections` flag. + +If you experience content collections errors after updating to v6, [check your project for any removed legacy features](https://docs.astro.build/en/guides/upgrade-to/v6/#if-you-have) that may need updating to the Content Layer API. See [the Starlight v0.30.0 upgrade guide](https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md#0300) for detailed instructions on upgrading legacy collections to the new Content Layer API. + +If you are unable to make any changes to your collections at this time, including Starlight's default `docs` and `i18n` collections, you can enable the [`legacy.collectionsBackwardsCompat` flag](https://docs.astro.build/en/reference/legacy-flags/#collectionsbackwardscompat) to upgrade to v6 without updating your collections. This temporary flag preserves some legacy v4 content collections features, and will allow you to keep your collections in their current state until the legacy flag is no longer supported. diff --git a/.changeset/dry-bikes-beg.md b/.changeset/dry-bikes-beg.md new file mode 100644 index 00000000000..80a93eced16 --- /dev/null +++ b/.changeset/dry-bikes-beg.md @@ -0,0 +1,11 @@ +--- +'@astrojs/starlight-markdoc': minor +--- + +⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now 0.38.0 + +Please use the `@astrojs/upgrade` command to upgrade your project: + +```sh +npx @astrojs/upgrade +``` diff --git a/.changeset/hot-dryers-drop.md b/.changeset/hot-dryers-drop.md new file mode 100644 index 00000000000..dd321c8c410 --- /dev/null +++ b/.changeset/hot-dryers-drop.md @@ -0,0 +1,11 @@ +--- +'@astrojs/starlight-tailwind': major +--- + +⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now 0.38.0 + +Please use the `@astrojs/upgrade` command to upgrade your project: + +```sh +npx @astrojs/upgrade +``` diff --git a/.changeset/spicy-moons-sell.md b/.changeset/spicy-moons-sell.md new file mode 100644 index 00000000000..a234d997761 --- /dev/null +++ b/.changeset/spicy-moons-sell.md @@ -0,0 +1,12 @@ +--- +'@astrojs/starlight-docsearch': minor +--- + +⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now 0.38.0 + +Please use the `@astrojs/upgrade` command to upgrade your project: + +```sh +npx @astrojs/upgrade +``` + diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 207f5d818bb..2c0921d2b61 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # Based on https://github.com/withastro/astro/blob/main/.devcontainer/Dockerfile -FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 +FROM mcr.microsoft.com/devcontainers/javascript-node:0-22 # We uninstall pnpm here, since we enable the corepack version in the postCreateCommand # This ensures we respect the "packageManager" version in package.json diff --git a/.github/ISSUE_TEMPLATE/---01-bug-report.yml b/.github/ISSUE_TEMPLATE/---01-bug-report.yml index 8bdef90cc47..75a9722ae7e 100644 --- a/.github/ISSUE_TEMPLATE/---01-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/---01-bug-report.yml @@ -9,7 +9,7 @@ body: Thank you for taking the time to file a bug report! Please fill out this form as completely as possible. ✅ I am using the **latest versions of Starlight and Astro**. - ✅ I am using a version of Node that supports ESM (`v14.18.0+`, or `v16.12.0+`). + ✅ I am using a compatible version of Node.js (`v22.12.0+`). - type: input id: starlight-version attributes: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ad680c928..7405971ec41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: true env: - NODE_VERSION: 18 + NODE_VERSION: 22 ASTRO_TELEMETRY_DISABLED: true jobs: @@ -69,9 +69,6 @@ jobs: - run: pnpm i - name: Test packages run: pnpm -r test:coverage - - name: Test legacy collections support - working-directory: packages/starlight - run: pnpm test:legacy e2e-test: name: 'Run E2E tests (${{ matrix.os }})' diff --git a/.github/workflows/file-icons.yml b/.github/workflows/file-icons.yml index 9f16c224805..5d6abc01db3 100644 --- a/.github/workflows/file-icons.yml +++ b/.github/workflows/file-icons.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 18.20.8 + node-version: 22.12.0 cache: 'pnpm' - name: Install Dependencies diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index a977b4457d5..3d9a31037fa 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 18.20.8 + node-version: 22.12.0 cache: 'pnpm' - run: pnpm i - name: Format with Prettier diff --git a/.github/workflows/lunaria.yml b/.github/workflows/lunaria.yml index af994ab1b21..9becaa0a778 100644 --- a/.github/workflows/lunaria.yml +++ b/.github/workflows/lunaria.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20.19.4 + node-version: 22.12.0 cache: pnpm - name: Install dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 07412463a20..2aef4e43f37 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -9,6 +9,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }} cancel-in-progress: true +env: + NODE_VERSION: 22 + jobs: # This basic check runs size-limit for the current branch. # It will fail if the branch pushes the size over the specified budget. @@ -19,6 +22,11 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' - run: pnpm i - run: 'pnpm build:examples' - run: pnpm size @@ -32,6 +40,11 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + - name: Setup Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' - name: Run size-limit uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0 with: diff --git a/docs/__a11y__/test-utils.ts b/docs/__a11y__/test-utils.ts index 60a1941744e..cb475526234 100644 --- a/docs/__a11y__/test-utils.ts +++ b/docs/__a11y__/test-utils.ts @@ -9,7 +9,7 @@ import Sitemapper from 'sitemapper'; // We use the Lunaria config to get the list of languages rather than the Astro config as importing // the latter does not play well with Playwright. -import lunariaConfig from '../lunaria.config.json' assert { type: 'json' }; +import lunariaConfig from '../lunaria.config.json' with { type: 'json' }; export { expect, type Locator } from '@playwright/test'; @@ -72,7 +72,11 @@ export const test = baseTest.extend<{ // A Playwright test fixture accessible from within all tests. class DocsSite { - constructor(private readonly page: Page) {} + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } async getAllUrls() { const sitemap = new Sitemapper({ url: config.sitemap.url }); @@ -142,18 +146,29 @@ class DocsSite { } function landmarkUniqueNodeMatcher(node: ViolationNode) { - /** - * Ignore the `landmark-unique` violation only if the node HTML is an aside. - * - * The best action to fix this violation would be to remove the landmark altogether as it's not - * necessary in this case and switch to the `note` role. Although, this is not possible at the - * moment due to an issue with NVDA not announcing it and also skipping the associated label for - * a role not supported. - * - * @see https://github.com/nvaccess/nvda/issues/10439 - * @see https://github.com/withastro/starlight/pull/2503 - */ - return !/^]* class="starlight-aside[^>]*>$/.test(node.html); + // Ignore some `landmark-unique` violations. + return ( + /** + * Asides: the best action to fix this violation would be to remove the landmark altogether as + * it's not necessary in this case and switch to the `note` role. Although, this is not possible + * at the moment due to an issue with NVDA not announcing it and also skipping the associated + * label for a role not supported. + * + * @see https://github.com/nvaccess/nvda/issues/10439 + * @see https://github.com/withastro/starlight/pull/2503 + */ + !/^]* class="starlight-aside[^>]*>$/.test(node.html) && + /** + * Expressive Code `
` blocks: EC 0.41.3 introduced a change adding the `region` role to
+		 * scrollable code blocks. The best action to fix this violation would potentially to switch to
+		 * another role, e.g. `group`, and adding `aria-label` or `aria-labelledby` to provide a generic
+		 * label, e.g. `'Horizontally scrollable code'`.
+		 *
+		 * @see https://github.com/expressive-code/expressive-code/pull/343
+		 * @see https://github.com/expressive-code/expressive-code/pull/348
+		 */
+		!/^]* data-language[^>]* role="region"[^>]*>$/.test(node.html)
+	);
 }
 
 interface Config {
diff --git a/docs/package.json b/docs/package.json
index 2059be1dc02..3574705b7e9 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -16,16 +16,16 @@
     "grammars": "node grammars/generate.mjs"
   },
   "dependencies": {
-    "@astro-community/astro-embed-youtube": "^0.5.6",
+    "@astro-community/astro-embed-youtube": "^0.5.10",
     "@astrojs/starlight": "workspace:*",
     "@lunariajs/core": "^0.1.1",
     "@types/culori": "^2.1.1",
-    "astro": "^5.6.1",
+    "astro": "^6.0.1",
     "culori": "^4.0.1",
     "sharp": "^0.34.2"
   },
   "devDependencies": {
-    "@playwright/test": "^1.45.0",
+    "@playwright/test": "^1.57.0",
     "axe-playwright": "^2.0.3",
     "sitemapper": "^3.2.12",
     "starlight-links-validator": "^0.14.0"
diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts
index e47e819f524..da1874ab1fe 100644
--- a/docs/src/content.config.ts
+++ b/docs/src/content.config.ts
@@ -1,4 +1,5 @@
-import { defineCollection, z } from 'astro:content';
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
 import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders';
 import { docsSchema, i18nSchema } from '@astrojs/starlight/schema';
 
diff --git a/docs/src/content/docs/guides/i18n.mdx b/docs/src/content/docs/guides/i18n.mdx
index e305de8a336..6e70b47b1b6 100644
--- a/docs/src/content/docs/guides/i18n.mdx
+++ b/docs/src/content/docs/guides/i18n.mdx
@@ -259,7 +259,8 @@ In the following example, a new, optional `custom.label` key is added to the def
 
 ```diff lang="js"
 // src/content.config.ts
-import { defineCollection, z } from 'astro:content';
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
 import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders';
 import { docsSchema, i18nSchema } from '@astrojs/starlight/schema';
 
diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx
index 270eabaf15f..411a1881a5e 100644
--- a/docs/src/content/docs/guides/sidebar.mdx
+++ b/docs/src/content/docs/guides/sidebar.mdx
@@ -191,7 +191,7 @@ The configuration above generates the following sidebar:
 Starlight can automatically generate a group in your sidebar based on a directory of your docs.
 This is helpful when you do not want to manually enter each sidebar item in a group.
 
-By default, pages are sorted in alphabetical order according to the file [`slug`](/reference/route-data/#slug).
+By default, pages are sorted in alphabetical order according to the file [`id`](/reference/route-data/#id).
 
 Add an autogenerated group using an object with `label` and `autogenerate` properties. Your `autogenerate` configuration must specify the `directory` to use for sidebar entries. For example, with the following configuration:
 
diff --git a/docs/src/content/docs/manual-setup.mdx b/docs/src/content/docs/manual-setup.mdx
index 7b8fbe5dbfb..c1a9c0f940a 100644
--- a/docs/src/content/docs/manual-setup.mdx
+++ b/docs/src/content/docs/manual-setup.mdx
@@ -77,8 +77,8 @@ export const collections = {
 };
 ```
 
-Starlight also supports the [`legacy.collections` flag](https://docs.astro.build/en/reference/legacy-flags/) where collections are handled using the legacy content collections implementation.
-This is useful if you have an existing Astro project and are unable to make any changes to collections at this time to use a loader.
+Starlight also supports the [`legacy.collectionsBackwardsCompat` flag](https://docs.astro.build/en/reference/legacy-flags/#collectionsbackwardscompat) which preserves some legacy v4 content collections features.
+This is useful if you have an existing Astro project and are unable to make any changes to collections at this time to migrate to the Content Layer API introduced in v5.0.
 
 ### Add content
 
@@ -132,3 +132,5 @@ In the future, we plan to support this use case better to avoid the need for the
 To enable SSR, follow the [“On-demand Rendering Adapters”](https://docs.astro.build/en/guides/on-demand-rendering/) guide in Astro’s docs to add a server adapter to your Starlight project.
 
 Documentation pages generated by Starlight are pre-rendered by default regardless of your project's output mode. To opt out of pre-rendering your Starlight pages, set the [`prerender` config option](/reference/configuration/#prerender) to `false`.
+
+If you are using the [Cloudflare adapter](https://docs.astro.build/en/guides/integrations-guide/cloudflare/) to enable server-rendering in your documentation project, make sure to also [add the `nodejs_compat` compatibility flag to your Wrangler configuration file](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#get-started).
diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md
index b8028a7d298..31e361093cf 100644
--- a/docs/src/content/docs/reference/frontmatter.md
+++ b/docs/src/content/docs/reference/frontmatter.md
@@ -428,9 +428,10 @@ The value should be a [Zod schema](https://docs.astro.build/en/guides/content-co
 
 In the following example, we provide a stricter type for `description` to make it required and add a new optional `category` field:
 
-```ts {10-15}
+```ts {11-16}
 // src/content.config.ts
-import { defineCollection, z } from 'astro:content';
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
 import { docsLoader } from '@astrojs/starlight/loaders';
 import { docsSchema } from '@astrojs/starlight/schema';
 
@@ -451,9 +452,10 @@ export const collections = {
 
 To take advantage of the [Astro `image()` helper](https://docs.astro.build/en/guides/images/#images-in-content-collections), use a function that returns your schema extension:
 
-```ts {10-15}
+```ts {11-16}
 // src/content.config.ts
-import { defineCollection, z } from 'astro:content';
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
 import { docsLoader } from '@astrojs/starlight/loaders';
 import { docsSchema } from '@astrojs/starlight/schema';
 
diff --git a/docs/src/content/docs/reference/route-data.mdx b/docs/src/content/docs/reference/route-data.mdx
index 5b53eafb8e9..35a9b568534 100644
--- a/docs/src/content/docs/reference/route-data.mdx
+++ b/docs/src/content/docs/reference/route-data.mdx
@@ -62,20 +62,11 @@ The site title for this page’s locale.
 The value for the site title’s `href` attribute, linking back to the homepage, e.g. `/`.
 For multilingual sites this will include the current locale, e.g. `/en/` or `/zh-cn/`.
 
-### `slug`
-
-**Type:** `string`
-
-The slug for this page generated from the content filename.
-
-This property is deprecated and will be removed in a future version of Starlight.
-Migrate to the new Content Layer API by using [Starlight’s `docsLoader`](/manual-setup/#configure-content-collections) and use the [`id`](#id) property instead.
-
 ### `id`
 
 **Type:** `string`
 
-The slug for this page or the unique ID for this page based on the content filename if using the [`legacy.collections`](https://docs.astro.build/en/reference/legacy-flags/#collections) flag.
+The slug for this page.
 
 ### `isFallback`
 
diff --git a/examples/basics/package.json b/examples/basics/package.json
index 5edbb084e01..edf8e5f59ea 100644
--- a/examples/basics/package.json
+++ b/examples/basics/package.json
@@ -12,7 +12,7 @@
   },
   "dependencies": {
     "@astrojs/starlight": "^0.37.7",
-    "astro": "^5.6.1",
+    "astro": "^6.0.1",
     "sharp": "^0.34.2"
   }
 }
diff --git a/examples/markdoc/package.json b/examples/markdoc/package.json
index 57764b73de8..7e310ce6f95 100644
--- a/examples/markdoc/package.json
+++ b/examples/markdoc/package.json
@@ -11,10 +11,10 @@
     "astro": "astro"
   },
   "dependencies": {
-    "@astrojs/markdoc": "^0.13.3",
+    "@astrojs/markdoc": "^1.0.0",
     "@astrojs/starlight": "^0.37.7",
     "@astrojs/starlight-markdoc": "^0.5.1",
-    "astro": "^5.6.1",
+    "astro": "^6.0.1",
     "sharp": "^0.34.2"
   }
 }
diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json
index fa6d53e573c..b6b222aea18 100644
--- a/examples/tailwind/package.json
+++ b/examples/tailwind/package.json
@@ -13,9 +13,9 @@
   "dependencies": {
     "@astrojs/starlight": "^0.37.7",
     "@astrojs/starlight-tailwind": "^4.0.2",
-    "@tailwindcss/vite": "^4.0.7",
-    "astro": "^5.6.1",
+    "@tailwindcss/vite": "^4.1.18",
+    "astro": "^6.0.1",
     "sharp": "^0.34.2",
-    "tailwindcss": "^4.0.7"
+    "tailwindcss": "^4.1.18"
   }
 }
diff --git a/package.json b/package.json
index 08bb9eebf47..147a9e7bce7 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,12 @@
   },
   "license": "MIT",
   "devDependencies": {
-    "@astrojs/check": "^0.9.4",
+    "@astrojs/check": "^0.9.7",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.9",
     "@eslint/js": "^9.33.0",
     "@size-limit/file": "^11.1.6",
-    "astro": "^5.6.1",
+    "astro": "^6.0.1",
     "eslint": "^9.33.0",
     "eslint-config-prettier": "^10.1.8",
     "globals": "^16.3.0",
@@ -33,7 +33,7 @@
     {
       "name": "/index.html",
       "path": "examples/basics/dist/index.html",
-      "limit": "10 kB",
+      "limit": "8 kB",
       "gzip": true
     },
     {
@@ -48,7 +48,7 @@
         "examples/basics/dist/_astro/*.css",
         "!examples/basics/dist/_astro/print.*.css"
       ],
-      "limit": "14.75 kB",
+      "limit": "16.75 kB",
       "gzip": true
     }
   ],
@@ -59,6 +59,9 @@
         "playwright",
         "search-insights"
       ]
+    },
+    "patchedDependencies": {
+      "starlight-links-validator": "patches/starlight-links-validator.patch"
     }
   }
 }
diff --git a/packages/docsearch/index.ts b/packages/docsearch/index.ts
index e8cc7e5df26..c727d174e8e 100644
--- a/packages/docsearch/index.ts
+++ b/packages/docsearch/index.ts
@@ -5,6 +5,13 @@ import { resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { z } from 'astro/zod';
 
+const moduleId = 'virtual:starlight/docsearch-config';
+const resolvedModuleId = `\0${moduleId}`;
+
+// https://vite.dev/guide/api-plugin#hook-filters
+const pluginResolveIdIdFilter = new RegExp(`^${moduleId}$`);
+const pluginLoadIdFilter = new RegExp(`^${resolvedModuleId}$`);
+
 export type DocSearchClientOptions = Omit<
 	Parameters[0],
 	'container' | 'translations'
@@ -14,7 +21,7 @@ type SearchOptions = DocSearchClientOptions['searchParameters'];
 
 /** DocSearch configuration options. */
 const DocSearchConfigSchema = z
-	.object({
+	.strictObject({
 		// Required config without which DocSearch won’t work.
 		/** Your Algolia application ID. */
 		appId: z.string(),
@@ -42,49 +49,46 @@ const DocSearchConfigSchema = z
 		 * The Algolia Search Parameters.
 		 * @see https://www.algolia.com/doc/api-reference/search-api-parameters/
 		 */
-		searchParameters: z.custom(),
+		searchParameters: z.custom().optional(),
 	})
-	.strict()
 	.or(
-		z
-			.object({
-				/**
-				 * The path to a JavaScript or TypeScript file containing a default export of options to
-				 * pass to the DocSearch client.
-				 *
-				 * The value can be a path to a local JS/TS file relative to the root of your project,
-				 * e.g. `'/src/docsearch.js'`, or an npm module specifier for a package you installed,
-				 * e.g. `'@company/docsearch-config'`.
-				 *
-				 * Use `clientOptionsModule` when you need to configure options that are not serializable,
-				 * such as `transformSearchClient()` or `resultsFooterComponent()`.
-				 *
-				 * When `clientOptionsModule` is set, all options must be set via the module file. Other
-				 * inline options passed to the plugin in `astro.config.mjs` will be ignored.
-				 *
-				 * @see https://docsearch.algolia.com/docs/api
-				 *
-				 * @example
-				 * // astro.config.mjs
-				 * // ...
-				 * starlightDocSearch({ clientOptionsModule: './src/config/docsearch.ts' }),
-				 * // ...
-				 *
-				 * // src/config/docsearch.ts
-				 * import type { DocSearchClientOptions } from '@astrojs/starlight-docsearch';
-				 *
-				 * export default {
-				 *   appId: '...',
-				 *   apiKey: '...',
-				 *   indexName: '...',
-				 *   getMissingResultsUrl({ query }) {
-				 *     return `https://github.com/algolia/docsearch/issues/new?title=${query}`;
-				 *   },
-				 * } satisfies DocSearchClientOptions;
-				 */
-				clientOptionsModule: z.string(),
-			})
-			.strict()
+		z.strictObject({
+			/**
+			 * The path to a JavaScript or TypeScript file containing a default export of options to
+			 * pass to the DocSearch client.
+			 *
+			 * The value can be a path to a local JS/TS file relative to the root of your project,
+			 * e.g. `'/src/docsearch.js'`, or an npm module specifier for a package you installed,
+			 * e.g. `'@company/docsearch-config'`.
+			 *
+			 * Use `clientOptionsModule` when you need to configure options that are not serializable,
+			 * such as `transformSearchClient()` or `resultsFooterComponent()`.
+			 *
+			 * When `clientOptionsModule` is set, all options must be set via the module file. Other
+			 * inline options passed to the plugin in `astro.config.mjs` will be ignored.
+			 *
+			 * @see https://docsearch.algolia.com/docs/api
+			 *
+			 * @example
+			 * // astro.config.mjs
+			 * // ...
+			 * starlightDocSearch({ clientOptionsModule: './src/config/docsearch.ts' }),
+			 * // ...
+			 *
+			 * // src/config/docsearch.ts
+			 * import type { DocSearchClientOptions } from '@astrojs/starlight-docsearch';
+			 *
+			 * export default {
+			 *   appId: '...',
+			 *   apiKey: '...',
+			 *   indexName: '...',
+			 *   getMissingResultsUrl({ query }) {
+			 *     return `https://github.com/algolia/docsearch/issues/new?title=${query}`;
+			 *   },
+			 * } satisfies DocSearchClientOptions;
+			 */
+			clientOptionsModule: z.string(),
+		})
 	);
 
 type DocSearchUserConfig = z.infer;
@@ -136,9 +140,6 @@ export default function starlightDocSearch(userConfig: DocSearchUserConfig): Sta
 
 /** Vite plugin that exposes the DocSearch config via virtual modules. */
 function vitePluginDocSearch(root: URL, config: DocSearchUserConfig): VitePlugin {
-	const moduleId = 'virtual:starlight/docsearch-config';
-	const resolvedModuleId = `\0${moduleId}`;
-
 	const resolveId = (id: string, base = root) =>
 		JSON.stringify(id.startsWith('.') ? resolve(fileURLToPath(base), id) : id);
 
@@ -152,11 +153,17 @@ function vitePluginDocSearch(root: URL, config: DocSearchUserConfig): VitePlugin
 
 	return {
 		name: 'vite-plugin-starlight-docsearch-config',
-		load(id) {
-			return id === resolvedModuleId ? moduleContent : undefined;
+		load: {
+			filter: { id: pluginLoadIdFilter },
+			handler(id) {
+				return id === resolvedModuleId ? moduleContent : undefined;
+			},
 		},
-		resolveId(id) {
-			return id === moduleId ? resolvedModuleId : undefined;
+		resolveId: {
+			filter: { id: pluginResolveIdIdFilter },
+			handler(id) {
+				return id === moduleId ? resolvedModuleId : undefined;
+			},
 		},
 	};
 }
diff --git a/packages/docsearch/package.json b/packages/docsearch/package.json
index b2f7c380551..7dca3d079db 100644
--- a/packages/docsearch/package.json
+++ b/packages/docsearch/package.json
@@ -25,7 +25,7 @@
     "./schema": "./schema.ts"
   },
   "peerDependencies": {
-    "@astrojs/starlight": ">=0.32.0"
+    "@astrojs/starlight": ">=0.38.0"
   },
   "dependencies": {
     "@docsearch/css": "^3.6.0",
diff --git a/packages/markdoc/package.json b/packages/markdoc/package.json
index a69d69d9d1d..f26f8f6cab4 100644
--- a/packages/markdoc/package.json
+++ b/packages/markdoc/package.json
@@ -17,13 +17,13 @@
     "./components": "./components.ts"
   },
   "devDependencies": {
-    "@astrojs/markdoc": "^0.13.3",
+    "@astrojs/markdoc": "^1.0.0",
     "@astrojs/starlight": "workspace:*",
-    "vitest": "^3.0.5"
+    "vitest": "^4.1.0-beta.6"
   },
   "peerDependencies": {
-    "@astrojs/markdoc": ">=0.12.1",
-    "@astrojs/starlight": ">=0.35.0"
+    "@astrojs/markdoc": "^1.0.0",
+    "@astrojs/starlight": ">=0.38.0"
   },
   "publishConfig": {
     "provenance": true
diff --git a/packages/starlight/__e2e__/fixtures/basics/package.json b/packages/starlight/__e2e__/fixtures/basics/package.json
index 1880d32fd4a..013a6dcb74f 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": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/fixtures/basics/src/content.config.ts b/packages/starlight/__e2e__/fixtures/basics/src/content.config.ts
index 471dfd376d6..da52cf87263 100644
--- a/packages/starlight/__e2e__/fixtures/basics/src/content.config.ts
+++ b/packages/starlight/__e2e__/fixtures/basics/src/content.config.ts
@@ -1,4 +1,5 @@
-import { defineCollection, z } from 'astro:content';
+import { defineCollection } from 'astro:content';
+import { z } from 'astro/zod';
 import { docsLoader } from '@astrojs/starlight/loaders';
 import { docsSchema } from '@astrojs/starlight/schema';
 import { glob } from 'astro/loaders';
diff --git a/packages/starlight/__e2e__/fixtures/custom src-dir/package.json b/packages/starlight/__e2e__/fixtures/custom src-dir/package.json
index 9edbcca8267..32dc4b14468 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": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/fixtures/git/package.json b/packages/starlight/__e2e__/fixtures/git/package.json
index ca06d7c9ec2..f15058e9ef7 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": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/astro.config.mjs b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/astro.config.mjs
similarity index 66%
rename from packages/starlight/__e2e__/fixtures/legacy-collection-config-file/astro.config.mjs
rename to packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/astro.config.mjs
index 02d3655e401..b717082fa79 100644
--- a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/astro.config.mjs
+++ b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/astro.config.mjs
@@ -3,9 +3,12 @@ import starlight from '@astrojs/starlight';
 import { defineConfig } from 'astro/config';
 
 export default defineConfig({
+	legacy: {
+		collectionsBackwardsCompat: true,
+	},
 	integrations: [
 		starlight({
-			title: 'Legacy collection config file',
+			title: 'Legacy collections backwards compat',
 			pagefind: false,
 		}),
 	],
diff --git a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/package.json b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json
similarity index 59%
rename from packages/starlight/__e2e__/fixtures/legacy-collection-config-file/package.json
rename to packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json
index 09c3991e254..6dcb61e3dad 100644
--- a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/package.json
+++ b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/package.json
@@ -1,9 +1,9 @@
 {
-  "name": "@e2e/legacy-collection-config-file",
+  "name": "@e2e/legacy-collections-backwards-compat",
   "version": "0.0.0",
   "private": true,
   "dependencies": {
     "@astrojs/starlight": "workspace:*",
-    "astro": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/src/content/config.ts b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/src/content/config.ts
similarity index 100%
rename from packages/starlight/__e2e__/fixtures/legacy-collection-config-file/src/content/config.ts
rename to packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/src/content/config.ts
diff --git a/packages/starlight/__e2e__/fixtures/legacy-collection-config-file/src/pages/custom.astro b/packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/src/pages/custom.astro
similarity index 100%
rename from packages/starlight/__e2e__/fixtures/legacy-collection-config-file/src/pages/custom.astro
rename to packages/starlight/__e2e__/fixtures/legacy-collections-backwards-compat/src/pages/custom.astro
diff --git a/packages/starlight/__e2e__/fixtures/no-node-builtins/package.json b/packages/starlight/__e2e__/fixtures/no-node-builtins/package.json
index 5555ae1fdf8..6bbd1d3e97f 100644
--- a/packages/starlight/__e2e__/fixtures/no-node-builtins/package.json
+++ b/packages/starlight/__e2e__/fixtures/no-node-builtins/package.json
@@ -2,8 +2,9 @@
   "name": "@e2e/no-node-builtins",
   "version": "0.0.0",
   "private": true,
+  "type": "module",
   "dependencies": {
     "@astrojs/starlight": "workspace:*",
-    "astro": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/fixtures/ssr/package.json b/packages/starlight/__e2e__/fixtures/ssr/package.json
index d93d95aa3c7..04b7a67c1a9 100644
--- a/packages/starlight/__e2e__/fixtures/ssr/package.json
+++ b/packages/starlight/__e2e__/fixtures/ssr/package.json
@@ -3,8 +3,8 @@
   "version": "0.0.0",
   "private": true,
   "dependencies": {
-    "@astrojs/node": "^9.0.0",
+    "@astrojs/node": "^10.0.0",
     "@astrojs/starlight": "workspace:*",
-    "astro": "^5.6.1"
+    "astro": "^6.0.1"
   }
 }
diff --git a/packages/starlight/__e2e__/legacy-collection-config-file.test.ts b/packages/starlight/__e2e__/legacy-collections-backwards-compat.test.ts
similarity index 53%
rename from packages/starlight/__e2e__/legacy-collection-config-file.test.ts
rename to packages/starlight/__e2e__/legacy-collections-backwards-compat.test.ts
index 71b67f4136f..437a760799a 100644
--- a/packages/starlight/__e2e__/legacy-collection-config-file.test.ts
+++ b/packages/starlight/__e2e__/legacy-collections-backwards-compat.test.ts
@@ -1,10 +1,9 @@
 import { expect, testFactory } from './test-utils';
 
-// This fixture uses a legacy collection config file (`src/content/config.ts`) instead of the new
-// one (`src/content.config.ts`).
-const test = testFactory('./fixtures/legacy-collection-config-file/');
+// This fixture uses the `legacy.collectionsBackwardsCompat` flag.
+const test = testFactory('./fixtures/legacy-collections-backwards-compat/');
 
-test('builds a custom page using the `` component and a legacy collection config file', async ({
+test('builds a custom page using the `` component with the `legacy.collectionsBackwardsCompat` flag', async ({
 	page,
 	getProdServer,
 }) => {
diff --git a/packages/starlight/__tests__/basics/config-errors.test.ts b/packages/starlight/__tests__/basics/config-errors.test.ts
index e4772813719..c5833ed0141 100644
--- a/packages/starlight/__tests__/basics/config-errors.test.ts
+++ b/packages/starlight/__tests__/basics/config-errors.test.ts
@@ -115,7 +115,7 @@ test('errors if title value is not a string or an Object', () => {
 			Invalid config passed to starlight integration
 		Hint:
 			**title**: Did not match union.
-			> Expected type \`"string" | "object"\`, received \`"number"\`"
+			> Expected type \`"string" | "record"\`, received \`"number"\`"
 	`
 	);
 });
@@ -131,8 +131,7 @@ test('errors with bad social icon config', () => {
 		Hint:
 			Starlight v0.33.0 changed the \`social\` configuration syntax. Please specify an array of link items instead of an object.
 			See the Starlight changelog for details: https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md#0330
-			
-			**social**: Expected type \`"array"\`, received \`"object"\`"
+			"
 	`
 	);
 });
@@ -165,7 +164,7 @@ test('errors with bad head config', () => {
 		"[AstroUserError]:
 			Invalid config passed to starlight integration
 		Hint:
-			**head.0.tag**: Invalid enum value. Expected 'title' | 'base' | 'link' | 'style' | 'meta' | 'script' | 'noscript' | 'template', received 'unknown'
+			**head.0.tag**: Invalid option: expected one of "title"|"base"|"link"|"style"|"meta"|"script"|"noscript"|"template"
 			**head.0.attrs.prop**: Did not match union.
 			> Expected type \`"string" | "boolean" | "undefined"\`, received \`"null"\`
 			**head.0.content**: Expected type \`"string"\`, received \`"number"\`"
@@ -186,7 +185,7 @@ test('errors with bad sidebar config', () => {
 			Invalid config passed to starlight integration
 		Hint:
 			**sidebar.0**: Did not match union.
-			> Expected type \`{ link: string;  } | { items: array;  } | { autogenerate: object;  } | { slug: string } | string\`
+			> Expected type \`{ link: string } | { items: array } | { autogenerate: object } | { slug: string } | string\`
 			> Received \`{ "label": "Example", "href": "/" }\`"
 	`
 	);
@@ -212,8 +211,8 @@ test('errors with bad nested sidebar config', () => {
 			Invalid config passed to starlight integration
 		Hint:
 			**sidebar.0.items.1**: Did not match union.
-			> Expected type \`{ link: string } | { items: array;  } | { autogenerate: object;  } | { slug: string } | string\`
-			> Received \`{ "label": "Example", "items": [ { "label": "Nested Example 1", "link": "/" }, { "label": "Nested Example 2", "link": true } ] }\`"
+			> Expected type \`{ link: string } | { items: array } | { autogenerate: object } | { slug: string } | string\`
+			> Received \`{ "label": "Nested Example 2", "link": true }\`"
 	`);
 });
 
@@ -229,7 +228,9 @@ test('errors with sidebar entry that includes `link` and `items`', () => {
 		"[AstroUserError]:
 			Invalid config passed to starlight integration
 		Hint:
-			**sidebar.0**: Unrecognized key(s) in object: 'items'"
+			**sidebar.0**: Did not match union.
+			> Expected type \`{ autogenerate: object } | { slug: string } | string\`
+			> Received \`{ "label": "Parent", "link": "/parent", "items": [ { "label": "Child", "link": "/parent/child" } ] }\`"
 	`);
 });
 
@@ -243,7 +244,9 @@ test('errors with sidebar entry that includes `link` and `autogenerate`', () =>
 		"[AstroUserError]:
 			Invalid config passed to starlight integration
 		Hint:
-			**sidebar.0**: Unrecognized key(s) in object: 'autogenerate'"
+			**sidebar.0**: Did not match union.
+			> Expected type \`{ items: array } | { slug: string } | string\`
+			> Received \`{ "label": "Parent", "link": "/parent", "autogenerate": { "directory": "test" } }\`"
 	`);
 });
 
@@ -263,7 +266,9 @@ test('errors with sidebar entry that includes `items` and `autogenerate`', () =>
 		"[AstroUserError]:
 			Invalid config passed to starlight integration
 		Hint:
-			**sidebar.0**: Unrecognized key(s) in object: 'autogenerate'"
+			**sidebar.0**: Did not match union.
+			> Expected type \`{ link: string } | { slug: string } | string\`
+			> Received \`{ "label": "Parent", "items": [ { "label": "Child", "link": "/parent/child" } ], "autogenerate": { "directory": "test" } }\`"
 	`);
 });
 
diff --git a/packages/starlight/__tests__/basics/i18n.test.ts b/packages/starlight/__tests__/basics/i18n.test.ts
index 98e09205d40..88a14b01106 100644
--- a/packages/starlight/__tests__/basics/i18n.test.ts
+++ b/packages/starlight/__tests__/basics/i18n.test.ts
@@ -273,10 +273,10 @@ describe('getLocaleDir', () => {
 	});
 
 	test('uses `getTextInfo()` when `textInfo` is not available', () => {
-		// @ts-expect-error - `getTextInfo` is not typed but is available in some non-v8 based environments.
-		vi.spyOn(global.Intl, 'Locale').mockImplementation(() => ({
-			getTextInfo: () => ({ direction: 'rtl' }),
-		}));
+		vi.spyOn(global.Intl, 'Locale').mockImplementation(function () {
+			// @ts-expect-error - `getTextInfo` is not typed but is available in some non-v8 based environments.
+			this.getTextInfo = () => ({ direction: 'rtl' });
+		});
 
 		const { starlightConfig } = processI18nConfig(
 			config,
@@ -290,8 +290,10 @@ describe('getLocaleDir', () => {
 	});
 
 	test('fallbacks to a list of well-known RTL languages when `textInfo` and `getTextInfo()` are not available', () => {
-		// @ts-expect-error - We are simulating the absence of `textInfo` and `getTextInfo()`.
-		vi.spyOn(global.Intl, 'Locale').mockImplementation((tag) => ({ language: tag }));
+		vi.spyOn(global.Intl, 'Locale').mockImplementation(function (tag) {
+			// @ts-expect-error - We are simulating the absence of `textInfo` and `getTextInfo()`.
+			this.language = tag;
+		});
 
 		const { starlightConfig } = processI18nConfig(
 			config,
diff --git a/packages/starlight/__tests__/basics/routing.test.ts b/packages/starlight/__tests__/basics/routing.test.ts
index 8d1ecac53e4..4722509ab9f 100644
--- a/packages/starlight/__tests__/basics/routing.test.ts
+++ b/packages/starlight/__tests__/basics/routing.test.ts
@@ -1,7 +1,6 @@
 import { type GetStaticPathsResult } from 'astro';
 import { getCollection } from 'astro:content';
 import config from 'virtual:starlight/user-config';
-import project from 'virtual:starlight/project-context';
 import { expect, test, vi } from 'vitest';
 import { routes, paths, getRouteBySlugParam } from '../../utils/routing';
 import { slugToParam } from '../../utils/slugs';
@@ -22,10 +21,8 @@ test('test suite is using correct env', () => {
 });
 
 test('route slugs are normalized', () => {
-	const indexRoute = routes.find(
-		(route) => route.id === (project.legacyCollections ? 'index.mdx' : '')
-	);
-	expect(indexRoute?.slug).toBe('');
+	const indexRoute = routes.find((route) => route.id === '');
+	expect(indexRoute?.id).toBe('');
 });
 
 test('routes contain copy of original doc as entry', async () => {
@@ -33,22 +30,10 @@ test('routes contain copy of original doc as entry', async () => {
 	for (const route of routes) {
 		const doc = docs.find((doc) => doc.id === route.id || (doc.id === 'index' && route.id === ''));
 		if (!doc) throw new Error('Expected to find doc for route ' + route.id);
-		// Compare without slug as slugs can be normalized.
-		const { slug: _, ...entry } = route.entry;
-		if (project.legacyCollections) {
-			// When using legacy collections, the `filePath` property is added to the route entry.
-			expect(entry.filePath).toBeDefined();
-			const { filePath: _, ...legacyEntry } = entry;
-			// @ts-expect-error - When using legacy collections, the `slug` property is available but can
-			// be normalized.
-			const { slug: __, ...legacyInput } = doc;
-			expect(legacyEntry).toEqual(legacyInput);
-		} else {
-			// Compare without ids as ids can be normalized when using loaders.
-			const { id: _, ...loaderEntry } = entry;
-			const { id: __, ...loaderInput } = doc;
-			expect(loaderEntry).toEqual(loaderInput);
-		}
+		// Compare without ids as ids are normalized.
+		const { id: __, ...loaderEntry } = route.entry;
+		const { id: ___, ...loaderInput } = doc;
+		expect(loaderEntry).toEqual(loaderInput);
 	}
 });
 
@@ -81,7 +66,7 @@ test('paths contain normalized slugs for path parameters', () => {
 
 test('routes can be retrieved from their path parameters', () => {
 	for (const route of routes) {
-		const params = slugToParam(route.slug);
+		const params = slugToParam(route.id);
 		const routeFromParams = getRouteBySlugParam(params);
 
 		expect(routeFromParams).toBe(route);
@@ -89,9 +74,7 @@ test('routes can be retrieved from their path parameters', () => {
 });
 
 test('routes includes drafts except in production', async () => {
-	const routeMatcher = (route: Route) =>
-		route.id ===
-		(project.legacyCollections ? 'guides/authoring-content.mdx' : 'guides/authoring-content');
+	const routeMatcher = (route: Route) => route.id === 'guides/authoring-content';
 
 	expect(routes.find(routeMatcher)).toBeTruthy();
 
diff --git a/packages/starlight/__tests__/basics/slugs.test.ts b/packages/starlight/__tests__/basics/slugs.test.ts
index 0806a0bdf76..5b292cd5e8b 100644
--- a/packages/starlight/__tests__/basics/slugs.test.ts
+++ b/packages/starlight/__tests__/basics/slugs.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, test, vi } from 'vitest';
 import {
 	localeToLang,
-	localizedId,
+	localizedFilePath,
 	localizedSlug,
 	slugToLocaleData,
 	slugToParam,
@@ -75,9 +75,9 @@ describe('localeToLang', () => {
 	});
 });
 
-describe('localizedId', () => {
+describe('localizedFilePath', () => {
 	test('returns unchanged when no locales are set', () => {
-		expect(localizedId('test.md', undefined)).toBe('test.md');
+		expect(localizedFilePath('test.md', undefined)).toBe('test.md');
 	});
 });
 
diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
index 4db04f67898..3ebc96768c9 100644
--- a/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
+++ b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
@@ -6,7 +6,7 @@ import {
 } from '../../utils/starlight-page';
 
 vi.mock('virtual:starlight/collection-config', async () => {
-	const { z } = await vi.importActual('astro:content');
+	const { z } = await vi.importActual('astro/zod');
 	return (await import('../test-utils')).mockedCollectionConfig({
 		extend: z.object({
 			// Make the built-in description field required.
diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
index 077c91a9494..78af73041f0 100644
--- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
+++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
@@ -35,10 +35,8 @@ test('adds data to route shape', async () => {
 		props: starlightPageProps,
 		context: getRouteDataTestContext({ pathname: starlightPagePathname }),
 	});
-	// Starlight pages infer the slug from the URL.
-	expect(data.slug).toBe('test-slug');
 	// Starlight pages generate an ID based on their slug.
-	expect(data.id).toBeDefined();
+	expect(data.id).toBe('test-slug');
 	// Starlight pages cannot be fallbacks.
 	expect(data.isFallback).toBeUndefined();
 	// Starlight pages are not editable if no edit URL is passed.
@@ -278,7 +276,7 @@ test('throws error if sidebar is malformated', async () => {
 			Invalid sidebar prop passed to the \`\` component.
 		Hint:
 			**0**: Did not match union.
-			> Expected type \`{ link: string;  } | { items: array;  } | { autogenerate: object;  } | { slug: string } | string\`
+			> Expected type \`{ link: string } | { items: array } | { autogenerate: object } | { slug: string } | string\`
 			> Received \`{ "label": "Custom link 1", "href": "/test/1" }\`"
 	`);
 });
diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts
index 216b17b50fb..8b0d58967dd 100644
--- a/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts
+++ b/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts
@@ -1,5 +1,4 @@
 import { expect, test, vi } from 'vitest';
-import project from 'virtual:starlight/project-context';
 import { routes } from '../../utils/routing';
 
 vi.mock('astro:content', async () =>
@@ -13,10 +12,8 @@ vi.mock('astro:content', async () =>
 );
 
 test('route slugs are normalized', () => {
-	const indexRoute = routes.find(
-		(route) => route.entry.id === (project.legacyCollections ? 'fr/index.mdx' : 'fr')
-	);
-	expect(indexRoute?.slug).toBe('fr');
+	const indexRoute = routes.find((route) => route.entry.id === 'fr');
+	expect(indexRoute?.id).toBe('fr');
 });
 
 test('routes for the configured locale have locale data added', () => {
diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts
index 1d4e5b4189d..256d9d5e382 100644
--- a/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts
+++ b/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts
@@ -1,5 +1,10 @@
 import { describe, expect, test } from 'vitest';
-import { localeToLang, localizedId, localizedSlug, slugToLocaleData } from '../../utils/slugs';
+import {
+	localeToLang,
+	localizedFilePath,
+	localizedSlug,
+	slugToLocaleData,
+} from '../../utils/slugs';
 
 describe('slugToLocaleData', () => {
 	test('returns default "fr" locale', () => {
@@ -22,9 +27,9 @@ describe('localeToLang', () => {
 	});
 });
 
-describe('localizedId', () => {
+describe('localizedFilePath', () => {
 	test('returns unchanged for default locale', () => {
-		expect(localizedId('fr/test.md', 'fr')).toBe('fr/test.md');
+		expect(localizedFilePath('fr/test.md', 'fr')).toBe('fr/test.md');
 	});
 });
 
diff --git a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
index 6130eeaafc5..0c541539810 100644
--- a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
+++ b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
@@ -1,4 +1,3 @@
-import project from 'virtual:starlight/project-context';
 import { getRouteDataTestContext } from '../test-utils';
 import config from 'virtual:starlight/user-config';
 import { assert, expect, test, vi } from 'vitest';
@@ -61,13 +60,7 @@ test('fallback routes have fallback locale data in entryMeta', () => {
 });
 
 test('fallback routes use their own locale data', () => {
-	const enGuide = routes.find(
-		(route) =>
-			route.id ===
-			(project.legacyCollections
-				? 'en/guides/authoring-content.mdx'
-				: 'en/guides/authoring-content')
-	);
+	const enGuide = routes.find((route) => route.id === 'en/guides/authoring-content');
 	if (!enGuide)
 		throw new Error('Expected to find English fallback route for authoring-content.mdx');
 	expect(enGuide.locale).toBe('en');
diff --git a/packages/starlight/__tests__/i18n-root-locale/slugs.test.ts b/packages/starlight/__tests__/i18n-root-locale/slugs.test.ts
index e50961346d0..9a220712aa6 100644
--- a/packages/starlight/__tests__/i18n-root-locale/slugs.test.ts
+++ b/packages/starlight/__tests__/i18n-root-locale/slugs.test.ts
@@ -1,5 +1,10 @@
 import { describe, expect, test } from 'vitest';
-import { localeToLang, localizedId, localizedSlug, slugToLocaleData } from '../../utils/slugs';
+import {
+	localeToLang,
+	localizedFilePath,
+	localizedSlug,
+	slugToLocaleData,
+} from '../../utils/slugs';
 
 describe('slugToLocaleData', () => {
 	test('returns an undefined locale for root locale slugs', () => {
@@ -38,20 +43,20 @@ describe('localeToLang', () => {
 	});
 });
 
-describe('localizedId', () => {
+describe('localizedFilePath', () => {
 	test('returns unchanged when already in requested locale', () => {
-		expect(localizedId('test.md', undefined)).toBe('test.md');
-		expect(localizedId('dir/test.md', undefined)).toBe('dir/test.md');
-		expect(localizedId('en/test.md', 'en')).toBe('en/test.md');
-		expect(localizedId('en/dir/test.md', 'en')).toBe('en/dir/test.md');
-		expect(localizedId('ar/test.md', 'ar')).toBe('ar/test.md');
-		expect(localizedId('ar/dir/test.md', 'ar')).toBe('ar/dir/test.md');
+		expect(localizedFilePath('test.md', undefined)).toBe('test.md');
+		expect(localizedFilePath('dir/test.md', undefined)).toBe('dir/test.md');
+		expect(localizedFilePath('en/test.md', 'en')).toBe('en/test.md');
+		expect(localizedFilePath('en/dir/test.md', 'en')).toBe('en/dir/test.md');
+		expect(localizedFilePath('ar/test.md', 'ar')).toBe('ar/test.md');
+		expect(localizedFilePath('ar/dir/test.md', 'ar')).toBe('ar/dir/test.md');
 	});
 	test('returns localized id for requested locale', () => {
-		expect(localizedId('test.md', 'en')).toBe('en/test.md');
-		expect(localizedId('dir/test.md', 'en')).toBe('en/dir/test.md');
-		expect(localizedId('en/test.md', 'ar')).toBe('ar/test.md');
-		expect(localizedId('en/test.md', undefined)).toBe('test.md');
+		expect(localizedFilePath('test.md', 'en')).toBe('en/test.md');
+		expect(localizedFilePath('dir/test.md', 'en')).toBe('en/dir/test.md');
+		expect(localizedFilePath('en/test.md', 'ar')).toBe('ar/test.md');
+		expect(localizedFilePath('en/test.md', undefined)).toBe('test.md');
 	});
 });
 
diff --git a/packages/starlight/__tests__/i18n-single-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-single-root-locale/routing.test.ts
index a356dd4b671..677423ffd08 100644
--- a/packages/starlight/__tests__/i18n-single-root-locale/routing.test.ts
+++ b/packages/starlight/__tests__/i18n-single-root-locale/routing.test.ts
@@ -1,5 +1,4 @@
 import { expect, test, vi } from 'vitest';
-import project from 'virtual:starlight/project-context';
 import { routes } from '../../utils/routing';
 
 vi.mock('astro:content', async () =>
@@ -13,10 +12,8 @@ vi.mock('astro:content', async () =>
 );
 
 test('route slugs are normalized', () => {
-	const indexRoute = routes.find(
-		(route) => route.id === (project.legacyCollections ? 'index.mdx' : '')
-	);
-	expect(indexRoute?.slug).toBe('');
+	const indexRoute = routes.find((route) => route.id === '');
+	expect(indexRoute?.id).toBe('');
 });
 
 test('routes have locale data added', () => {
diff --git a/packages/starlight/__tests__/i18n-single-root-locale/slugs.test.ts b/packages/starlight/__tests__/i18n-single-root-locale/slugs.test.ts
index ba32999802b..bffe4358d5a 100644
--- a/packages/starlight/__tests__/i18n-single-root-locale/slugs.test.ts
+++ b/packages/starlight/__tests__/i18n-single-root-locale/slugs.test.ts
@@ -1,5 +1,10 @@
 import { describe, expect, test } from 'vitest';
-import { localeToLang, localizedId, localizedSlug, slugToLocaleData } from '../../utils/slugs';
+import {
+	localeToLang,
+	localizedFilePath,
+	localizedSlug,
+	slugToLocaleData,
+} from '../../utils/slugs';
 
 describe('slugToLocaleData', () => {
 	test('returns an undefined locale for root locale slugs', () => {
@@ -22,9 +27,9 @@ describe('localeToLang', () => {
 	});
 });
 
-describe('localizedId', () => {
+describe('localizedFilePath', () => {
 	test('returns unchanged for default locale', () => {
-		expect(localizedId('test.md', undefined)).toBe('test.md');
+		expect(localizedFilePath('test.md', undefined)).toBe('test.md');
 	});
 });
 
diff --git a/packages/starlight/__tests__/i18n/routing.test.ts b/packages/starlight/__tests__/i18n/routing.test.ts
index 7e9f254a06e..c17760dd537 100644
--- a/packages/starlight/__tests__/i18n/routing.test.ts
+++ b/packages/starlight/__tests__/i18n/routing.test.ts
@@ -1,5 +1,4 @@
 import config from 'virtual:starlight/user-config';
-import project from 'virtual:starlight/project-context';
 import { expect, test, vi } from 'vitest';
 import { routes } from '../../utils/routing';
 
@@ -65,11 +64,7 @@ test('fallback routes have fallback locale data in entryMeta', () => {
 });
 
 test('fallback routes use their own locale data', () => {
-	const arGuide = routes.find(
-		(route) =>
-			route.id ===
-			(project.legacyCollections ? 'ar/guides/authoring-content.md' : 'ar/guides/authoring-content')
-	);
+	const arGuide = routes.find((route) => route.id === 'ar/guides/authoring-content');
 	if (!arGuide) throw new Error('Expected to find Arabic fallback route for authoring-content.md');
 	expect(arGuide.locale).toBe('ar');
 	expect(arGuide.lang).toBe('ar');
diff --git a/packages/starlight/__tests__/remark-rehype/anchor-links.test.ts b/packages/starlight/__tests__/remark-rehype/anchor-links.test.ts
index 40f21c970a0..c1458a313f9 100644
--- a/packages/starlight/__tests__/remark-rehype/anchor-links.test.ts
+++ b/packages/starlight/__tests__/remark-rehype/anchor-links.test.ts
@@ -20,11 +20,9 @@ function renderMarkdown(
 	content: string,
 	options: { fileURL?: URL; processor?: MarkdownProcessor } = {}
 ) {
-	return (options.processor ?? processor).render(
-		content,
-		// @ts-expect-error fileURL is part of MarkdownProcessor's options
-		{ fileURL: options.fileURL ?? new URL(`./_src/content/docs/index.md`, import.meta.url) }
-	);
+	return (options.processor ?? processor).render(content, {
+		fileURL: options.fileURL ?? new URL(`./_src/content/docs/index.md`, import.meta.url),
+	});
 }
 
 test('generates anchor link markup', async () => {
diff --git a/packages/starlight/__tests__/remark-rehype/asides.test.ts b/packages/starlight/__tests__/remark-rehype/asides.test.ts
index 6f1ae767931..404c9b49ccf 100644
--- a/packages/starlight/__tests__/remark-rehype/asides.test.ts
+++ b/packages/starlight/__tests__/remark-rehype/asides.test.ts
@@ -27,11 +27,9 @@ function renderMarkdown(
 	content: string,
 	options: { fileURL?: URL; processor?: MarkdownProcessor } = {}
 ) {
-	return (options.processor ?? processor).render(
-		content,
-		// @ts-expect-error fileURL is part of MarkdownProcessor's options
-		{ fileURL: options.fileURL ?? new URL(`./_src/content/docs/index.md`, import.meta.url) }
-	);
+	return (options.processor ?? processor).render(content, {
+		fileURL: options.fileURL ?? new URL(`./_src/content/docs/index.md`, import.meta.url),
+	});
 }
 
 test('generates aside', async () => {
@@ -136,8 +134,6 @@ Some text
 			// We are not relying on `toThrowErrorMatchingInlineSnapshot()` and our custom snapshot
 			// serializer in this specific test as error thrown in a remark plugin includes a dynamic file
 			// path.
-			// `expect.objectContaining` returns `any`.
-			/* eslint-disable @typescript-eslint/no-unsafe-argument */
 			expect.objectContaining({
 				type: 'AstroUserError',
 				// `expect.stringMatching` returns `any`.
diff --git a/packages/starlight/__tests__/remark-rehype/utils.ts b/packages/starlight/__tests__/remark-rehype/utils.ts
index 8cf9f0502b8..3cc75f6089a 100644
--- a/packages/starlight/__tests__/remark-rehype/utils.ts
+++ b/packages/starlight/__tests__/remark-rehype/utils.ts
@@ -16,7 +16,6 @@ export async function createRemarkRehypePluginTestOptions(
 	const astroConfig = {
 		root: new URL(import.meta.url),
 		srcDir: new URL('./_src/', import.meta.url),
-		experimental: { headingIdCompat: false },
 	};
 
 	return {
diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts
index 8c04c39499c..cd8d15e7189 100644
--- a/packages/starlight/__tests__/test-config.ts
+++ b/packages/starlight/__tests__/test-config.ts
@@ -7,8 +7,6 @@ import { runPlugins, type StarlightUserConfigWithPlugins } from '../utils/plugin
 import { createTestPluginContext } from './test-plugin-utils';
 import { vitePluginStarlightCssLayerOrder } from '../integrations/vite-layer-order';
 
-const testLegacyCollections = process.env.LEGACY_COLLECTIONS === 'true';
-
 export async function defineVitestConfig(
 	{ plugins, ...config }: StarlightUserConfigWithPlugins,
 	opts?: {
@@ -32,14 +30,14 @@ export async function defineVitestConfig(
 		plugins: [
 			vitePluginStarlightCssLayerOrder(),
 			vitePluginStarlightUserConfig(
-				command,
+				{ command, isNodeCompatibleEnv: true },
 				starlightConfig,
 				{
 					root,
 					srcDir,
 					build,
 					trailingSlash,
-					legacy: { collections: testLegacyCollections },
+					legacy: { collectionsBackwardsCompat: false },
 				},
 				pluginTranslations
 			),
diff --git a/packages/starlight/__tests__/test-utils.ts b/packages/starlight/__tests__/test-utils.ts
index df114789ecb..5af2b805c48 100644
--- a/packages/starlight/__tests__/test-utils.ts
+++ b/packages/starlight/__tests__/test-utils.ts
@@ -1,5 +1,4 @@
 import { z } from 'astro/zod';
-import project from 'virtual:starlight/project-context';
 import { docsSchema, i18nSchema } from '../schema';
 import type { StarlightDocsCollectionEntry } from '../utils/routing/types';
 import type { RouteDataContext } from '../utils/routing/data';
@@ -35,27 +34,20 @@ function mockDoc(
 		.replace(/\/index$/, '')
 		.toLowerCase();
 
-	const doc: StarlightDocsCollectionEntry = {
-		id: project.legacyCollections ? docsFilePath : slug,
+	return {
+		id: slug,
 		body,
 		collection: 'docs',
 		data: frontmatterSchema.parse(data),
+		filePath: `src/content/docs/${docsFilePath}`,
 	};
-
-	if (project.legacyCollections) {
-		doc.slug = slug;
-	} else {
-		doc.filePath = `src/content/docs/${docsFilePath}`;
-	}
-
-	return doc;
 }
 
 function mockDict(id: string, data: z.input>) {
 	return {
-		id: project.legacyCollections ? id : id.toLocaleLowerCase(),
+		id: id.toLocaleLowerCase(),
 		data: i18nSchema().parse(data),
-		filePath: project.legacyCollections ? undefined : `src/content/i18n/${id}.yml`,
+		filePath: `src/content/i18n/${id}.yml`,
 	};
 }
 
@@ -88,16 +80,14 @@ export async function mockedCollectionConfig(docsUserSchema?: Parameters name === '@astrojs/cloudflare');
+				const isNodeCompatibleEnv = !isCloudflareEnv;
+
 				updateConfig({
 					vite: {
 						plugins: [
 							vitePluginStarlightCssLayerOrder(),
-							vitePluginStarlightUserConfig(command, starlightConfig, config, pluginTranslations),
+							vitePluginStarlightUserConfig(
+								{ command, isNodeCompatibleEnv },
+								starlightConfig,
+								config,
+								pluginTranslations
+							),
 						],
+						ssr: isNodeCompatibleEnv
+							? {}
+							: {
+									optimizeDeps: {
+										include: [
+											// Prebundle some dependencies for non-Node.js compatible environments to
+											// speed up dev server start time and prevent restarts.
+											'@astrojs/cloudflare/entrypoints/server',
+											'@astrojs/starlight>i18next',
+											'@astrojs/starlight>js-yaml',
+											'@astrojs/starlight>klona/lite',
+											// TODO: once Expressive Code is refactored/fixed, remove this workaround for
+											// Expressive Code relying on CJS dependencies like postcss not compatible
+											// with non-Node.js compatible environments like Cloudflare.
+											'@astrojs/starlight>astro-expressive-code/components',
+											'@astrojs/starlight>astro-expressive-code>hast-util-select',
+											'@astrojs/starlight>astro-expressive-code>rehype',
+											'@astrojs/starlight>astro-expressive-code>unist-util-visit',
+											'@astrojs/starlight>astro-expressive-code>rehype-format',
+											'@astrojs/starlight>astro-expressive-code>hastscript',
+											'@astrojs/starlight>astro-expressive-code>hast-util-from-html',
+											'@astrojs/starlight>astro-expressive-code>hast-util-to-string',
+											'@astrojs/starlight>astro-expressive-code>@expressive-code/core>postcss',
+										],
+									},
+								},
 					},
 					markdown: {
 						remarkPlugins: [...starlightRemarkPlugins(remarkRehypeOptions)],
diff --git a/packages/starlight/integrations/remark-rehype.ts b/packages/starlight/integrations/remark-rehype.ts
index ebfa4865cc4..b0d930c2b91 100644
--- a/packages/starlight/integrations/remark-rehype.ts
+++ b/packages/starlight/integrations/remark-rehype.ts
@@ -21,14 +21,7 @@ export function starlightRemarkPlugins(options: RemarkRehypePluginOptions): Rema
 /** List of rehype plugins to apply. */
 export function starlightRehypePlugins(options: RemarkRehypePluginOptions): RehypePlugin[] {
 	return [
-		...(options.starlightConfig.markdown.headingLinks
-			? [
-					[
-						rehypeHeadingIds,
-						{ experimentalHeadingIdCompat: options.astroConfig.experimental?.headingIdCompat },
-					],
-				]
-			: []),
+		...(options.starlightConfig.markdown.headingLinks ? [[rehypeHeadingIds]] : []),
 		rehypePlugins(options),
 	] as RehypePlugin[];
 }
@@ -115,9 +108,7 @@ function normalizePath(path: string) {
 
 export interface RemarkRehypePluginOptions {
 	starlightConfig: Pick;
-	astroConfig: Pick & {
-		experimental: Pick;
-	};
+	astroConfig: Pick;
 	useTranslations: HookParameters<'config:setup'>['useTranslations'];
 	absolutePathToLang: HookParameters<'config:setup'>['absolutePathToLang'];
 }
diff --git a/packages/starlight/integrations/virtual-user-config.ts b/packages/starlight/integrations/virtual-user-config.ts
index 893af513df6..35be3200ea4 100644
--- a/packages/starlight/integrations/virtual-user-config.ts
+++ b/packages/starlight/integrations/virtual-user-config.ts
@@ -7,13 +7,24 @@ import type { StarlightConfig } from '../utils/user-config';
 import { getAllNewestCommitDate } from '../utils/git';
 import type { PluginTranslations } from '../utils/plugins';
 
+// https://vite.dev/guide/api-plugin#hook-filters
+const pluginResolveIdIdFilter = /^virtual:starlight\//;
+// eslint-disable-next-line no-control-regex -- virtual module prefix
+const pluginLoadIdFilter = /^\x00virtual:starlight\//;
+
 function resolveVirtualModuleId(id: T): `\0${T}` {
 	return `\0${id}`;
 }
 
 /** Vite plugin that exposes Starlight user config and project context via virtual modules. */
 export function vitePluginStarlightUserConfig(
-	command: HookParameters<'astro:config:setup'>['command'],
+	{
+		command,
+		isNodeCompatibleEnv,
+	}: {
+		command: HookParameters<'astro:config:setup'>['command'];
+		isNodeCompatibleEnv: boolean;
+	},
 	opts: StarlightConfig,
 	{
 		build,
@@ -23,7 +34,7 @@ export function vitePluginStarlightUserConfig(
 		trailingSlash,
 	}: Pick & {
 		build: Pick;
-		legacy: Pick;
+		legacy: Pick;
 	},
 	pluginTranslations: PluginTranslations
 ): NonNullable[number] {
@@ -49,14 +60,12 @@ export function vitePluginStarlightUserConfig(
 	const rootPath = fileURLToPath(root);
 	const docsPath = resolveCollectionPath('docs', srcDir);
 
-	let collectionConfigImportPath = resolve(
-		fileURLToPath(srcDir),
-		legacy.collections ? './content/config.ts' : './content.config.ts'
-	);
-	// If not using legacy collections and the config doesn't exist, fallback to the legacy location.
-	// We need to test this ahead of time as we cannot `try/catch` a failing import in the virtual
-	// module as this would fail at build time when Rollup tries to resolve a non-existent path.
-	if (!legacy.collections && !existsSync(collectionConfigImportPath)) {
+	let collectionConfigImportPath = resolve(fileURLToPath(srcDir), './content.config.ts');
+	// If using the collections backwards compatibility mode and the config doesn't exist, fallback
+	// to the legacy location. We need to test this ahead of time as we cannot `try/catch` a failing
+	// import in the virtual module as this would fail at build time when Rollup tries to resolve a
+	// non-existent path.
+	if (legacy.collectionsBackwardsCompat && !existsSync(collectionConfigImportPath)) {
 		collectionConfigImportPath = resolve(fileURLToPath(srcDir), './content/config.ts');
 	}
 
@@ -72,13 +81,12 @@ export function vitePluginStarlightUserConfig(
 		'virtual:starlight/user-config': `export default ${JSON.stringify(opts)}`,
 		'virtual:starlight/project-context': `export default ${JSON.stringify({
 			build: { format: build.format },
-			legacyCollections: legacy.collections,
 			root,
 			srcDir,
 			trailingSlash,
 		})}`,
 		'virtual:starlight/git-info':
-			(command !== 'build'
+			(command !== 'build' && isNodeCompatibleEnv
 				? `import { makeAPI } from ${resolveLocalPath('../utils/git.ts')};` +
 					`const api = makeAPI(${JSON.stringify(rootPath)});`
 				: `import { makeAPI } from ${resolveLocalPath('../utils/gitInlined.ts')};` +
@@ -150,12 +158,18 @@ export function vitePluginStarlightUserConfig(
 
 	return {
 		name: 'vite-plugin-starlight-user-config',
-		resolveId(id): string | void {
-			if (id in modules) return resolveVirtualModuleId(id);
+		resolveId: {
+			filter: { id: pluginResolveIdIdFilter },
+			handler(id): string | void {
+				if (id in modules) return resolveVirtualModuleId(id);
+			},
 		},
-		load(id): string | void {
-			const resolution = resolutionMap[id];
-			if (resolution) return modules[resolution];
+		load: {
+			filter: { id: pluginLoadIdFilter },
+			handler(id): string | void {
+				const resolution = resolutionMap[id];
+				if (resolution) return modules[resolution];
+			},
 		},
 	};
 }
diff --git a/packages/starlight/integrations/vite-layer-order.ts b/packages/starlight/integrations/vite-layer-order.ts
index 41277e49c4f..38b787ca367 100644
--- a/packages/starlight/integrations/vite-layer-order.ts
+++ b/packages/starlight/integrations/vite-layer-order.ts
@@ -1,6 +1,11 @@
 import type { ViteUserConfig } from 'astro';
 import MagicString from 'magic-string';
 
+// https://vite.dev/guide/api-plugin#hook-filters
+const pluginTransformIdIncludeFilter = /\.astro$/;
+const pluginTransformIdExcludeFilter = /@astrojs\/starlight\/components\/StarlightPage\.astro$/;
+const pluginTransformCodeFilter = 'StarlightPage.astro';
+
 const starlightPageImportSource = '@astrojs/starlight/components/StarlightPage.astro';
 
 /**
@@ -16,49 +21,47 @@ export function vitePluginStarlightCssLayerOrder(): VitePlugin {
 	return {
 		name: 'vite-plugin-starlight-css-layer-order',
 		enforce: 'pre',
-		transform(code, id) {
-			if (
-				!id.endsWith('.astro') ||
-				id.endsWith(starlightPageImportSource) ||
-				code.indexOf('StarlightPage.astro') === -1
-			) {
-				return;
-			}
-
-			let ast: ReturnType;
+		transform: {
+			filter: {
+				id: { include: pluginTransformIdIncludeFilter, exclude: pluginTransformIdExcludeFilter },
+				code: pluginTransformCodeFilter,
+			},
+			handler(code, id) {
+				let ast: ReturnType;
 
-			try {
-				ast = this.parse(code);
-			} catch {
-				return;
-			}
+				try {
+					ast = this.parse(code);
+				} catch {
+					return;
+				}
 
-			let hasStarlightPageImport = false;
+				let hasStarlightPageImport = false;
 
-			for (const node of ast.body) {
-				if (node.type !== 'ImportDeclaration') continue;
-				if (node.source.value !== starlightPageImportSource) continue;
+				for (const node of ast.body) {
+					if (node.type !== 'ImportDeclaration') continue;
+					if (node.source.value !== starlightPageImportSource) continue;
 
-				const importDefaultSpecifier = node.specifiers.find(
-					(specifier) => specifier.type === 'ImportDefaultSpecifier'
-				);
-				if (!importDefaultSpecifier) continue;
+					const importDefaultSpecifier = node.specifiers.find(
+						(specifier) => specifier.type === 'ImportDefaultSpecifier'
+					);
+					if (!importDefaultSpecifier) continue;
 
-				hasStarlightPageImport = true;
-				break;
-			}
+					hasStarlightPageImport = true;
+					break;
+				}
 
-			if (!hasStarlightPageImport) return;
+				if (!hasStarlightPageImport) return;
 
-			// Format path to unix style path.
-			const filename = id.replace(/\\/g, '/');
-			const ms = new MagicString(code, { filename });
-			ms.prepend(`import "${starlightPageImportSource}";\n`);
+				// Format path to unix style path.
+				const filename = id.replace(/\\/g, '/');
+				const ms = new MagicString(code, { filename });
+				ms.prepend(`import "${starlightPageImportSource}";\n`);
 
-			return {
-				code: ms.toString(),
-				map: ms.generateMap({ hires: 'boundary' }),
-			};
+				return {
+					code: ms.toString(),
+					map: ms.generateMap({ hires: 'boundary' }),
+				};
+			},
 		},
 	};
 }
diff --git a/packages/starlight/package.json b/packages/starlight/package.json
index fbe1256aeb7..09cd731ca10 100644
--- a/packages/starlight/package.json
+++ b/packages/starlight/package.json
@@ -4,7 +4,6 @@
   "description": "Build beautiful, high-performance documentation websites with Astro",
   "scripts": {
     "test": "vitest",
-    "test:legacy": "LEGACY_COLLECTIONS=true vitest",
     "test:coverage": "vitest run --coverage",
     "test:e2e": "pnpm test:e2e:chrome",
     "test:e2e:chrome": "playwright install --with-deps chromium && playwright test --project chrome",
@@ -185,25 +184,25 @@
     "./style/markdown.css": "./style/markdown.css"
   },
   "peerDependencies": {
-    "astro": "^5.5.0"
+    "astro": "^6.0.0"
   },
   "devDependencies": {
-    "@playwright/test": "^1.45.0",
-    "@types/node": "^18.16.19",
-    "@vitest/coverage-v8": "^3.0.5",
-    "astro": "^5.6.1",
+    "@playwright/test": "^1.57.0",
+    "@types/node": "^22.19.3",
+    "@vitest/coverage-v8": "^4.1.0-beta.6",
+    "astro": "^6.0.1",
     "linkedom": "^0.18.4",
-    "vitest": "^3.0.5"
+    "vitest": "^4.1.0-beta.6"
   },
   "dependencies": {
-    "@astrojs/markdown-remark": "^6.3.1",
-    "@astrojs/mdx": "^4.2.3",
-    "@astrojs/sitemap": "^3.3.0",
+    "@astrojs/markdown-remark": "^7.0.0",
+    "@astrojs/mdx": "^5.0.0",
+    "@astrojs/sitemap": "^3.7.1",
     "@pagefind/default-ui": "^1.3.0",
     "@types/hast": "^3.0.4",
     "@types/js-yaml": "^4.0.9",
     "@types/mdast": "^4.0.4",
-    "astro-expressive-code": "^0.41.1",
+    "astro-expressive-code": "^0.41.6",
     "bcp-47": "^2.1.0",
     "hast-util-from-html": "^2.0.1",
     "hast-util-select": "^6.0.2",
diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts
index 905258e45c2..a90220b87e6 100644
--- a/packages/starlight/schema.ts
+++ b/packages/starlight/schema.ts
@@ -27,7 +27,7 @@ const StarlightFrontmatterSchema = (context: SchemaContext) =>
 		 *
 		 * Can also be set to `false` to disable showing an edit link on this page.
 		 */
-		editUrl: z.union([z.string().url(), z.boolean()]).optional().default(true),
+		editUrl: z.union([z.url(), z.boolean()]).optional().default(true),
 
 		/** Set custom `` tags just for this page. */
 		head: HeadConfigSchema({ source: 'content' }),
@@ -91,7 +91,7 @@ const StarlightFrontmatterSchema = (context: SchemaContext) =>
 				/** HTML attributes to add to the sidebar link. */
 				attrs: SidebarLinkItemHTMLAttributesSchema(),
 			})
-			.default({}),
+			.prefault({}),
 
 		/** Display an announcement banner at the top of this page. */
 		banner: z
@@ -113,14 +113,8 @@ const StarlightFrontmatterSchema = (context: SchemaContext) =>
 /** Type of Starlight’s default frontmatter schema. */
 type DefaultSchema = ReturnType;
 
-/** Plain object, union, and intersection Zod types. */
-type BaseSchemaWithoutEffects =
-	| z.AnyZodObject
-	| z.ZodUnion<[BaseSchemaWithoutEffects, ...BaseSchemaWithoutEffects[]]>
-	| z.ZodDiscriminatedUnion
-	| z.ZodIntersection;
 /** Base subset of Zod types that we support passing to the `extend` option. */
-type BaseSchema = BaseSchemaWithoutEffects | z.ZodEffects;
+type BaseSchema = z.core.$ZodType;
 
 /** Type that extends Starlight’s default schema with an optional, user-defined schema. */
 type ExtendedSchema = [T] extends [never]
diff --git a/packages/starlight/schemas/badge.ts b/packages/starlight/schemas/badge.ts
index f3f1f738f68..d8567e59d90 100644
--- a/packages/starlight/schemas/badge.ts
+++ b/packages/starlight/schemas/badge.ts
@@ -5,19 +5,20 @@ const badgeBaseSchema = z.object({
 	class: z.string().optional(),
 });
 
-const badgeSchema = badgeBaseSchema.extend({
+const badgeSchema = z.object({
+	...badgeBaseSchema.shape,
 	text: z.string(),
 });
 
-const i18nBadgeSchema = badgeBaseSchema.extend({
-	text: z.union([z.string(), z.record(z.string())]),
+const i18nBadgeSchema = z.object({
+	...badgeBaseSchema.shape,
+	text: z.union([z.string(), z.record(z.string(), z.string())]),
 });
 
-export const BadgeComponentSchema = badgeSchema
-	.extend({
-		size: z.enum(['small', 'medium', 'large']).default('small'),
-	})
-	.passthrough();
+export const BadgeComponentSchema = z.looseObject({
+	...badgeSchema.shape,
+	size: z.enum(['small', 'medium', 'large']).default('small'),
+});
 
 export type BadgeComponentProps = z.input;
 
diff --git a/packages/starlight/schemas/components.ts b/packages/starlight/schemas/components.ts
index 3ae79ec65cf..b9864915d39 100644
--- a/packages/starlight/schemas/components.ts
+++ b/packages/starlight/schemas/components.ts
@@ -261,5 +261,5 @@ export function ComponentConfigSchema() {
 			 */
 			EditLink: z.string().default('@astrojs/starlight/components/EditLink.astro'),
 		})
-		.default({});
+		.prefault({});
 }
diff --git a/packages/starlight/schemas/expressiveCode.ts b/packages/starlight/schemas/expressiveCode.ts
index 97420ab4ca2..5bedbf1f9ce 100644
--- a/packages/starlight/schemas/expressiveCode.ts
+++ b/packages/starlight/schemas/expressiveCode.ts
@@ -9,7 +9,4 @@ export const ExpressiveCodeSchema = () =>
 			),
 			z.boolean(),
 		])
-		.describe(
-			'Define how code blocks are rendered by passing options to Expressive Code, or disable the integration by passing `false`.'
-		)
 		.optional();
diff --git a/packages/starlight/schemas/favicon.ts b/packages/starlight/schemas/favicon.ts
index b3b6a570096..a014c190efb 100644
--- a/packages/starlight/schemas/favicon.ts
+++ b/packages/starlight/schemas/favicon.ts
@@ -20,9 +20,10 @@ export const FaviconSchema = () =>
 			const ext = extname(pathname).toLowerCase();
 
 			if (!isFaviconExt(ext)) {
-				ctx.addIssue({
-					code: z.ZodIssueCode.custom,
+				ctx.issues.push({
+					code: 'custom',
 					message: 'favicon must be a .ico, .gif, .jpg, .png, or .svg file',
+					input: favicon,
 				});
 
 				return z.NEVER;
@@ -32,10 +33,7 @@ export const FaviconSchema = () =>
 				href: favicon,
 				type: faviconTypeMap[ext],
 			};
-		})
-		.describe(
-			'The default favicon for your site which should be a path to an image in the `public/` directory.'
-		);
+		});
 
 function isFaviconExt(ext: string): ext is keyof typeof faviconTypeMap {
 	return ext in faviconTypeMap;
diff --git a/packages/starlight/schemas/head.ts b/packages/starlight/schemas/head.ts
index 367480b2917..0ecb634f932 100644
--- a/packages/starlight/schemas/head.ts
+++ b/packages/starlight/schemas/head.ts
@@ -17,7 +17,7 @@ export const HeadConfigSchema = ({
 					/** Name of the HTML tag to add to ``, e.g. `'meta'`, `'link'`, or `'script'`. */
 					tag: z.enum(['title', 'base', 'link', 'style', 'meta', 'script', 'noscript', 'template']),
 					/** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */
-					attrs: z.record(z.union([z.string(), z.boolean(), z.undefined()])).optional(),
+					attrs: z.record(z.string(), z.union([z.string(), z.boolean(), z.undefined()])).optional(),
 					/** Content to place inside the tag (optional). */
 					content: z.string().optional(),
 				})
@@ -30,7 +30,7 @@ export const HeadConfigSchema = ({
 					};
 					const code =
 						source === 'config' ? JSON.stringify(correctTag, null, 2) : yaml.dump([correctTag]);
-					ctx.addIssue({
+					ctx.issues.push({
 						code: 'custom',
 						message:
 							`The \`head\` configuration includes a \`meta\` tag with \`content\` which is invalid HTML.\n` +
@@ -40,6 +40,7 @@ export const HeadConfigSchema = ({
 								: '') +
 							`in the \`attrs\` object:\n\n` +
 							code,
+						input: config,
 					});
 				})
 		)
diff --git a/packages/starlight/schemas/hero.ts b/packages/starlight/schemas/hero.ts
index e1af1f90676..8b38a87a928 100644
--- a/packages/starlight/schemas/hero.ts
+++ b/packages/starlight/schemas/hero.ts
@@ -62,7 +62,7 @@ export const HeroSchema = ({ image }: SchemaContext) =>
 					})
 					.optional(),
 				/** HTML attributes to add to the link */
-				attrs: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
+				attrs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
 			})
 			.array()
 			.default([]),
diff --git a/packages/starlight/schemas/i18n.ts b/packages/starlight/schemas/i18n.ts
index 11e5ae02c1d..34fe7d6f5c8 100644
--- a/packages/starlight/schemas/i18n.ts
+++ b/packages/starlight/schemas/i18n.ts
@@ -1,6 +1,6 @@
 import { z } from 'astro/zod';
 
-interface i18nSchemaOpts {
+interface i18nSchemaOpts {
 	/**
 	 * Extend Starlight’s i18n schema with additional fields.
 	 *
@@ -19,147 +19,152 @@ interface i18nSchemaOpts {
 }
 
 const defaultI18nSchema = () =>
-	starlightI18nSchema().merge(pagefindI18nSchema()).merge(expressiveCodeI18nSchema());
+	z.object({
+		...starlightI18nSchema().shape,
+		...pagefindI18nSchema().shape,
+		...expressiveCodeI18nSchema().shape,
+	});
 /** Type of Starlight’s default i18n schema, including extensions from Pagefind and Expressive Code. */
 type DefaultI18nSchema = ReturnType;
 
 /**
- * Based on the the return type of Zod’s `merge()` method. Merges the type of two `z.object()` schemas.
- * Also sets them as “passthrough” schemas as that’s how we use them. In practice whether or not the types
- * are passthrough or not doesn’t matter too much.
+ * Based on the the return type of Zod’s `extend()` method. Adds fields from one `z.object()` schema
+ * to another.
+ * Also sets the resulting schema as “loose” as that’s how we use it. In practice whether or not
+ * the types are loose or not doesn’t matter too much.
  *
- * @see https://github.com/colinhacks/zod/blob/3032e240a0c227692bb96eedf240ed493c53f54c/src/types.ts#L2656-L2660
+ * @see https://github.com/colinhacks/zod/blob/9712a6707f3d4584f222729965f3b78f076f0435/packages/zod/src/v4/classic/schemas.ts#L1187
  */
-type MergeSchemas = z.ZodObject<
-	z.objectUtil.extendShape,
-	'passthrough',
-	B['_def']['catchall']
+type MergeSchemas = z.ZodObject<
+	z.util.Extend,
+	z.core.$loose
 >;
 /** Type that extends Starlight’s default i18n schema with an optional, user-defined schema. */
-type ExtendedSchema = T extends z.AnyZodObject
+type ExtendedSchema = T extends z.ZodObject
 	? MergeSchemas
 	: DefaultI18nSchema;
+/** Type representing an empty Zod object schema used as the default for the `extend` option. */
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+type BaseExtendSchema = z.ZodObject<{}>;
 
 /** Content collection schema for Starlight’s optional `i18n` collection. */
-export function i18nSchema({
+export function i18nSchema({
 	extend = z.object({}) as T,
 }: i18nSchemaOpts = {}): ExtendedSchema {
-	return defaultI18nSchema().merge(extend).passthrough() as ExtendedSchema;
+	return z.looseObject({
+		...defaultI18nSchema().shape,
+		...extend.shape,
+	}) as ExtendedSchema;
 }
-export type i18nSchemaOutput = z.output>;
+export type i18nSchemaOutput = z.output>;
 
 export function builtinI18nSchema() {
-	return starlightI18nSchema()
-		.required()
-		.strict()
-		.merge(pagefindI18nSchema())
-		.merge(expressiveCodeI18nSchema());
+	return z.object({
+		...z.strictObject({ ...starlightI18nSchema().required().shape }).shape,
+		...pagefindI18nSchema().shape,
+		...expressiveCodeI18nSchema().shape,
+	});
 }
 
 function starlightI18nSchema() {
 	return z
 		.object({
-			'skipLink.label': z
-				.string()
-				.describe(
-					'Text displayed in the accessible “Skip link” when a keyboard user first tabs into a page.'
-				),
+			'skipLink.label': z.string().meta({
+				description:
+					'Text displayed in the accessible “Skip link” when a keyboard user first tabs into a page.',
+			}),
 
-			'search.label': z.string().describe('Text displayed in the search bar.'),
+			'search.label': z.string().meta({ description: 'Text displayed in the search bar.' }),
 
-			'search.ctrlKey': z
-				.string()
-				.describe(
-					'Visible representation of the Control key potentially used in the shortcut key to open the search modal.'
-				),
+			'search.ctrlKey': z.string().meta({
+				description:
+					'Visible representation of the Control key potentially used in the shortcut key to open the search modal.',
+			}),
 
 			'search.cancelLabel': z
 				.string()
-				.describe('Text for the “Cancel” button that closes the search modal.'),
+				.meta({ description: 'Text for the “Cancel” button that closes the search modal.' }),
 
-			'search.devWarning': z
-				.string()
-				.describe('Warning displayed when opening the Search in a dev environment.'),
+			'search.devWarning': z.string().meta({
+				description: 'Warning displayed when opening the Search in a dev environment.',
+			}),
 
 			'themeSelect.accessibleLabel': z
 				.string()
-				.describe('Accessible label for the theme selection dropdown.'),
+				.meta({ description: 'Accessible label for the theme selection dropdown.' }),
 
-			'themeSelect.dark': z.string().describe('Name of the dark color theme.'),
+			'themeSelect.dark': z.string().meta({ description: 'Name of the dark color theme.' }),
 
-			'themeSelect.light': z.string().describe('Name of the light color theme.'),
+			'themeSelect.light': z.string().meta({ description: 'Name of the light color theme.' }),
 
-			'themeSelect.auto': z
-				.string()
-				.describe('Name of the automatic color theme that syncs with system preferences.'),
+			'themeSelect.auto': z.string().meta({
+				description: 'Name of the automatic color theme that syncs with system preferences.',
+			}),
 
 			'languageSelect.accessibleLabel': z
 				.string()
-				.describe('Accessible label for the language selection dropdown.'),
+				.meta({ description: 'Accessible label for the language selection dropdown.' }),
 
 			'menuButton.accessibleLabel': z
 				.string()
-				.describe('Accessible label for the mobile menu button.'),
+				.meta({ description: 'Accessible label for the mobile menu button.' }),
 
-			'sidebarNav.accessibleLabel': z
-				.string()
-				.describe(
-					'Accessible label for the main sidebar `