diff --git a/.changeset/famous-grapes-develop.md b/.changeset/famous-grapes-develop.md new file mode 100644 index 00000000000..6f50d1be0a4 --- /dev/null +++ b/.changeset/famous-grapes-develop.md @@ -0,0 +1,9 @@ +--- +'@astrojs/starlight': minor +--- + +Ensures that Starlight CSS layer order is predictable in custom pages using the `` component. + +Previously, due to how [import order](https://docs.astro.build/en/guides/styling/#import-order) works in Astro, the `` component had to be the first import in custom pages to set up [cascade layers](https://starlight.astro.build/guides/css-and-tailwind/#cascade-layers) used internally by Starlight to manage the order of its styles. + +With this change, this restriction no longer applies and Starlight’s styles will be applied correctly regardless of the import order of the `` component. diff --git a/docs/src/content/docs/guides/pages.mdx b/docs/src/content/docs/guides/pages.mdx index dc7f828e7e7..5d9f8ff95ff 100644 --- a/docs/src/content/docs/guides/pages.mdx +++ b/docs/src/content/docs/guides/pages.mdx @@ -77,17 +77,13 @@ Read more in the [“Pages” guide in the Astro docs](https://docs.astro.build/ To use the Starlight layout in custom pages, wrap your page content with the [`` component](#starlightpage-component). This can be helpful if you are generating content dynamically but still want to use Starlight’s design. -This component must be the first import in your file to set up [cascade layers](/guides/css-and-tailwind/#cascade-layers) and ensure a predictable CSS order. To add anchor links to headings that match Starlight’s Markdown anchor link styles, you can use the [`` component](#anchorheading-component) in your custom pages. ```astro --- // src/pages/custom-page/example.astro -// Import the `` component first to set up cascade layers. import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; - -// Import any other components you want to use in your custom page. import AnchorHeading from '@astrojs/starlight/components/AnchorHeading.astro'; import CustomComponent from './CustomComponent.astro'; --- @@ -109,7 +105,6 @@ The `` component renders a full page of content using Starlight ```astro --- -// Import the `` component first to set up cascade layers. import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; --- @@ -118,8 +113,6 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; ``` -Due to how [import order](https://docs.astro.build/en/guides/styling/#import-order) works in Astro, the `` component must be the first import in your file to set up [cascade layers](/guides/css-and-tailwind/#cascade-layers) used internally by Starlight to manage the order of its styles. - The `` component accepts the following props. ##### `frontmatter` diff --git a/packages/starlight/__e2e__/basics.test.ts b/packages/starlight/__e2e__/basics.test.ts index e0349d89a76..00ecef38aa3 100644 --- a/packages/starlight/__e2e__/basics.test.ts +++ b/packages/starlight/__e2e__/basics.test.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises'; import { expect, testFactory, type Locator } from './test-utils'; const test = testFactory('./fixtures/basics/'); @@ -467,6 +468,31 @@ test.describe('components', () => { }); }); + test.describe('css layer order', () => { + test('ensures that the StarlightPage component is always imported first to ensure a predictable CSS layer order in custom pages', async ({ + page, + makeServer, + }) => { + const starlight = await makeServer('dev', { mode: 'dev' }); + await starlight.goto('/starlight-page-css-layer-order'); + + const firstStyleContent = await page.evaluate( + () => document.head.querySelector('style')?.textContent ?? '' + ); + + const expectedLayersOrder = await fs.readFile( + new URL('../style/layers.css', import.meta.url), + 'utf-8' + ); + + // Ensure that the first style block in the head contains the expected layers order rather + // the styles of the link button wrapped in a `@layer` block at-rule automatically declaring + // a new layer and thus potentially breaking the intended layers order as the initial order + // in which layers are declared indicates which layer has precedence. + expect(firstStyleContent).toBe(expectedLayersOrder); + }); + }); + async function expectSelectedTab(tabs: Locator, label: string, panel?: string) { expect( ( diff --git a/packages/starlight/__e2e__/fixtures/basics/src/pages/starlight-page-css-layer-order.astro b/packages/starlight/__e2e__/fixtures/basics/src/pages/starlight-page-css-layer-order.astro new file mode 100644 index 00000000000..b5071965a52 --- /dev/null +++ b/packages/starlight/__e2e__/fixtures/basics/src/pages/starlight-page-css-layer-order.astro @@ -0,0 +1,23 @@ +--- +import { LinkButton } from '@astrojs/starlight/components'; + +import AnchorHeading from '@astrojs/starlight/components/AnchorHeading.astro'; + +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; + +/** + * This page is used to test the CSS layer order in custom pages using the StarlightPage> component + * where we cannot ensure a correct import order of CSS. + * Note that the order of imports in this file is important and should not be changed. + */ +--- + + + A Sub heading + +

Custom page content and a link to another page.

+ +

+ Tabs link button +

+
diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts index 73ae958c737..8c04c39499c 100644 --- a/packages/starlight/__tests__/test-config.ts +++ b/packages/starlight/__tests__/test-config.ts @@ -5,6 +5,7 @@ import { getViteConfig } from 'astro/config'; import { vitePluginStarlightUserConfig } from '../integrations/virtual-user-config'; import { runPlugins, type StarlightUserConfigWithPlugins } from '../utils/plugins'; import { createTestPluginContext } from './test-plugin-utils'; +import { vitePluginStarlightCssLayerOrder } from '../integrations/vite-layer-order'; const testLegacyCollections = process.env.LEGACY_COLLECTIONS === 'true'; @@ -29,6 +30,7 @@ export async function defineVitestConfig( ); return getViteConfig({ plugins: [ + vitePluginStarlightCssLayerOrder(), vitePluginStarlightUserConfig( command, starlightConfig, diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index 87a5c7945c8..cd267bd96fc 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'; import { starlightAsides, starlightDirectivesRestorationIntegration } from './integrations/asides'; import { starlightExpressiveCode } from './integrations/expressive-code/index'; import { starlightSitemap } from './integrations/sitemap'; +import { vitePluginStarlightCssLayerOrder } from './integrations/vite-layer-order'; import { vitePluginStarlightUserConfig } from './integrations/virtual-user-config'; import { rehypeRtlCodeSupport } from './integrations/code-rtl-support'; import { @@ -118,6 +119,7 @@ export default function StarlightIntegration( updateConfig({ vite: { plugins: [ + vitePluginStarlightCssLayerOrder(), vitePluginStarlightUserConfig(command, starlightConfig, config, pluginTranslations), ], }, diff --git a/packages/starlight/integrations/vite-layer-order.ts b/packages/starlight/integrations/vite-layer-order.ts new file mode 100644 index 00000000000..41277e49c4f --- /dev/null +++ b/packages/starlight/integrations/vite-layer-order.ts @@ -0,0 +1,66 @@ +import type { ViteUserConfig } from 'astro'; +import MagicString from 'magic-string'; + +const starlightPageImportSource = '@astrojs/starlight/components/StarlightPage.astro'; + +/** + * Vite plugin that ensures the StarlightPage component is always imported first when imported in + * an Astro file. + * + * This is necessary to ensure a predictable CSS layer order which is defined by the `` + * imported by the `` component. If a user imports any other component using + * cascade layers before the `` component, it will result in undesired layers + * being created before we explicitly set the expected layer order. + */ +export function vitePluginStarlightCssLayerOrder(): VitePlugin { + return { + name: 'vite-plugin-starlight-css-layer-order', + enforce: 'pre', + transform(code, id) { + if ( + !id.endsWith('.astro') || + id.endsWith(starlightPageImportSource) || + code.indexOf('StarlightPage.astro') === -1 + ) { + return; + } + + let ast: ReturnType; + + try { + ast = this.parse(code); + } catch { + return; + } + + let hasStarlightPageImport = false; + + 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; + + hasStarlightPageImport = true; + break; + } + + 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`); + + return { + code: ms.toString(), + map: ms.generateMap({ hires: 'boundary' }), + }; + }, + }; +} + +type VitePlugin = NonNullable[number]; diff --git a/packages/starlight/package.json b/packages/starlight/package.json index 75b6fd443f5..13f5dab2cb1 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -210,6 +210,7 @@ "i18next": "^23.11.5", "js-yaml": "^4.1.0", "klona": "^2.0.6", + "magic-string": "^0.30.17", "mdast-util-directive": "^3.0.0", "mdast-util-to-markdown": "^2.1.0", "mdast-util-to-string": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed3d067c219..bd6b1f298c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: klona: specifier: ^2.0.6 version: 2.0.6 + magic-string: + specifier: ^0.30.17 + version: 0.30.17 mdast-util-directive: specifier: ^3.0.0 version: 3.0.0