Astro 6 support (#3644)

Co-authored-by: Chris Swithinbank <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
Co-authored-by: delucis <[email protected]>
Co-authored-by: matthewp <[email protected]>
Co-authored-by: lukaszpiotrluczak <[email protected]>
Co-authored-by: cloudkite <[email protected]>
This commit is contained in:
HiDeoo
2026-03-11 09:34:24 +01:00
committed by GitHub
co-authored by Chris Swithinbank Sarah Rainsberger delucis matthewp lukaszpiotrluczak cloudkite
parent 9db01d089d
commit 0d2e7ed74a
93 changed files with 2680 additions and 2815 deletions
+27
View File
@@ -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.
+11
View File
@@ -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
```
+11
View File
@@ -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
```
+12
View File
@@ -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
```
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -4
View File
@@ -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 }})'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+13
View File
@@ -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:
+29 -14
View File
@@ -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 !/^<aside[^>]* 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
*/
!/^<aside[^>]* class="starlight-aside[^>]*>$/.test(node.html) &&
/**
* Expressive Code `<pre>` 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
*/
!/^<pre[^>]* data-language[^>]* role="region"[^>]*>$/.test(node.html)
);
}
interface Config {
+3 -3
View File
@@ -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"
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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';
+1 -1
View File
@@ -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:
+4 -2
View File
@@ -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).
@@ -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';
+1 -10
View File
@@ -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`
+1 -1
View File
@@ -12,7 +12,7 @@
},
"dependencies": {
"@astrojs/starlight": "^0.37.7",
"astro": "^5.6.1",
"astro": "^6.0.1",
"sharp": "^0.34.2"
}
}
+2 -2
View File
@@ -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"
}
}
+3 -3
View File
@@ -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"
}
}
+7 -4
View File
@@ -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"
}
}
}
+56 -49
View File
@@ -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<typeof docsearch>[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<SearchOptions>(),
searchParameters: z.custom<SearchOptions>().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<typeof DocSearchConfigSchema>;
@@ -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;
},
},
};
}
+1 -1
View File
@@ -25,7 +25,7 @@
"./schema": "./schema.ts"
},
"peerDependencies": {
"@astrojs/starlight": ">=0.32.0"
"@astrojs/starlight": ">=0.38.0"
},
"dependencies": {
"@docsearch/css": "^3.6.0",
+4 -4
View File
@@ -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
@@ -4,6 +4,6 @@
"private": true,
"dependencies": {
"@astrojs/starlight": "workspace:*",
"astro": "^5.6.1"
"astro": "^6.0.1"
}
}
@@ -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';
@@ -4,6 +4,6 @@
"private": true,
"dependencies": {
"@astrojs/starlight": "workspace:*",
"astro": "^5.6.1"
"astro": "^6.0.1"
}
}
@@ -4,6 +4,6 @@
"private": true,
"dependencies": {
"@astrojs/starlight": "workspace:*",
"astro": "^5.6.1"
"astro": "^6.0.1"
}
}
@@ -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,
}),
],
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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 `<StarlightPage>` component and a legacy collection config file', async ({
test('builds a custom page using the `<StarlightPage>` component with the `legacy.collectionsBackwardsCompat` flag', async ({
page,
getProdServer,
}) => {
@@ -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" } }\`"
`);
});
@@ -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,
@@ -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();
@@ -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');
});
});
@@ -6,7 +6,7 @@ import {
} from '../../utils/starlight-page';
vi.mock('virtual:starlight/collection-config', async () => {
const { z } = await vi.importActual<typeof import('astro:content')>('astro:content');
const { z } = await vi.importActual<typeof import('astro/zod')>('astro/zod');
return (await import('../test-utils')).mockedCollectionConfig({
extend: z.object({
// Make the built-in description field required.
@@ -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 \`<StarlightPage/>\` 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" }\`"
`);
});
@@ -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', () => {
@@ -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');
});
});
@@ -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');
@@ -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');
});
});
@@ -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', () => {
@@ -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');
});
});
@@ -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');
@@ -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 () => {
@@ -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`.
@@ -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 {
+2 -4
View File
@@ -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
),
+13 -23
View File
@@ -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<ReturnType<typeof i18nSchema>>) {
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<typeof
return {
collections: {
docs: content.defineCollection(
project.legacyCollections
? { schema: schemas.docsSchema(docsUserSchema) }
: { loader: loaders.docsLoader(), schema: schemas.docsSchema(docsUserSchema) }
),
i18n: content.defineCollection(
project.legacyCollections
? { type: 'data', schema: schemas.i18nSchema() }
: { loader: loaders.i18nLoader(), schema: schemas.i18nSchema() }
),
docs: content.defineCollection({
loader: loaders.docsLoader(),
schema: schemas.docsSchema(docsUserSchema),
}),
i18n: content.defineCollection({
loader: loaders.i18nLoader(),
schema: schemas.i18nSchema(),
}),
},
};
}
+2 -2
View File
@@ -34,8 +34,8 @@ import printHref from '../style/print.css?url&no-inline';
const { starlightRoute } = Astro.locals;
const pagefindEnabled =
starlightRoute.entry.slug !== '404' &&
!starlightRoute.entry.slug.endsWith('/404') &&
starlightRoute.entry.id !== '404' &&
!starlightRoute.entry.id.endsWith('/404') &&
starlightRoute.entry.data.pagefind !== false;
const htmlDataAttributes: DOMStringMap = { 'data-theme': 'dark' };
+9 -3
View File
@@ -1,5 +1,11 @@
interface StarlightThemeProvider {
updatePickers(theme?: string): void;
}
export declare global {
var StarlightThemeProvider: {
updatePickers(theme?: string): void;
};
var StarlightThemeProvider: StarlightThemeProvider;
interface Window {
StarlightThemeProvider: StarlightThemeProvider;
}
}
+39 -1
View File
@@ -124,12 +124,50 @@ export default function StarlightIntegration(
absolutePathToLang,
};
// TODO: refactor once there is a reliable way to detect non-Node.js compatible
// environments, rather than relying on the presence of specific adapters/integrations.
const isCloudflareEnv =
config.adapter?.name === '@astrojs/cloudflare' ||
config.integrations.some(({ name }) => 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)],
@@ -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<StarlightConfig, 'defaultLocale' | 'locales' | 'markdown'>;
astroConfig: Pick<AstroConfig, 'root' | 'srcDir'> & {
experimental: Pick<AstroConfig['experimental'], 'headingIdCompat'>;
};
astroConfig: Pick<AstroConfig, 'root' | 'srcDir'>;
useTranslations: HookParameters<'config:setup'>['useTranslations'];
absolutePathToLang: HookParameters<'config:setup'>['absolutePathToLang'];
}
@@ -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<T extends string>(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<AstroConfig, 'root' | 'srcDir' | 'trailingSlash'> & {
build: Pick<AstroConfig['build'], 'format'>;
legacy: Pick<AstroConfig['legacy'], 'collections'>;
legacy: Pick<AstroConfig['legacy'], 'collectionsBackwardsCompat'>;
},
pluginTranslations: PluginTranslations
): NonNullable<ViteUserConfig['plugins']>[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];
},
},
};
}
@@ -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;
}
transform: {
filter: {
id: { include: pluginTransformIdIncludeFilter, exclude: pluginTransformIdExcludeFilter },
code: pluginTransformCodeFilter,
},
handler(code, id) {
let ast: ReturnType<typeof this.parse>;
let ast: ReturnType<typeof this.parse>;
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' }),
};
},
},
};
}
+10 -11
View File
@@ -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",
+3 -9
View File
@@ -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 `<head>` 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<typeof StarlightFrontmatterSchema>;
/** Plain object, union, and intersection Zod types. */
type BaseSchemaWithoutEffects =
| z.AnyZodObject
| z.ZodUnion<[BaseSchemaWithoutEffects, ...BaseSchemaWithoutEffects[]]>
| z.ZodDiscriminatedUnion<string, z.AnyZodObject[]>
| z.ZodIntersection<BaseSchemaWithoutEffects, BaseSchemaWithoutEffects>;
/** Base subset of Zod types that we support passing to the `extend` option. */
type BaseSchema = BaseSchemaWithoutEffects | z.ZodEffects<BaseSchemaWithoutEffects>;
type BaseSchema = z.core.$ZodType;
/** Type that extends Starlight’s default schema with an optional, user-defined schema. */
type ExtendedSchema<T extends BaseSchema = never> = [T] extends [never]
+9 -8
View File
@@ -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<typeof BadgeComponentSchema>;
+1 -1
View File
@@ -261,5 +261,5 @@ export function ComponentConfigSchema() {
*/
EditLink: z.string().default('@astrojs/starlight/components/EditLink.astro'),
})
.default({});
.prefault({});
}
@@ -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();
+4 -6
View File
@@ -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;
+3 -2
View File
@@ -17,7 +17,7 @@ export const HeadConfigSchema = ({
/** Name of the HTML tag to add to `<head>`, 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,
});
})
)
+1 -1
View File
@@ -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([]),
+139 -144
View File
@@ -1,6 +1,6 @@
import { z } from 'astro/zod';
interface i18nSchemaOpts<T extends z.AnyZodObject = z.SomeZodObject> {
interface i18nSchemaOpts<T extends z.ZodObject = BaseExtendSchema> {
/**
* Extend Starlight’s i18n schema with additional fields.
*
@@ -19,147 +19,152 @@ interface i18nSchemaOpts<T extends z.AnyZodObject = z.SomeZodObject> {
}
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<typeof defaultI18nSchema>;
/**
* 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<A extends z.AnyZodObject, B extends z.AnyZodObject> = z.ZodObject<
z.objectUtil.extendShape<A['shape'], B['shape']>,
'passthrough',
B['_def']['catchall']
type MergeSchemas<A extends z.ZodObject, B extends z.ZodObject> = z.ZodObject<
z.util.Extend<A['shape'], B['shape']>,
z.core.$loose
>;
/** Type that extends Starlight’s default i18n schema with an optional, user-defined schema. */
type ExtendedSchema<T extends z.AnyZodObject> = T extends z.AnyZodObject
type ExtendedSchema<T extends z.ZodObject> = T extends z.ZodObject
? MergeSchemas<DefaultI18nSchema, T>
: 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<T extends z.AnyZodObject = z.SomeZodObject>({
export function i18nSchema<T extends z.ZodObject = BaseExtendSchema>({
extend = z.object({}) as T,
}: i18nSchemaOpts<T> = {}): ExtendedSchema<T> {
return defaultI18nSchema().merge(extend).passthrough() as ExtendedSchema<T>;
return z.looseObject({
...defaultI18nSchema().shape,
...extend.shape,
}) as ExtendedSchema<T>;
}
export type i18nSchemaOutput = z.output<ReturnType<typeof i18nSchema>>;
export type i18nSchemaOutput = z.output<ExtendedSchema<BaseExtendSchema>>;
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 `<nav>` element to distinguish it from other `<nav>` landmarks on the page.'
),
'sidebarNav.accessibleLabel': z.string().meta({
description:
'Accessible label for the main sidebar `<nav>` element to distinguish it from other `<nav>` landmarks on the page.',
}),
'tableOfContents.onThisPage': z
.string()
.describe('Title for the table of contents component.'),
.meta({ description: 'Title for the table of contents component.' }),
'tableOfContents.overview': z
.string()
.describe(
'Label used for the first link in the table of contents, linking to the page title.'
),
'tableOfContents.overview': z.string().meta({
description:
'Label used for the first link in the table of contents, linking to the page title.',
}),
'i18n.untranslatedContent': z
.string()
.describe(
'Notice informing users they are on a page that is not yet translated to their language.'
),
'i18n.untranslatedContent': z.string().meta({
description:
'Notice informing users they are on a page that is not yet translated to their language.',
}),
'page.editLink': z.string().describe('Text for the link to edit a page.'),
'page.editLink': z.string().meta({ description: 'Text for the link to edit a page.' }),
'page.lastUpdated': z
.string()
.describe('Text displayed in front of the last updated date in the page footer.'),
'page.lastUpdated': z.string().meta({
description: 'Text displayed in front of the last updated date in the page footer.',
}),
'page.previousLink': z
.string()
.describe('Label shown on the “previous page” pagination arrow in the page footer.'),
'page.previousLink': z.string().meta({
description: 'Label shown on the “previous page” pagination arrow in the page footer.',
}),
'page.nextLink': z
.string()
.describe('Label shown on the “next page” pagination arrow in the page footer.'),
'page.nextLink': z.string().meta({
description: 'Label shown on the “next page” pagination arrow in the page footer.',
}),
'page.draft': z
.string()
.describe(
'Development-only notice informing users they are on a page that is a draft which will not be included in production builds.'
),
'page.draft': z.string().meta({
description:
'Development-only notice informing users they are on a page that is a draft which will not be included in production builds.',
}),
'404.text': z.string().describe('Text shown on Starlight’s default 404 page'),
'aside.tip': z.string().describe('Text shown on the tip aside variant'),
'aside.note': z.string().describe('Text shown on the note aside variant'),
'aside.caution': z.string().describe('Text shown on the warning aside variant'),
'aside.danger': z.string().describe('Text shown on the danger aside variant'),
'404.text': z.string().meta({ description: 'Text shown on Starlight’s default 404 page' }),
'aside.tip': z.string().meta({ description: 'Text shown on the tip aside variant' }),
'aside.note': z.string().meta({ description: 'Text shown on the note aside variant' }),
'aside.caution': z.string().meta({ description: 'Text shown on the warning aside variant' }),
'aside.danger': z.string().meta({ description: 'Text shown on the danger aside variant' }),
'fileTree.directory': z
.string()
.describe('Label for the directory icon in the file tree component.'),
.meta({ description: 'Label for the directory icon in the file tree component.' }),
'builtWithStarlight.label': z
'builtWithStarlight.label': z.string().meta({
description:
'Label for the “Built with Starlight” badge optionally displayed in the site footer.',
}),
'heading.anchorLabel': z
.string()
.describe(
'Label for the “Built with Starlight” badge optionally displayed in the site footer.'
),
'heading.anchorLabel': z.string().describe('Label for anchor links in Markdown content.'),
.meta({ description: 'Label for anchor links in Markdown content.' }),
})
.partial();
}
@@ -167,65 +172,55 @@ function starlightI18nSchema() {
function pagefindI18nSchema() {
return z
.object({
'pagefind.clear_search': z
.string()
.describe(
'Pagefind UI translation. English default value: `"Clear"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.clear_search': z.string().meta({
description:
'Pagefind UI translation. English default value: `"Clear"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.load_more': z
.string()
.describe(
'Pagefind UI translation. English default value: `"Load more results"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.load_more': z.string().meta({
description:
'Pagefind UI translation. English default value: `"Load more results"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.search_label': z
.string()
.describe(
'Pagefind UI translation. English default value: `"Search this site"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.search_label': z.string().meta({
description:
'Pagefind UI translation. English default value: `"Search this site"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.filters_label': z
.string()
.describe(
'Pagefind UI translation. English default value: `"Filters"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.filters_label': z.string().meta({
description:
'Pagefind UI translation. English default value: `"Filters"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.zero_results': z
.string()
.describe(
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.zero_results': z.string().meta({
description:
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.many_results': z
.string()
.describe(
'Pagefind UI translation. English default value: `"[COUNT] results for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.many_results': z.string().meta({
description:
'Pagefind UI translation. English default value: `"[COUNT] results for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.one_result': z
.string()
.describe(
'Pagefind UI translation. English default value: `"[COUNT] result for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.one_result': z.string().meta({
description:
'Pagefind UI translation. English default value: `"[COUNT] result for [SEARCH_TERM]"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.alt_search': z
.string()
.describe(
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.alt_search': z.string().meta({
description:
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.search_suggestion': z
.string()
.describe(
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]. Try one of the following searches:"`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.search_suggestion': z.string().meta({
description:
'Pagefind UI translation. English default value: `"No results for [SEARCH_TERM]. Try one of the following searches:"`. See https://pagefind.app/docs/ui/#translations',
}),
'pagefind.searching': z
.string()
.describe(
'Pagefind UI translation. English default value: `"Searching for [SEARCH_TERM]..."`. See https://pagefind.app/docs/ui/#translations'
),
'pagefind.searching': z.string().meta({
description:
'Pagefind UI translation. English default value: `"Searching for [SEARCH_TERM]..."`. See https://pagefind.app/docs/ui/#translations',
}),
})
.partial();
}
@@ -233,17 +228,17 @@ function pagefindI18nSchema() {
function expressiveCodeI18nSchema() {
return z
.object({
'expressiveCode.copyButtonCopied': z
.string()
.describe('Expressive Code UI translation. English default value: `"Copied!"`'),
'expressiveCode.copyButtonCopied': z.string().meta({
description: 'Expressive Code UI translation. English default value: `"Copied!"`',
}),
'expressiveCode.copyButtonTooltip': z
.string()
.describe('Expressive Code UI translation. English default value: `"Copy to clipboard"`'),
'expressiveCode.copyButtonTooltip': z.string().meta({
description: 'Expressive Code UI translation. English default value: `"Copy to clipboard"`',
}),
'expressiveCode.terminalWindowFallbackTitle': z
.string()
.describe('Expressive Code UI translation. English default value: `"Terminal window"`'),
'expressiveCode.terminalWindowFallbackTitle': z.string().meta({
description: 'Expressive Code UI translation. English default value: `"Terminal window"`',
}),
})
.partial();
}
+5 -4
View File
@@ -57,7 +57,7 @@ const pagefindIndexOptionsSchema = z.object({
*
* @see https://pagefind.app/docs/multisite/#filtering-results-by-index
*/
mergeFilter: z.record(z.string(), z.string().or(z.array(z.string()).nonempty())).optional(),
mergeFilter: z.record(z.string(), z.string().or(z.tuple([z.string()], z.string()))).optional(),
/**
* Language of this index.
*
@@ -68,7 +68,7 @@ const pagefindIndexOptionsSchema = z.object({
* Configure how search result rankings are calculated by Pagefind.
*/
// We apply a default value to merged indexes in order to share the same ranking for them and the current site when not set explicitly.
ranking: pagefindRankingWeightsSchema.default({}),
ranking: pagefindRankingWeightsSchema.prefault({}),
});
const pagefindSchema = z.object({
@@ -80,7 +80,7 @@ const pagefindSchema = z.object({
*/
indexWeight: indexWeightSchema,
/** Configure how search result rankings are calculated by Pagefind. */
ranking: pagefindRankingWeightsSchema.default({}),
ranking: pagefindRankingWeightsSchema.prefault({}),
/**
* Configure how search indexes from different sites are merged by Pagefind.
*
@@ -93,7 +93,8 @@ const pagefindSchema = z.object({
*
* @see https://github.com/CloudCannon/pagefind/blob/v1.3.0/pagefind_web_js/lib/coupled_search.ts#L549
*/
pagefindIndexOptionsSchema.extend({
z.object({
...pagefindIndexOptionsSchema.shape,
/**
* Set Pagefind’s `bundlePath` mergeIndex option.
*
+6 -8
View File
@@ -5,14 +5,12 @@ export const PrevNextLinkConfigSchema = () =>
.union([
z.boolean(),
z.string(),
z
.object({
/** The navigation link URL. */
link: z.string().optional(),
/** The navigation link text. */
label: z.string().optional(),
})
.strict(),
z.strictObject({
/** The navigation link URL. */
link: z.string().optional(),
/** The navigation link text. */
label: z.string().optional(),
}),
])
.optional();
+33 -27
View File
@@ -8,12 +8,13 @@ const SidebarBaseSchema = z.object({
/** The visible label for this item in the sidebar. */
label: z.string(),
/** Translations of the `label` for each supported language. */
translations: z.record(z.string()).default({}),
translations: z.record(z.string(), z.string()).default({}),
/** Adds a badge to the item */
badge: I18nBadgeConfigSchema(),
});
const SidebarGroupSchema = SidebarBaseSchema.extend({
const SidebarGroupSchema = z.object({
...SidebarBaseSchema.shape,
/**
* Explicitly prevent custom attributes on groups as the final type for supported sidebar item
* is a non-discriminated union where TypeScript will not perform excess property checks.
@@ -30,21 +31,27 @@ const SidebarGroupSchema = SidebarBaseSchema.extend({
// `Record<string, string | number | boolean | undefined>` but typed as `HTMLAttributes<'a'>`
// for user convenience.
const linkHTMLAttributesSchema = z.record(
z.string(),
z.union([z.string(), z.number(), z.boolean(), z.undefined(), z.null()])
) as z.Schema<Omit<HTMLAttributes<'a'>, keyof AstroBuiltinAttributes | 'children'>>;
export type LinkHTMLAttributes = z.infer<typeof linkHTMLAttributesSchema>;
) as z.ZodType<LinkHTMLAttributes, LinkHTMLAttributes>;
export type LinkHTMLAttributes = Omit<
HTMLAttributes<'a'>,
keyof AstroBuiltinAttributes | 'children'
>;
export const SidebarLinkItemHTMLAttributesSchema = () => linkHTMLAttributesSchema.default({});
const SidebarLinkItemSchema = SidebarBaseSchema.extend({
const SidebarLinkItemSchema = z.strictObject({
...SidebarBaseSchema.shape,
/** The link to this item’s content. Can be a relative link to local files or the full URL of an external page. */
link: z.string(),
/** HTML attributes to add to the link item. */
attrs: SidebarLinkItemHTMLAttributesSchema(),
}).strict();
});
export type SidebarLinkItem = z.infer<typeof SidebarLinkItemSchema>;
const AutoSidebarGroupSchema = SidebarGroupSchema.extend({
const AutoSidebarGroupSchema = z.strictObject({
...SidebarGroupSchema.shape,
/** Enable autogenerating a sidebar category from a specific docs directory. */
autogenerate: z.object({
/** The directory to generate sidebar items for. */
@@ -60,7 +67,7 @@ const AutoSidebarGroupSchema = SidebarGroupSchema.extend({
/** How many directories deep to include from this directory in the sidebar. Default: `Infinity`. */
// depth: z.number().optional(),
}),
}).strict();
});
export type AutoSidebarGroup = z.infer<typeof AutoSidebarGroupSchema>;
type ManualSidebarGroupInput = z.input<typeof SidebarGroupSchema> & {
@@ -85,26 +92,25 @@ type ManualSidebarGroupOutput = z.output<typeof SidebarGroupSchema> & {
>;
};
const ManualSidebarGroupSchema: z.ZodType<
ManualSidebarGroupOutput,
z.ZodTypeDef,
ManualSidebarGroupInput
> = SidebarGroupSchema.extend({
/** Array of links and subcategories to display in this category. */
items: z.lazy(() =>
z
.union([
SidebarLinkItemSchema,
ManualSidebarGroupSchema,
AutoSidebarGroupSchema,
InternalSidebarLinkItemSchema,
InternalSidebarLinkItemShorthandSchema,
])
.array()
),
}).strict();
const ManualSidebarGroupSchema: z.ZodType<ManualSidebarGroupOutput, ManualSidebarGroupInput> =
z.strictObject({
...SidebarGroupSchema.shape,
/** Array of links and subcategories to display in this category. */
items: z.lazy(() =>
z
.union([
SidebarLinkItemSchema,
ManualSidebarGroupSchema,
AutoSidebarGroupSchema,
InternalSidebarLinkItemSchema,
InternalSidebarLinkItemShorthandSchema,
])
.array()
),
});
const InternalSidebarLinkItemSchema = SidebarBaseSchema.partial({ label: true }).extend({
const InternalSidebarLinkItemSchema = z.object({
...SidebarBaseSchema.partial({ label: true }).shape,
/** The link to this item’s content. Must be a slug of a Content Collection entry. */
slug: z.string(),
/** HTML attributes to add to the link item. */
+4 -6
View File
@@ -1,9 +1,6 @@
import { z } from 'astro/zod';
export const TitleConfigSchema = () =>
z
.union([z.string(), z.record(z.string())])
.describe('Title for your website. Will be used in metadata and as browser tab title.');
export const TitleConfigSchema = () => z.union([z.string(), z.record(z.string(), z.string())]);
// transform the title for runtime use
export const TitleTransformConfigSchema = (defaultLang: string) =>
@@ -12,9 +9,10 @@ export const TitleTransformConfigSchema = (defaultLang: string) =>
return { [defaultLang]: title };
}
if (!title[defaultLang] && title[defaultLang] !== '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
ctx.issues.push({
code: 'custom',
message: `Title must have a key for the default language "${defaultLang}"`,
input: title,
});
return z.NEVER;
}
+3 -2
View File
@@ -11,11 +11,12 @@ export const SocialLinksSchema = () =>
// TODO: remove once most people have updated to v0.33 or higher (e.g. when releasing Starlight v1)
z.preprocess((value, ctx) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
ctx.issues.push({
code: 'custom',
message:
'Starlight v0.33.0 changed the `social` configuration syntax. Please specify an array of link items instead of an object.\n' +
'See the Starlight changelog for details: https://github.com/withastro/starlight/blob/main/packages/starlight/CHANGELOG.md#0330\n',
input: value,
});
}
return value;
@@ -7,13 +7,13 @@ export const TableOfContentsSchema = () =>
.union([
z.object({
/** The level to start including headings at in the table of contents. Default: 2. */
minHeadingLevel: z.number().int().min(1).max(6).optional().default(2),
minHeadingLevel: z.int().min(1).max(6).optional().default(2),
/** The level to stop including headings at in the table of contents. Default: 3. */
maxHeadingLevel: z.number().int().min(1).max(6).optional().default(3),
maxHeadingLevel: z.int().min(1).max(6).optional().default(3),
}),
z.boolean().transform((enabled) => (enabled ? defaults : false)),
])
.default(defaults)
.refine((toc) => (toc ? toc.minHeadingLevel <= toc.maxHeadingLevel : true), {
message: 'minHeadingLevel must be less than or equal to maxHeadingLevel',
error: 'minHeadingLevel must be less than or equal to maxHeadingLevel',
});
@@ -107,7 +107,7 @@ function localeToLang(
type BuiltInStrings = (typeof builtinTranslations)['en'];
/** Build an i18next resources dictionary by layering preferred translation sources. */
function buildResources<T extends Record<string, string | undefined>>(
function buildResources<T extends i18nSchemaOutput>(
...dictionaries: (T | BuiltInStrings | undefined)[]
): { [I18nextNamespace]: BuiltInStrings & T } {
const dictionary: Partial<BuiltInStrings> = {};
+98 -57
View File
@@ -4,12 +4,16 @@
*/
import { AstroError } from 'astro/errors';
import type { z } from 'astro:content';
import { z, locales } from 'astro/zod';
type TypeOrLiteralErrByPathEntry = {
code: 'invalid_type' | 'invalid_literal';
// The default Zod error map that we use to retrieve default error messages.
const zodErrorMap = locales.en().localeError;
type TypeErrByPathEntry = {
code: 'invalid_type';
received: unknown;
expected: unknown[];
message: string | undefined;
};
/**
@@ -20,12 +24,15 @@ type TypeOrLiteralErrByPathEntry = {
* @param message Error message preamble to use if the input fails to parse.
* @returns Validated data parsed by Zod.
*/
export function parseWithFriendlyErrors<T extends z.Schema>(
export function parseWithFriendlyErrors<T extends z.ZodType>(
schema: T,
input: z.input<T>,
message: string
): z.output<T> {
return processParsedData<T>(schema.safeParse(input, { errorMap }), message);
return processParsedData<T>(
schema.safeParse(input, { error: errorMap, reportInput: true }),
message
);
}
/**
@@ -37,16 +44,19 @@ export function parseWithFriendlyErrors<T extends z.Schema>(
* @param message Error message preamble to use if the input fails to parse.
* @returns Validated data parsed by Zod.
*/
export async function parseAsyncWithFriendlyErrors<T extends z.Schema>(
export async function parseAsyncWithFriendlyErrors<T extends z.ZodType>(
schema: T,
input: z.input<T>,
message: string
): Promise<z.output<T>> {
return processParsedData<T>(await schema.safeParseAsync(input, { errorMap }), message);
return processParsedData<T>(
await schema.safeParseAsync(input, { error: errorMap, reportInput: true }),
message
);
}
function processParsedData<T extends z.Schema>(
parsedData: z.SafeParseReturnType<T, T>,
function processParsedData<T extends z.ZodType>(
parsedData: z.ZodSafeParseResult<z.output<T>>,
message: string
) {
if (!parsedData.success) {
@@ -55,25 +65,27 @@ function processParsedData<T extends z.Schema>(
return parsedData.data;
}
const errorMap: z.ZodErrorMap = (baseError, ctx) => {
const baseErrorPath = flattenErrorPath(baseError.path);
if (baseError.code === 'invalid_union') {
const errorMap: z.core.$ZodErrorMap = (issue) => {
const baseErrorPath = flattenErrorPath(issue.path ?? []);
if (issue.code === 'invalid_union') {
// Optimization: Combine type and literal errors for keys that are common across ALL union types
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
// raise a single error when `key` does not match:
// > Did not match union.
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
const typeOrLiteralErrByPath: Map<string, TypeOrLiteralErrByPathEntry> = new Map();
for (const unionError of baseError.unionErrors.map((e) => e.errors).flat()) {
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
const flattenedErrorPath = flattenErrorPath(unionError.path);
const unionErrors = issue.errors.flat();
const typeOrLiteralErrByPath: Map<string, TypeErrByPathEntry> = new Map();
for (const unionError of unionErrors) {
if (unionError.code === 'invalid_type') {
const flattenedErrorPath = flattenErrorPath([baseErrorPath, ...unionError.path]);
if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected);
} else {
typeOrLiteralErrByPath.set(flattenedErrorPath, {
code: unionError.code,
received: unionError.received,
received: parsedType(issue.input),
expected: [unionError.expected],
message: unionError.message,
});
}
}
@@ -82,83 +94,84 @@ const errorMap: z.ZodErrorMap = (baseError, ctx) => {
const details: string[] = [...typeOrLiteralErrByPath.entries()]
// If type or literal error isn't common to ALL union types,
// filter it out. Can lead to confusing noise.
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
.filter(([, error]) => error.expected.length === unionErrors.length)
.map(([key, error]) =>
key === baseErrorPath
? // Avoid printing the key again if it's a base error
`> ${getTypeOrLiteralMsg(error)}`
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`
`> ${getTypeErrMsg(error)}`
: `> ${prefix(key, getTypeErrMsg(error))}`
);
if (details.length === 0) {
const expectedShapes: string[] = [];
for (const unionError of baseError.unionErrors) {
for (const unionError of issue.errors) {
const expectedShape: string[] = [];
for (const issue of unionError.issues) {
for (const issue of unionError) {
// If the issue is a nested union error, show the associated error message instead of the
// base error message.
if (issue.code === 'invalid_union') {
return errorMap(issue, ctx);
return errorMap({ ...issue, input: issue.input, path: [baseErrorPath, ...issue.path] });
}
const relativePath = flattenErrorPath(issue.path)
.replace(baseErrorPath, '')
.replace(leadingPeriod, '');
if ('expected' in issue && typeof issue.expected === 'string') {
if (issue.code === 'invalid_type') {
expectedShape.push(
relativePath ? `${relativePath}: ${issue.expected}` : issue.expected
);
} else {
} else if (issue.code === 'custom') {
expectedShape.push(relativePath);
}
}
if (expectedShape.length === 1 && !expectedShape[0]?.includes(':')) {
// In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`.
expectedShapes.push(expectedShape.join(''));
} else {
} else if (expectedShape.length > 0) {
expectedShapes.push(`{ ${expectedShape.join('; ')} }`);
}
}
if (expectedShapes.length) {
details.push('> Expected type `' + expectedShapes.join(' | ') + '`');
details.push('> Received `' + stringify(ctx.data) + '`');
details.push('> Received `' + stringify(issue.input) + '`');
}
}
return {
message: messages.concat(details).join('\n'),
};
} else if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
return {
message: prefix(
baseErrorPath,
getTypeOrLiteralMsg({
code: baseError.code,
received: baseError.received,
expected: [baseError.expected],
})
),
};
} else if (baseError.message) {
return { message: prefix(baseErrorPath, baseError.message) };
return messages.concat(details).join('\n');
} else if (issue.code === 'invalid_type') {
return prefix(
baseErrorPath,
getTypeErrMsg({
code: issue.code,
received: parsedType(issue.input),
expected: [issue.expected],
message: issue.message,
})
);
} else if (issue.message) {
return prefix(baseErrorPath, issue.message);
} else {
return { message: prefix(baseErrorPath, ctx.defaultError) };
// By design, the default Zod error may not be provided in Zod 4 error maps. Instead, error
// maps are supposed to return `undefined` in order to yield control to the next error map in
// the precedence chain. Unfortunately, this prevents us from prefixing all errors with their
// paths so we have to manually invoke the default Zod error map here.
const defaultError = zodErrorMap(issue);
if (!defaultError) return;
return prefix(
baseErrorPath,
typeof defaultError === 'string' ? defaultError : defaultError.message
);
}
};
const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
const getTypeErrMsg = (error: TypeErrByPathEntry): string => {
// received could be `undefined` or the string `'undefined'`
if (typeof error.received === 'undefined' || error.received === 'undefined') return 'Required';
if (typeof error.received === 'undefined' || error.received === 'undefined')
return error.message ?? 'Required';
const expectedDeduped = new Set(error.expected);
switch (error.code) {
case 'invalid_type':
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
error.received
)}\``;
case 'invalid_literal':
return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
error.received
)}\``;
}
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(
error.received
)}\``;
};
const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);
@@ -166,10 +179,38 @@ const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}`
const unionExpectedVals = (expectedVals: Set<unknown>) =>
[...expectedVals].map((expectedVal) => stringify(expectedVal)).join(' | ');
const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
const flattenErrorPath = (errorPath: PropertyKey[]) => errorPath.join('.');
/** `JSON.stringify()` a value with spaces around object/array entries. */
const stringify = (val: unknown) =>
JSON.stringify(val, null, 1).split(newlinePlusWhitespace).join(' ');
const newlinePlusWhitespace = /\n\s*/;
const leadingPeriod = /^\./;
/**
* In Zod 4, we don't necessarily get a human-readable representation of input data types. For such
* cases, we use the same logic as Zod's own `parsedType()` function.
* @see https://github.com/colinhacks/zod/blob/73b071d7d08825dedb6b48b78718739118ee1308/packages/zod/src/v4/locales/en.ts#L5
*/
const parsedType = (data: unknown): string => {
const t = typeof data;
switch (t) {
case 'number': {
return Number.isNaN(data) ? 'NaN' : 'number';
}
case 'object': {
if (Array.isArray(data)) {
return 'array';
}
if (data === null) {
return 'null';
}
if (data && Object.getPrototypeOf(data) !== Object.prototype && data.constructor) {
return data.constructor.name;
}
}
}
return t;
};
+11 -10
View File
@@ -28,7 +28,7 @@ import type {
Route,
SidebarEntry,
} from './routing/types';
import { localeToLang, localizedId, slugToPathname } from './slugs';
import { localeToLang, localizedFilePath, slugToPathname } from './slugs';
import { isAbsoluteUrl } from './url';
import type { StarlightConfig } from './user-config';
@@ -145,7 +145,7 @@ function linkFromInternalSidebarLinkItem(
// Astro passes root `index.[md|mdx]` entries with a slug of `index`
const slug = item.slug === 'index' ? '' : item.slug;
const localizedSlug = locale ? (slug ? locale + '/' + slug : locale) : slug;
const route = routes.find((entry) => localizedSlug === entry.slug);
const route = routes.find((entry) => localizedSlug === entry.id);
if (!route) {
const hasExternalSlashes = item.slug.at(0) === '/' || item.slug.at(-1) === '/';
if (hasExternalSlashes) {
@@ -170,7 +170,7 @@ function linkFromInternalSidebarLinkItem(
const badge = item.badge ?? frontmatter.sidebar?.badge;
const attrs = { ...frontmatter.sidebar?.attrs, ...item.attrs };
return makeSidebarLink(
slugToPathname(route.slug),
slugToPathname(route.id),
label,
getSidebarBadge(badge, locale, label),
attrs
@@ -225,12 +225,13 @@ function getBreadcrumbs(path: string, baseDir: string): string[] {
return relativePath.split('/');
}
/** Return the path of a route relative to the root of the collection, which is equivalent to legacy IDs. */
/** Return the path of a route relative to the root of the collection. */
function getRoutePathRelativeToCollectionRoot(route: Route, locale: string | undefined) {
return project.legacyCollections
? route.id
: // For collections with a loader, use a localized filePath relative to the collection
localizedId(route.entry.filePath.replace(`${docsCollectionPathFromRoot}/`, ''), locale);
// Use a localized filePath relative to the collection
return localizedFilePath(
route.entry.filePath.replace(`${docsCollectionPathFromRoot}/`, ''),
locale
);
}
/** Turn a flat array of routes into a tree structure. */
@@ -274,7 +275,7 @@ function treeify(routes: Route[], locale: string | undefined, baseDir: string):
/** Create a link entry for a given content collection entry. */
function linkFromRoute(route: Route, attrs?: LinkHTMLAttributes): SidebarLink {
return makeSidebarLink(
slugToPathname(route.slug),
slugToPathname(route.id),
route.entry.data.sidebar.label || route.entry.data.title,
route.entry.data.sidebar.badge,
{ ...attrs, ...route.entry.data.sidebar.attrs }
@@ -300,7 +301,7 @@ function sortDirEntries(dir: [string, Dir | Route][]): [string, Dir | Route][] {
// Pages are sorted by order in ascending order.
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
// If two pages have the same order value they will be sorted by their slug.
return collator.compare(isDir(a) ? a[SlugKey] : a.slug, isDir(b) ? b[SlugKey] : b.slug);
return collator.compare(isDir(a) ? a[SlugKey] : a.id, isDir(b) ? b[SlugKey] : b.id);
});
}
+75 -40
View File
@@ -183,8 +183,8 @@ export function injectPluginTranslationsTypes(
// https://github.com/withastro/astro/blob/910eb00fe0b70ca80bd09520ae100e8c78b675b5/packages/astro/src/core/config/schema.ts#L113
const astroIntegrationSchema = z.object({
name: z.string(),
hooks: z.object({}).passthrough().default({}),
}) as z.Schema<AstroIntegration>;
hooks: z.looseObject({}).default({}),
}) as z.ZodType<AstroIntegration, AstroIntegration>;
const routeMiddlewareConfigSchema = z.object({
entrypoint: z.string(),
@@ -196,9 +196,12 @@ const baseStarlightPluginSchema = z.object({
name: z.string(),
});
type StarlightConfigUpdate = Partial<Omit<StarlightUserConfig, 'routeMiddleware'>>;
type StarlightI18nTFactory = Awaited<ReturnType<typeof createTranslationSystemFromFs>>;
const configSetupHookSchema = z
.function(
z.tuple([
.function({
input: [
z.object({
/**
* A read-only copy of the user-supplied Starlight configuration.
@@ -206,9 +209,10 @@ const configSetupHookSchema = z
* Note that this configuration may have been updated by other plugins configured
* before this one.
*/
config: z.any() as z.Schema<
config: z.any() as z.ZodType<
// The configuration passed to plugins should contains the list of plugins.
StarlightUserConfig & { plugins?: z.input<typeof baseStarlightPluginSchema>[] }
StarlightUserConfigWithPlugins,
StarlightUserConfigWithPlugins
>,
/**
* A callback function to update the user-supplied Starlight configuration.
@@ -228,12 +232,15 @@ const configSetupHookSchema = z
* }
* }
*/
updateConfig: z.function(
z.tuple([
z.record(z.any()) as z.Schema<Partial<Omit<StarlightUserConfig, 'routeMiddleware'>>>,
]),
z.void()
),
updateConfig: z.function({
input: [
z.record(z.string(), z.any()) as z.ZodType<
StarlightConfigUpdate,
StarlightConfigUpdate
>,
],
output: z.void(),
}),
/**
* A callback function to add an Astro integration required by this plugin.
*
@@ -256,7 +263,10 @@ const configSetupHookSchema = z
* }
* }
*/
addIntegration: z.function(z.tuple([astroIntegrationSchema]), z.void()),
addIntegration: z.function({
input: [astroIntegrationSchema],
output: z.void(),
}),
/**
* A callback function to register additional route middleware handlers.
*
@@ -273,7 +283,10 @@ const configSetupHookSchema = z
* },
* }
*/
addRouteMiddleware: z.function(z.tuple([routeMiddlewareConfigSchema]), z.void()),
addRouteMiddleware: z.function({
input: [routeMiddlewareConfigSchema],
output: z.void(),
}),
/**
* A read-only copy of the user-supplied Astro configuration.
*
@@ -281,26 +294,38 @@ const configSetupHookSchema = z
*
* @see https://docs.astro.build/en/reference/integrations-reference/#config-option
*/
astroConfig: z.any() as z.Schema<StarlightPluginContext['config']>,
astroConfig: z.any() as z.ZodType<
StarlightPluginContext['config'],
StarlightPluginContext['config']
>,
/**
* The command used to run Starlight.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#command-option
*/
command: z.any() as z.Schema<StarlightPluginContext['command']>,
command: z.any() as z.ZodType<
StarlightPluginContext['command'],
StarlightPluginContext['command']
>,
/**
* `false` when the dev server starts, `true` when a reload is triggered.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#isrestart-option
*/
isRestart: z.any() as z.Schema<StarlightPluginContext['isRestart']>,
isRestart: z.any() as z.ZodType<
StarlightPluginContext['isRestart'],
StarlightPluginContext['isRestart']
>,
/**
* An instance of the Astro integration logger with all logged messages prefixed with the
* plugin name.
*
* @see https://docs.astro.build/en/reference/integrations-reference/#astrointegrationlogger
*/
logger: z.any() as z.Schema<StarlightPluginContext['logger']>,
logger: z.any() as z.ZodType<
StarlightPluginContext['logger'],
StarlightPluginContext['logger']
>,
/**
* A callback function to generate a utility function to access UI strings for a given
* language.
@@ -319,9 +344,7 @@ const configSetupHookSchema = z
* }
* }
*/
useTranslations: z.any() as z.Schema<
Awaited<ReturnType<typeof createTranslationSystemFromFs>>
>,
useTranslations: z.any() as z.ZodType<StarlightI18nTFactory, StarlightI18nTFactory>,
/**
* A callback function to get the language for a given absolute file path. The returned
* language can be used with the `useTranslations` helper to get UI strings for that
@@ -344,11 +367,17 @@ const configSetupHookSchema = z
* }
* }
*/
absolutePathToLang: z.function(z.tuple([z.string()]), z.string()),
absolutePathToLang: z.function({
input: [z.string()],
output: z.string(),
}),
}),
]),
z.union([z.void(), z.promise(z.void())])
)
],
// We still rely on the deprecated `z.promise()` to define the function output as the only
// non-deprecated way to define an async function is to use `.implementAsync()` but we don't
// want to implement the function using Zod.
output: z.promise(z.void()),
})
.optional();
/**
@@ -356,8 +385,9 @@ const configSetupHookSchema = z
* user config schema but properly typed for user convenience because we do not want to run any of
* the Zod `transform`s used in the user config schema when running plugins.
*/
const starlightPluginSchema = baseStarlightPluginSchema
.extend({
const starlightPluginSchema = z
.object({
...baseStarlightPluginSchema.shape,
/** The different hooks available to the plugin. */
hooks: z.object({
/**
@@ -366,8 +396,8 @@ const starlightPluginSchema = baseStarlightPluginSchema
* and plugin UI.
*/
'i18n:setup': z
.function(
z.tuple([
.function({
input: [
z.object({
/**
* A callback function to add or update translations strings.
@@ -391,14 +421,17 @@ const starlightPluginSchema = baseStarlightPluginSchema
* }
* }
*/
injectTranslations: z.function(
z.tuple([z.record(z.string(), z.record(z.string(), z.string()))]),
z.void()
),
injectTranslations: z.function({
input: [z.record(z.string(), z.record(z.string(), z.string()))],
output: z.void(),
}),
}),
]),
z.union([z.void(), z.promise(z.void())])
)
],
// We still rely on the deprecated `z.promise()` to define the function output as the
// only non-deprecated way to define an async function is to use `.implementAsync()` but
// we don't want to implement the function using Zod.
output: z.promise(z.void()),
})
.optional(),
/**
* Plugin configuration setup function called with an object containing various values that
@@ -414,17 +447,19 @@ const starlightPluginSchema = baseStarlightPluginSchema
})
.superRefine((plugin, ctx) => {
if (!plugin.hooks['config:setup'] && !plugin.hooks.setup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
ctx.issues.push({
code: 'custom',
message: 'A plugin must define at least a `config:setup` hook.',
input: plugin,
});
} else if (plugin.hooks['config:setup'] && plugin.hooks.setup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
ctx.issues.push({
code: 'custom',
message:
'A plugin cannot define both a `config:setup` and `setup` hook. ' +
'As `setup` is deprecated and will be removed in a future version, ' +
'consider using `config:setup` instead.',
input: plugin,
});
}
});
+3 -9
View File
@@ -6,12 +6,7 @@ import { getNewestCommitDate } from 'virtual:starlight/git-info';
import { getPrevNextLinks, getSidebar } from '../navigation';
import { ensureTrailingSlash } from '../path';
import { getRouteBySlugParam, normalizeCollectionEntry } from '../routing';
import type {
Route,
StarlightDocsCollectionEntry,
StarlightDocsEntry,
StarlightRouteData,
} from './types';
import type { Route, StarlightDocsEntry, StarlightRouteData } from './types';
import { formatPath } from '../format-path';
import { useTranslations } from '../translations';
import { BuiltInDefaultLocale } from '../i18n';
@@ -137,7 +132,6 @@ async function get404Route(locals: App.Locals): Promise<Route> {
const entryMeta = { dir, lang, locale };
const fallbackEntry: StarlightDocsEntry = {
slug: '404',
id: '404',
body: '',
collection: 'docs',
@@ -154,7 +148,7 @@ async function get404Route(locals: App.Locals): Promise<Route> {
filePath: `${getCollectionPathFromRoot('docs', project)}/404.md`,
};
const userEntry = (await getEntry('docs', '404')) as StarlightDocsCollectionEntry;
const userEntry = await getEntry('docs', '404');
const entry = userEntry ? normalizeCollectionEntry(userEntry) : fallbackEntry;
return { ...entryMeta, entryMeta, entry, id: entry.id, slug: entry.slug };
return { ...entryMeta, entryMeta, entry, id: entry.id };
}
+16 -24
View File
@@ -1,9 +1,7 @@
import type { GetStaticPathsItem } from 'astro';
import { getCollection } from 'astro:content';
import config from 'virtual:starlight/user-config';
import project from 'virtual:starlight/project-context';
import { getCollectionPathFromRoot } from '../collection';
import { localizedId, localizedSlug, slugToLocaleData, slugToParam } from '../slugs';
import { localizedSlug, slugToLocaleData, slugToParam } from '../slugs';
import { validateLogoImports } from '../validateLogoImports';
import { BuiltInDefaultLocale } from '../i18n';
import type { Route, StarlightDocsCollectionEntry, StarlightDocsEntry } from './types';
@@ -24,17 +22,14 @@ interface Path extends GetStaticPathsItem {
*/
const normalizeIndexSlug = (slug: string) => (slug === 'index' ? '' : slug);
/** Normalize the different collection entry we can get from a legacy collection or a loader. */
/** Normalize a collection entry. */
export function normalizeCollectionEntry(entry: StarlightDocsCollectionEntry): StarlightDocsEntry {
const slug = normalizeIndexSlug(entry.slug ?? entry.id);
return {
...entry,
// In a collection with a loader, the `id` is a slug and should be normalized.
id: entry.slug ? entry.id : slug,
// In a legacy collection, the `filePath` property doesn't exist.
filePath: entry.filePath ?? `${getCollectionPathFromRoot('docs', project)}/${entry.id}`,
// In a collection with a loader, the `slug` property is replaced by the `id`.
slug: normalizeIndexSlug(entry.slug ?? entry.id),
// The `id` is a slug and should be normalized.
id: normalizeIndexSlug(entry.id),
// At the moment, Starlight only supports file-based loaders which always include a `filePath`.
filePath: entry.filePath!,
};
}
@@ -49,10 +44,9 @@ const docs: StarlightDocsEntry[] = (
function getRoutes(): Route[] {
const routes: Route[] = docs.map((entry) => ({
entry,
slug: entry.slug,
id: entry.id,
entryMeta: slugToLocaleData(entry.slug),
...slugToLocaleData(entry.slug),
entryMeta: slugToLocaleData(entry.id),
...slugToLocaleData(entry.id),
}));
// In multilingual sites, add required fallback routes.
@@ -68,19 +62,17 @@ function getRoutes(): Route[] {
const locale = key === 'root' ? undefined : key;
const localeDocs = getLocaleDocs(locale);
for (const fallback of defaultLocaleDocs) {
const slug = localizedSlug(fallback.slug, locale);
const id = project.legacyCollections ? localizedId(fallback.id, locale) : slug;
const doesNotNeedFallback = localeDocs.some((doc) => doc.slug === slug);
const id = localizedSlug(fallback.id, locale);
const doesNotNeedFallback = localeDocs.some((doc) => doc.id === id);
if (doesNotNeedFallback) continue;
routes.push({
entry: fallback,
slug,
id,
isFallback: true,
lang: localeConfig.lang || BuiltInDefaultLocale.lang,
locale,
dir: localeConfig.dir,
entryMeta: slugToLocaleData(fallback.slug),
entryMeta: slugToLocaleData(fallback.id),
});
}
}
@@ -93,7 +85,7 @@ export const routes = getRoutes();
function getParamRouteMapping(): ReadonlyMap<string | undefined, Route> {
const map = new Map<string | undefined, Route>();
for (const route of routes) {
map.set(slugToParam(route.slug), route);
map.set(slugToParam(route.id), route);
}
return map;
}
@@ -105,7 +97,7 @@ export function getRouteBySlugParam(slugParam: string | undefined): Route | unde
function getPaths(): Path[] {
return routes.map((route) => ({
params: { slug: slugToParam(route.slug) },
params: { slug: slugToParam(route.id) },
props: route,
}));
}
@@ -128,15 +120,15 @@ function getLocaleDocs(locale: string | undefined): StarlightDocsEntry[] {
}
/** Filter an array to find items whose slug matches the passed locale. */
function filterByLocale<T extends { slug: string }>(items: T[], locale: string | undefined): T[] {
function filterByLocale<T extends { id: string }>(items: T[], locale: string | undefined): T[] {
if (config.locales) {
if (locale && locale in config.locales) {
return items.filter((i) => i.slug === locale || i.slug.startsWith(locale + '/'));
return items.filter((i) => i.id === locale || i.id.startsWith(locale + '/'));
} else if (config.locales.root) {
const langKeys = Object.keys(config.locales).filter((k) => k !== 'root');
const isLangIndex = new RegExp(`^(${langKeys.join('|')})$`);
const isLangDir = new RegExp(`^(${langKeys.join('|')})/`);
return items.filter((i) => !isLangIndex.test(i.slug) && !isLangDir.test(i.slug));
return items.filter((i) => !isLangIndex.test(i.id) && !isLangDir.test(i.id));
}
}
return items;
+5 -17
View File
@@ -40,23 +40,13 @@ export interface PaginationLinks {
next: SidebarLink | undefined;
}
// The type returned from `CollectionEntry` is different for legacy collections and collections
// using a loader. This type is a common subset of both types.
export type StarlightDocsCollectionEntry = Omit<
CollectionEntry<'docs'>,
'id' | 'filePath' | 'render' | 'slug'
> & {
// Update the `id` property to be a string like in the loader type.
id: string;
// Add the `filePath` property which is only present in the loader type.
filePath?: string;
// Add the `slug` property which is only present in the legacy type.
slug?: string;
};
// A type representing a docs collection entry returned by the `docsLoader()`.
export type StarlightDocsCollectionEntry = CollectionEntry<'docs'>;
// A normalized type representing a docs collection entry used throughout Starlight.
export type StarlightDocsEntry = StarlightDocsCollectionEntry & {
// At the moment, Starlight only supports file-based loaders which always include a `filePath`.
filePath: string;
slug: string;
};
export interface Route extends LocaleData {
@@ -64,9 +54,7 @@ export interface Route extends LocaleData {
entry: StarlightDocsEntry;
/** Locale metadata for the page content. Can be different from top-level locale values when a page is using fallback content. */
entryMeta: LocaleData;
/** @deprecated Migrate to the new Content Layer API and use `id` instead. */
slug: string;
/** The slug or unique ID if using the `legacy.collections` flag. */
/** The slug. */
id: string;
/** Whether this page is untranslated in the current language and using fallback content from the default locale. */
isFallback?: boolean;
+11 -12
View File
@@ -80,24 +80,23 @@ export function localizedSlug(slug: string, locale: string | undefined): string
}
/**
* Convert a legacy collection entry ID or filePath relative to the collection root to a different
* locale.
* For example, passing an ID of `en/home.md` and a locale of `fr` results in `fr/home.md`.
* Convert a file path relative to the collection root to a different locale.
* For example, passing a file path of `en/home.md` and a locale of `fr` results in `fr/home.md`.
* An undefined locale is treated as the root locale, resulting in `home.md`.
* @param id A collection entry ID
* @param filePath A collection entry file path relative to the collection root
* @param locale The target locale
* @example
* localizedSlug('en/home.md', 'fr') // => 'fr/home.md'
* localizedSlug('en/home.md', undefined) // => 'home.md'
* localizedFilePath('en/home.md', 'fr') // => 'fr/home.md'
* localizedFilePath('en/home.md', undefined) // => 'home.md'
*/
export function localizedId(id: string, locale: string | undefined): string {
const idLocale = slugToLocale(id);
if (idLocale) {
return id.replace(idLocale + '/', locale ? locale + '/' : '');
export function localizedFilePath(filePath: string, locale: string | undefined): string {
const filePathLocale = slugToLocale(filePath);
if (filePathLocale) {
return filePath.replace(filePathLocale + '/', locale ? locale + '/' : '');
} else if (locale) {
return locale + '/' + id;
return locale + '/' + filePath;
} else {
return id;
return filePath;
}
}
+13 -23
View File
@@ -83,7 +83,7 @@ const validateSidebarProp = (
*/
export type StarlightPageProps = Prettify<
// Remove the index signature from `Route`, omit undesired properties and make the rest optional.
Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> &
Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale'>> &
// Add the sidebar definitions for a Starlight page.
Partial<Pick<StarlightRouteData, 'hasSidebar'>> & {
sidebar?: StarlightUserConfig['sidebar'];
@@ -97,13 +97,7 @@ export type StarlightPageProps = Prettify<
* to a `StarlightDocsEntry`.
* A Starlight page docs entry cannot be rendered like a content collection entry.
*/
type StarlightPageDocsEntry = Omit<StarlightDocsEntry, 'id' | 'render'> & {
/**
* The unique ID if using the `legacy.collections` for this Starlight page which cannot be
* inferred from codegen like content collection entries or the slug.
*/
id: string;
};
type StarlightPageDocsEntry = Omit<StarlightDocsEntry, 'render'>;
export async function generateStarlightPageRouteData({
props,
@@ -114,20 +108,18 @@ export async function generateStarlightPageRouteData({
}): Promise<StarlightRouteData> {
const { frontmatter, ...routeProps } = props;
const { url } = context;
const slug = urlToSlug(url);
const id = urlToSlug(url);
const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter);
const id = project.legacyCollections ? `${stripLeadingAndTrailingSlashes(slug)}.md` : slug;
const localeData = slugToLocaleData(slug);
const localeData = slugToLocaleData(id);
const sidebar = props.sidebar
? getSidebarFromConfig(validateSidebarProp(props.sidebar), url.pathname, localeData.locale)
: getSidebar(url.pathname, localeData.locale);
const headings = props.headings ?? [];
const pageDocsEntry: StarlightPageDocsEntry = {
id,
slug,
body: '',
collection: 'docs',
filePath: `${getCollectionPathFromRoot('docs', project)}/${stripLeadingAndTrailingSlashes(slug)}.md`,
filePath: `${getCollectionPathFromRoot('docs', project)}/${stripLeadingAndTrailingSlashes(id)}.md`,
data: {
...pageFrontmatter,
sidebar: {
@@ -153,7 +145,6 @@ export async function generateStarlightPageRouteData({
headings,
id,
locale: localeData.locale,
slug,
};
const siteTitle = getSiteTitle(localeData.lang);
const routeData: StarlightRouteData = {
@@ -171,7 +162,6 @@ export async function generateStarlightPageRouteData({
sidebar,
siteTitle,
siteTitleHref: getSiteTitleHref(localeData.locale),
slug,
toc: getToC(pageProps),
};
return routeData;
@@ -185,16 +175,16 @@ async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter
// https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32
// It uses a custom validation approach because imported SVGs have a type of `function` as
// well as containing the metadata properties and this ensures we handle those correctly.
z.custom(
z.custom<ReturnType<ImageFunction>>(
(value) =>
(value &&
(typeof value === 'function' || typeof value === 'object') &&
'src' in value &&
'width' in value &&
'height' in value &&
'format' in value) as ReturnType<ImageFunction>,
value &&
(typeof value === 'function' || typeof value === 'object') &&
'src' in value &&
'width' in value &&
'height' in value &&
'format' in value,
'Invalid image passed to `<StarlightPage>` component. Expected imported `ImageMetadata` object.'
)) as ImageFunction,
)) as unknown as ImageFunction,
});
// Starting with Astro 4.14.0, a frontmatter schema that contains collection references will
+3 -4
View File
@@ -32,10 +32,9 @@ async function loadTranslations() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — may be a type error in projects without an i18n collection
(await getCollection('i18n')).map(({ id, data, filePath }) => {
const lang =
project.legacyCollections || !filePath
? id
: stripExtension(stripLeadingSlash(filePath.replace(i18nCollectionPathFromRoot, '')));
const lang = !filePath
? id
: stripExtension(stripLeadingSlash(filePath.replace(i18nCollectionPathFromRoot, '')));
return [lang, data] as const;
})
);
+28 -65
View File
@@ -14,24 +14,11 @@ import { BuiltInDefaultLocale } from './i18n';
const LocaleSchema = z.object({
/** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */
label: z
.string()
.describe(
'The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`.'
),
label: z.string(),
/** The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`. */
lang: z
.string()
.optional()
.describe('The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`.'),
lang: z.string().optional(),
/** The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left. */
dir: z
.enum(['rtl', 'ltr'])
.optional()
.default('ltr')
.describe(
'The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left.'
),
dir: z.enum(['rtl', 'ltr']).optional().default('ltr'),
});
const UserConfigSchema = z.object({
@@ -39,10 +26,7 @@ const UserConfigSchema = z.object({
title: TitleConfigSchema(),
/** Description metadata for your website. Can be used in page metadata. */
description: z
.string()
.optional()
.describe('Description metadata for your website. Can be used in page metadata.'),
description: z.string().optional(),
/** Set a logo image to show in the navigation bar alongside or instead of the site title. */
logo: LogoConfigSchema(),
@@ -62,7 +46,7 @@ const UserConfigSchema = z.object({
social: SocialLinksSchema(),
/** The tagline for your website. */
tagline: z.string().optional().describe('The tagline for your website.'),
tagline: z.string().optional(),
/** Configure the defaults for the table of contents on each page. */
tableOfContents: TableOfContentsSchema(),
@@ -71,7 +55,7 @@ const UserConfigSchema = z.object({
editLink: z
.object({
/** Set the base URL for edit links. The final link will be `baseUrl` + the current page path. */
baseUrl: z.string().url().optional(),
baseUrl: z.url().optional(),
})
.optional()
.default({}),
@@ -96,9 +80,10 @@ const UserConfigSchema = z.object({
// Error if parsing the language tag failed.
if (!normalizedLang) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
ctx.issues.push({
code: 'custom',
message: `Could not validate language tag "${lang}" at locales.${key}.lang.`,
input: lang,
});
return z.NEVER;
}
@@ -116,8 +101,7 @@ const UserConfigSchema = z.object({
}
return locales;
})
.optional()
.describe('Configure locales for internationalization (i18n).'),
.optional(),
/**
* Specify the default language for this site.
@@ -172,27 +156,22 @@ const UserConfigSchema = z.object({
const invalidPathRegex = /^\.?\/public\/.+$/;
const invalidPaths = paths.filter((path) => invalidPathRegex.test(path));
if (invalidPaths.length > 0) {
ctx.addIssue({
ctx.issues.push({
code: 'custom',
message:
`These paths in your Starlight \`customCss\` config are invalid: ${invalidPaths.map((path) => `\`"${path}"\``).join(', ')}\n\n` +
`CSS files specified in \`customCss\` should be in the \`src/\` directory, not the \`public/\` directory.\n\n` +
`You should move these CSS files into the \`src/\` directory and update the path in \`customCss\` to match.`,
input: paths,
});
}
}),
/** Define if the last update date should be visible in the page footer. */
lastUpdated: z
.boolean()
.default(false)
.describe('Define if the last update date should be visible in the page footer.'),
lastUpdated: z.boolean().default(false),
/** Define if the previous and next page links should be visible in the page footer. */
pagination: z
.boolean()
.default(true)
.describe('Define if the previous and next page links should be visible in the page footer.'),
pagination: z.boolean().default(true),
/** The default favicon for your site which should be a path to an image in the `public/` directory. */
favicon: FaviconSchema(),
@@ -218,13 +197,10 @@ const UserConfigSchema = z.object({
components: ComponentConfigSchema(),
/** Will be used as title delimiter in the generated `<title>` tag. */
titleDelimiter: z
.string()
.default('|')
.describe('Will be used as title delimiter in the generated `<title>` tag.'),
titleDelimiter: z.string().default('|'),
/** Disable Starlight's default 404 page. */
disable404Route: z.boolean().default(false).describe("Disable Starlight's default 404 page."),
disable404Route: z.boolean().default(false),
/**
* Define whether Starlight pages should be prerendered or not.
@@ -234,10 +210,7 @@ const UserConfigSchema = z.object({
prerender: z.boolean().default(true),
/** Enable displaying a “Built with Starlight” link in your site’s footer. */
credits: z
.boolean()
.default(false)
.describe('Enable displaying a “Built with Starlight” link in your site’s footer.'),
credits: z.boolean().default(false),
/** Add middleware to process Starlight’s route data for each page. */
routeMiddleware: z
@@ -250,47 +223,36 @@ const UserConfigSchema = z.object({
const invalidPathRegex = /^\.?\/src\/middleware(?:\/index)?\.[jt]s$/;
const invalidPaths = middlewares.filter((middleware) => invalidPathRegex.test(middleware));
for (const invalidPath of invalidPaths) {
ctx.addIssue({
ctx.issues.push({
code: 'custom',
message:
`The \`"${invalidPath}"\` path in your Starlight \`routeMiddleware\` config conflicts with Astro’s middleware locations.\n\n` +
`You should rename \`${invalidPath}\` to something else like \`./src/starlightRouteData.ts\` and update the \`routeMiddleware\` file path to match.\n\n` +
'- More about Starlight route middleware: https://starlight.astro.build/guides/route-data/#how-to-customize-route-data\n' +
'- More about Astro middleware: https://docs.astro.build/en/guides/middleware/',
input: middlewares,
});
}
})
.describe('Add middleware to process Starlight’s route data for each page.'),
}),
/** Configure features that impact Starlight’s Markdown processing. */
markdown: z
.object({
/** Define whether headings in content should be rendered with clickable anchor links. Default: `true`. */
headingLinks: z
.boolean()
.default(true)
.describe(
'Define whether headings in content should be rendered with clickable anchor links. Default: `true`.'
),
headingLinks: z.boolean().default(true),
/**
* Define additional directories where files should be processed by Starlight’s Markdown pipeline.
*
* Supports local directories relative to the root of your project, e.g. './src/data/comments/'.
* Content of the `docs` content collection is always processed by Starlight’s Markdown pipeline.
*/
processedDirs: z
.string()
.array()
.default([])
.describe(
'Define additional directories where files should be processed by Starlight’s Markdown pipeline. Default: `[]`.'
),
processedDirs: z.string().array().default([]),
})
.default({})
.describe('Configure features that impact Starlight’s Markdown processing.'),
.prefault({}),
});
export const StarlightConfigSchema = UserConfigSchema.strict()
export const StarlightConfigSchema = z
.strictObject({ ...UserConfigSchema.shape })
.transform((config) => ({
...config,
// Pagefind only defaults to true if prerender is also true.
@@ -300,7 +262,7 @@ export const StarlightConfigSchema = UserConfigSchema.strict()
: config.pagefind,
}))
.refine((config) => !(!config.prerender && config.pagefind), {
message: 'Pagefind search is not supported with prerendering disabled.',
error: 'Pagefind search is not supported with prerendering disabled.',
})
.transform(({ title, locales, defaultLocale, ...config }, ctx) => {
const configuredLocales = Object.keys(locales ?? {});
@@ -321,12 +283,13 @@ export const StarlightConfigSchema = UserConfigSchema.strict()
if (!defaultLocaleConfig) {
const availableLocales = configuredLocales.map((l) => `"${l}"`).join(', ');
ctx.addIssue({
ctx.issues.push({
code: 'custom',
message:
'Could not determine the default locale. ' +
'Please make sure `defaultLocale` in your Starlight config is one of ' +
availableLocales,
input: locales,
});
return z.NEVER;
}
+6 -3
View File
@@ -8,8 +8,12 @@ declare module 'virtual:starlight/plugin-translations' {
export default PluginTranslations;
}
// TODO: Move back to `virtual-internal.d.ts` when possible. For example, when dropping support for
// legacy collections, `utils/translations.ts` would no longer need to import project context.
// TODO: Technically, we could move back this module declaration to `virtual-internal.d.ts` when
// `utils/translations.ts` no longer need to import project context. Altho, we should not aim for
// such refactor right now as shipping Starlight in JavaScript rather than TypeScript will
// entirely eliminate such issue and the need for private and public declaration files for virtual
// modules.
// @see https://github.com/withastro/starlight/pull/3572
declare module 'virtual:starlight/project-context' {
const ProjectContext: {
root: string;
@@ -18,7 +22,6 @@ declare module 'virtual:starlight/project-context' {
build: {
format: import('astro').AstroConfig['build']['format'];
};
legacyCollections: boolean;
};
export default ProjectContext;
}
+22 -11
View File
@@ -1,30 +1,41 @@
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: ['__tests__/*/vitest.config.ts'],
coverage: {
all: true,
reportsDirectory: './__coverage__',
include: ['**.ts'],
exclude: [
...coverageConfigDefaults.exclude,
'**/__tests__/**',
'**/__e2e__/**',
'playwright.config.*',
'**/vitest.*',
'components.ts',
'types.ts',
// We use this to set up test environments so it isn‘t picked up, but we are testing it downstream.
'integrations/virtual-user-config.ts',
/**
* TODO: re-exclude these 2 files once the issue preventing us from excluding only these
* files at the project root in Vitest 4 is fixed and update thresholds accordingly.
*
* @see https://github.com/vitest-dev/vitest/issues/9395
*/
// 'components.ts',
// 'types.ts',
// Types-only export.
'props.ts',
// Types declaration files.
'*.d.ts',
/**
* TODO: re-exclude this file once the issue preventing us from excluding only this file at
* the project root in Vitest 4 is fixed and update thresholds accordingly.
*
* @see https://github.com/vitest-dev/vitest/issues/9395
*/
// Main integration entrypoint — don’t think we’re able to test this directly currently.
'index.ts',
// Since Vitest 2.1.2, coverage is collected for `*.astro` files.
'**/*.astro',
// 'index.ts',
],
thresholds: {
lines: 87,
functions: 90,
branches: 90,
branches: 85,
statements: 87,
},
},
-1
View File
@@ -1 +0,0 @@
export default ['__tests__/*/vitest.config.ts'];
+2 -2
View File
@@ -25,10 +25,10 @@
"devDependencies": {
"lightningcss": "^1.29.1",
"tailwindcss": "^4.0.7",
"vitest": "^3.0.5"
"vitest": "^4.1.0-beta.6"
},
"peerDependencies": {
"@astrojs/starlight": ">=0.34.0",
"@astrojs/starlight": ">=0.38.0",
"tailwindcss": "^4.0.0"
},
"publishConfig": {
+13
View File
@@ -0,0 +1,13 @@
diff --git a/index.ts b/index.ts
index d6deaed4c730a24aa5ef7838561c934638c5ef83..9450d36ff5c299b3ede1599e6a2559fb19e884a4 100644
--- a/index.ts
+++ b/index.ts
@@ -57,7 +57,7 @@ const starlightLinksValidatorOptionsSchema = z
*/
exclude: z.array(z.string()).default([]),
})
- .default({})
+ .prefault({})
export default function starlightLinksValidatorPlugin(
userOptions?: StarlightLinksValidatorUserOptions,
+1711 -1978
View File
File diff suppressed because it is too large Load Diff