diff options
author | HiDeoo | 2024-02-16 23:38:08 +0100 |
---|---|---|
committer | GitHub | 2024-02-16 23:38:08 +0100 |
commit | dd11b9538abdf4b5ba2ef70e07c0edda03e95add (patch) | |
tree | 02589e2096b963ee46d16290415e6415c5ae2a33 | |
parent | 2cb35782dace67c7c418a31005419fa95493b3d3 (diff) | |
download | IT.starlight-dd11b9538abdf4b5ba2ef70e07c0edda03e95add.tar.gz IT.starlight-dd11b9538abdf4b5ba2ef70e07c0edda03e95add.tar.bz2 IT.starlight-dd11b9538abdf4b5ba2ef70e07c0edda03e95add.zip |
"Virtual" pages prototype (#1175)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
20 files changed, 928 insertions, 77 deletions
diff --git a/docs/package.json b/docs/package.json index 7e46df58..8a751b95 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "@astrojs/starlight": "workspace:*", "@lunariajs/core": "^0.0.25", "@types/culori": "^2.0.0", - "astro": "^4.3.4", + "astro": "^4.3.5", "culori": "^3.2.0", "sharp": "^0.32.5" }, diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index d6580614..eb3a8f3e 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -83,43 +83,9 @@ Open this URL to start browsing your site. Starlight is ready for you to add new content, or bring your existing files! -#### File formats +Add new pages to your site by creating Markdown files in the `src/content/docs/` directory. -Starlight supports authoring content in Markdown and MDX with no configuration required. -You can add support for Markdoc by installing the experimental [Astro Markdoc integration](https://docs.astro.build/en/guides/integrations-guide/markdoc/). - -#### Add pages - -Add new pages to your site by creating `.md` or `.mdx` files in `src/content/docs/`. -Use sub-folders to organize your files and to create multiple path segments. - -For example, the following file structure will generate pages at `example.com/hello-world` and `example.com/guides/faq`: - -import FileTree from '~/components/file-tree.astro'; - -<FileTree> - -- src/ - - content/ - - docs/ - - guides/ - - faq.md - - hello-world.md - -</FileTree> - -#### Type-safe frontmatter - -All Starlight pages share a customizable [common set of frontmatter properties](/reference/frontmatter/) to control how the page appears: - -```md ---- -title: Hello, World! -description: This is a page in my Starlight-powered site ---- -``` - -If you forget anything important, Starlight will let you know. +Read more about file-based routing and support for MDX and Markdoc files in the [“Pages”](/guides/pages/) guide. ### Next steps diff --git a/docs/src/content/docs/guides/pages.mdx b/docs/src/content/docs/guides/pages.mdx new file mode 100644 index 00000000..0daeef16 --- /dev/null +++ b/docs/src/content/docs/guides/pages.mdx @@ -0,0 +1,151 @@ +--- +title: Pages +description: Learn how to create and manage your documentation site’s pages with Starlight. +sidebar: + order: 1 +--- + +Starlight generates your site’s HTML pages based on your content, with flexible options provided via Markdown frontmatter. +In addition, Starlight projects have full access to [Astro’s powerful page generation tools](https://docs.astro.build/en/basics/astro-pages/). +This guide shows how page generation works in Starlight. + +## Content pages + +### File formats + +Starlight supports authoring content in Markdown and MDX with no configuration required. +You can add support for Markdoc by installing the experimental [Astro Markdoc integration](https://docs.astro.build/en/guides/integrations-guide/markdoc/). + +### Add pages + +Add new pages to your site by creating `.md` or `.mdx` files in `src/content/docs/`. +Use sub-folders to organize your files and to create multiple path segments. + +For example, the following file structure will generate pages at `example.com/hello-world` and `example.com/reference/faq`: + +import FileTree from '~/components/file-tree.astro'; + +<FileTree> + +- src/ + - content/ + - docs/ + - hello-world.md + - reference/ + - faq.md + +</FileTree> + +### Type-safe frontmatter + +All Starlight pages share a customizable [common set of frontmatter properties](/reference/frontmatter/) to control how the page appears: + +```md +--- +title: Hello, World! +description: This is a page in my Starlight-powered site +--- +``` + +If you forget anything important, Starlight will let you know. + +## Custom pages + +For advanced use cases, you can add custom pages by creating a `src/pages/` directory. +The `src/pages/` directory uses [Astro's file-based routing](https://docs.astro.build/en/basics/astro-pages/#file-based-routing) and includes support for `.astro` files amongst other page formats. +This is helpful if you need to build pages with a completely custom layout or generate a page from an alternative data source. + +For example, this project mixes Markdown content in `src/content/docs/` with Astro and HTML routes in `src/pages/`: + +<FileTree> + +- src/ + - content/ + - docs/ + - hello-world.md + - pages/ + - custom.astro + - archived.html + +</FileTree> + +Read more in the [“Pages” guide in the Astro docs](https://docs.astro.build/en/basics/astro-pages/). + +### Using Starlight’s design in custom pages + +To use the Starlight layout in custom pages, wrap your page content with the `<StarlightPage />` component. +This can be helpful if you are generating content dynamically but still want to use Starlight’s design. + +```astro +--- +// src/pages/custom-page/example.astro +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import CustomComponent from './CustomComponent.astro'; +--- + +<StarlightPage frontmatter={{ title: 'My custom page' }}> + <p>This is a custom page with a custom component:</p> + <CustomComponent /> +</StarlightPage> +``` + +#### Props + +The `<StarlightPage />` component accepts the following props. + +##### `frontmatter` (required) + +**type:** `StarlightPageFrontmatter` + +Set the [frontmatter properties](/reference/frontmatter/) for this page, similar to frontmatter in Markdown pages. +The [`title`](/reference/frontmatter/#title-required) property is required and all other properties are optional. + +The following properties differ from Markdown frontmatter: + +- The [`slug`](/reference/frontmatter/#slug) property is not supported and is automatically set based on the custom page’s URL. +- The [`editUrl`](/reference/frontmatter/#editurl) option requires a URL to display an edit link. +- The [`sidebar`](/reference/frontmatter/#sidebar) property is not supported. In Markdown frontmatter, this option allows customization of [autogenerated link groups](/reference/configuration/#sidebar), which is not applicable to pages using the `<StarlightPage />` component. + +{/* ##### `sidebar` */} + +{/* **type:** `SidebarEntry[] | undefined` */} +{/* **default:** the sidebar generated based on the [global `sidebar` config](/reference/configuration/#sidebar) */} + +{/* Provide a custom site navigation sidebar for this page. */} +{/* If not set, the page will use the default global sidebar. */} + +##### `hasSidebar` + +**type:** `boolean` +**default:** `false` if [`frontmatter.template`](/reference/frontmatter/#template) is `'splash'`, otherwise `true` + +Control whether or not the sidebar should be displayed on this page. + +##### `headings` + +**type:** `{ depth: number; slug: string; text: string }[]` +**default:** `[]` + +Provide an array of all the headings on this page. +Starlight will generate the page table of contents from these headings if provided. + +##### `dir` + +**type:** `'ltr' | 'rtl'` +**default:** the writing direction for the current locale + +Set the writing direction for this page’s content. + +##### `lang` + +**type:** `string` +**default:** the language of the current locale + +Set the BCP-47 language tag for this page’s content, e.g. `en`, `zh-CN`, or `pt-BR`. + +##### `isFallback` + +**type:** `boolean` +**default:** `false` + +Indicate if this page is using [fallback content](/guides/i18n/#fallback-content) because there is no translation for the current language. diff --git a/examples/basics/package.json b/examples/basics/package.json index 13c0e86d..99b3dea6 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.18.1", - "astro": "^4.3.4", + "astro": "^4.3.5", "sharp": "^0.32.5" } } diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json index c088c537..9ddf8583 100644 --- a/examples/tailwind/package.json +++ b/examples/tailwind/package.json @@ -14,7 +14,7 @@ "@astrojs/starlight": "^0.18.1", "@astrojs/starlight-tailwind": "^2.0.1", "@astrojs/tailwind": "^5.1.0", - "astro": "^4.3.4", + "astro": "^4.3.5", "sharp": "^0.32.5", "tailwindcss": "^3.4.1" } diff --git a/package.json b/package.json index 697b1ebd..e0609e2a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.1", "@size-limit/file": "^8.2.4", - "astro": "^4.3.4", + "astro": "^4.3.5", "prettier": "^3.0.0", "prettier-plugin-astro": "^0.13.0", "size-limit": "^8.2.4" diff --git a/packages/starlight/__tests__/basics/slugs.test.ts b/packages/starlight/__tests__/basics/slugs.test.ts index 6c17414b..6b7c9bd1 100644 --- a/packages/starlight/__tests__/basics/slugs.test.ts +++ b/packages/starlight/__tests__/basics/slugs.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { localeToLang, localizedId, @@ -6,6 +6,7 @@ import { slugToLocaleData, slugToParam, slugToPathname, + urlToSlug, } from '../../utils/slugs'; describe('slugToLocaleData', () => { @@ -76,3 +77,35 @@ describe('localizedSlug', () => { expect(localizedSlug('test', undefined)).toBe('test'); }); }); + +describe('urlToSlug', () => { + test('returns slugs with `build.output: "directory"`', () => { + expect(urlToSlug(new URL('https://example.com'))).toBe(''); + expect(urlToSlug(new URL('https://example.com/slug'))).toBe('slug'); + expect(urlToSlug(new URL('https://example.com/dir/page/'))).toBe('dir/page'); + expect(urlToSlug(new URL('https://example.com/dir/sub-dir/page/'))).toBe('dir/sub-dir/page'); + }); + + test('returns slugs with `build.output: "file"`', () => { + expect(urlToSlug(new URL('https://example.com/index.html'))).toBe(''); + expect(urlToSlug(new URL('https://example.com/slug.html'))).toBe('slug'); + expect(urlToSlug(new URL('https://example.com/dir/page/index.html'))).toBe('dir/page'); + expect(urlToSlug(new URL('https://example.com/dir/sub-dir/page.html'))).toBe( + 'dir/sub-dir/page' + ); + }); + + // It is currently not possible to test this as stubbing BASE_URL is not supported due to + // `vite-plugin-env` controlling it and the lack of a way to pass in an Astro config using + // `getViteConfig()` from `astro/config`. + test.todo('returns slugs with a custom `base` option', () => { + vi.stubEnv('BASE_URL', '/base/'); + expect(urlToSlug(new URL('https://example.com/base'))).toBe(''); + expect(urlToSlug(new URL('https://example.com/base/slug'))).toBe('slug'); + expect(urlToSlug(new URL('https://example.com/base/dir/page/'))).toBe('dir/page'); + expect(urlToSlug(new URL('https://example.com/base/dir/sub-dir/page/'))).toBe( + 'dir/sub-dir/page' + ); + vi.unstubAllEnvs(); + }); +}); diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts new file mode 100644 index 00000000..39e60955 --- /dev/null +++ b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts @@ -0,0 +1,61 @@ +import { assert, expect, test, vi } from 'vitest'; +import { + generateStarlightPageRouteData, + type StarlightPageProps, +} from '../../utils/starlight-page'; + +vi.mock('virtual:starlight/collection-config', async () => { + const { z } = await vi.importActual<typeof import('astro:content')>('astro:content'); + return (await import('../test-utils')).mockedCollectionConfig({ + extend: z.object({ + // Make the built-in description field required. + description: z.string(), + // Add a new optional field. + category: z.string().optional(), + }), + }); +}); + +const starlightPageProps: StarlightPageProps = { + frontmatter: { title: 'This is a test title' }, +}; + +test('throws a validation error if a built-in field required by the user schema is not passed down', async () => { + expect.assertions(3); + + try { + await generateStarlightPageRouteData({ + props: starlightPageProps, + url: new URL('https://example.com/test-slug'), + }); + } catch (error) { + assert(error instanceof Error); + const lines = error.message.split('\n'); + // The first line should be a user-friendly error message describing the exact issue and the second line should be + // the missing description field. + expect(lines).toHaveLength(2); + const [message, missingField] = lines; + expect(message).toMatchInlineSnapshot( + `"Invalid frontmatter props passed to the \`<StarlightPage/>\` component."` + ); + expect(missingField).toMatchInlineSnapshot(`"**description**: Required"`); + } +}); + +test('returns new field defined in the user schema', async () => { + const category = 'test category'; + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + description: 'test description', + // @ts-expect-error - Custom field defined in the user schema. + category, + }, + }, + url: new URL('https://example.com/test-slug'), + }); + // @ts-expect-error - Custom field defined in the user schema. + expect(data.entry.data.category).toBe(category); +}); diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts new file mode 100644 index 00000000..0484f610 --- /dev/null +++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts @@ -0,0 +1,376 @@ +import { expect, test, vi } from 'vitest'; +import { + generateStarlightPageRouteData, + type StarlightPageProps, +} from '../../utils/starlight-page'; + +vi.mock('virtual:starlight/collection-config', async () => + (await import('../test-utils')).mockedCollectionConfig() +); + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['getting-started.mdx', { title: 'Getting Started' }], + ], + }) +); + +const starlightPageProps: StarlightPageProps = { + frontmatter: { title: 'This is a test title' }, +}; + +const starlightPageUrl = new URL('https://example.com/test-slug'); + +test('adds data to route shape', async () => { + const data = await generateStarlightPageRouteData({ + props: starlightPageProps, + url: starlightPageUrl, + }); + // 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(); + // Starlight pages cannot be fallbacks. + expect(data.isFallback).toBeUndefined(); + // Starlight pages are not editable if no edit URL is passed. + expect(data.editUrl).toBeUndefined(); + expect(data.entry.data.editUrl).toBe(false); + // Starlight pages are part of the docs collection. + expect(data.entry.collection).toBe('docs'); + // Starlight pages get dedicated frontmatter defaults. + expect(data.entry.data.head).toEqual([]); + expect(data.entry.data.pagefind).toBe(true); + expect(data.entry.data.template).toBe('doc'); + // Starlight pages respect the passed data. + expect(data.entry.data.title).toBe(starlightPageProps.frontmatter.title); + // Starlight pages get expected defaults. + expect(data.hasSidebar).toBe(true); + expect(data.headings).toEqual([]); + expect(data.entryMeta.dir).toBe('ltr'); + expect(data.entryMeta.lang).toBe('en'); +}); + +test('adds custom data to route shape', async () => { + const props: StarlightPageProps = { + ...starlightPageProps, + hasSidebar: false, + dir: 'rtl', + lang: 'ks', + }; + const data = await generateStarlightPageRouteData({ props, url: starlightPageUrl }); + expect(data.hasSidebar).toBe(props.hasSidebar); + expect(data.entryMeta.dir).toBe(props.dir); + expect(data.entryMeta.lang).toBe(props.lang); +}); + +test('adds custom frontmatter data to route shape', async () => { + const props: StarlightPageProps = { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + head: [{ tag: 'meta', attrs: { name: 'og:test', content: 'test' } }], + lastUpdated: new Date(), + pagefind: false, + template: 'splash', + }, + }; + const data = await generateStarlightPageRouteData({ props, url: starlightPageUrl }); + expect(data.entry.data.head).toMatchInlineSnapshot(` + [ + { + "attrs": { + "content": "test", + "name": "og:test", + }, + "content": "", + "tag": "meta", + }, + ] + `); + expect(data.entry.data.lastUpdated).toEqual(props.frontmatter.lastUpdated); + expect(data.entry.data.pagefind).toBe(props.frontmatter.pagefind); + expect(data.entry.data.template).toBe(props.frontmatter.template); +}); + +test('uses generated sidebar when no sidebar is provided', async () => { + const data = await generateStarlightPageRouteData({ + props: starlightPageProps, + url: starlightPageUrl, + }); + expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Home Page", + "Getting Started", + ] + `); +}); + +test('uses provided sidebar if any', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + sidebar: [ + { + type: 'link', + label: 'Custom link 1', + href: '/test/1', + isCurrent: false, + badge: undefined, + attrs: {}, + }, + { + type: 'link', + label: 'Custom link 2', + href: '/test/2', + isCurrent: false, + badge: undefined, + attrs: {}, + }, + ], + }, + url: starlightPageUrl, + }); + expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Custom link 1", + "Custom link 2", + ] + `); +}); + +test('uses provided pagination if any', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + prev: { + label: 'Previous link', + link: '/test/prev', + }, + next: { + label: 'Next link', + link: '/test/next', + }, + }, + }, + url: starlightPageUrl, + }); + expect(data.pagination).toMatchInlineSnapshot(` + { + "next": { + "attrs": {}, + "badge": undefined, + "href": "/test/next", + "isCurrent": false, + "label": "Next link", + "type": "link", + }, + "prev": { + "attrs": {}, + "badge": undefined, + "href": "/test/prev", + "isCurrent": false, + "label": "Previous link", + "type": "link", + }, + } + `); +}); + +test('uses provided headings if any', async () => { + const headings = [ + { depth: 2, slug: 'heading-1', text: 'Heading 1' }, + { depth: 3, slug: 'heading-2', text: 'Heading 2' }, + ]; + const data = await generateStarlightPageRouteData({ + props: { ...starlightPageProps, headings }, + url: starlightPageUrl, + }); + expect(data.headings).toEqual(headings); +}); + +test('generates the table of contents for provided headings', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + headings: [ + { depth: 2, slug: 'heading-1', text: 'Heading 1' }, + { depth: 3, slug: 'heading-2', text: 'Heading 2' }, + // Should be ignored as it's too deep with default config. + { depth: 4, slug: 'heading-3', text: 'Heading 3' }, + ], + }, + url: starlightPageUrl, + }); + expect(data.toc).toMatchInlineSnapshot(` + { + "items": [ + { + "children": [], + "depth": 2, + "slug": "_top", + "text": "Overview", + }, + { + "children": [ + { + "children": [], + "depth": 3, + "slug": "heading-2", + "text": "Heading 2", + }, + ], + "depth": 2, + "slug": "heading-1", + "text": "Heading 1", + }, + ], + "maxHeadingLevel": 3, + "minHeadingLevel": 2, + } + `); +}); + +test('respects the `tableOfContents` level configuration', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + headings: [ + // Should be ignored as it's not deep enough. + { depth: 2, slug: 'heading-1', text: 'Heading 1' }, + { depth: 3, slug: 'heading-2', text: 'Heading 2' }, + { depth: 4, slug: 'heading-3', text: 'Heading 3' }, + ], + frontmatter: { + ...starlightPageProps.frontmatter, + tableOfContents: { + minHeadingLevel: 3, + maxHeadingLevel: 4, + }, + }, + }, + url: starlightPageUrl, + }); + expect(data.toc).toMatchInlineSnapshot(` + { + "items": [ + { + "children": [ + { + "children": [ + { + "children": [], + "depth": 4, + "slug": "heading-3", + "text": "Heading 3", + }, + ], + "depth": 3, + "slug": "heading-2", + "text": "Heading 2", + }, + ], + "depth": 2, + "slug": "_top", + "text": "Overview", + }, + ], + "maxHeadingLevel": 4, + "minHeadingLevel": 3, + } + `); +}); + +test('disables table of contents if frontmatter includes `tableOfContents: false`', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + headings: [ + { depth: 2, slug: 'heading-1', text: 'Heading 1' }, + { depth: 3, slug: 'heading-2', text: 'Heading 2' }, + ], + frontmatter: { + ...starlightPageProps.frontmatter, + tableOfContents: false, + }, + }, + url: starlightPageUrl, + }); + expect(data.toc).toBeUndefined(); +}); + +test('disables table of contents for splash template', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + headings: [ + { depth: 2, slug: 'heading-1', text: 'Heading 1' }, + { depth: 3, slug: 'heading-2', text: 'Heading 2' }, + ], + frontmatter: { + ...starlightPageProps.frontmatter, + template: 'splash', + }, + }, + url: starlightPageUrl, + }); + expect(data.toc).toBeUndefined(); +}); + +test('hides the sidebar if the `hasSidebar` option is not specified and the splash template is used', async () => { + const { hasSidebar, ...otherProps } = starlightPageProps; + const data = await generateStarlightPageRouteData({ + props: { + ...otherProps, + frontmatter: { + ...otherProps.frontmatter, + template: 'splash', + }, + }, + url: starlightPageUrl, + }); + expect(data.hasSidebar).toBe(false); +}); + +test('includes localized labels', async () => { + const data = await generateStarlightPageRouteData({ + props: starlightPageProps, + url: starlightPageUrl, + }); + expect(data.labels).toBeDefined(); + expect(data.labels['skipLink.label']).toBe('Skip to content'); +}); + +test('uses provided edit URL if any', async () => { + const editUrl = 'https://example.com/edit'; + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + editUrl, + }, + }, + url: starlightPageUrl, + }); + expect(data.editUrl).toEqual(new URL(editUrl)); + expect(data.entry.data.editUrl).toEqual(editUrl); +}); + +test('strips unknown frontmatter properties', async () => { + const data = await generateStarlightPageRouteData({ + props: { + ...starlightPageProps, + frontmatter: { + ...starlightPageProps.frontmatter, + // @ts-expect-error - This is an unknown property. + unknown: 'test', + }, + }, + url: starlightPageUrl, + }); + expect('unknown' in data.entry.data).toBe(false); +}); diff --git a/packages/starlight/__tests__/test-utils.ts b/packages/starlight/__tests__/test-utils.ts index 96ff4f64..7c145378 100644 --- a/packages/starlight/__tests__/test-utils.ts +++ b/packages/starlight/__tests__/test-utils.ts @@ -56,3 +56,14 @@ export async function mockedAstroContent({ getCollection: (collection: 'docs' | 'i18n') => (collection === 'i18n' ? mockDicts : mockDocs), }; } + +export async function mockedCollectionConfig(docsUserSchema?: Parameters<typeof docsSchema>[0]) { + const content = await vi.importActual<typeof import('astro:content')>('astro:content'); + const schemas = await vi.importActual<typeof import('../schema')>('../schema'); + return { + collections: { + docs: content.defineCollection({ schema: schemas.docsSchema(docsUserSchema) }), + i18n: content.defineCollection({ type: 'data', schema: schemas.i18nSchema() }), + }, + }; +} diff --git a/packages/starlight/components/StarlightPage.astro b/packages/starlight/components/StarlightPage.astro new file mode 100644 index 00000000..52e5c58e --- /dev/null +++ b/packages/starlight/components/StarlightPage.astro @@ -0,0 +1,13 @@ +--- +import { + generateStarlightPageRouteData, + type StarlightPageProps as Props, +} from '../utils/starlight-page'; +import Page from './Page.astro'; + +export type StarlightPageProps = Props; +--- + +<Page {...await generateStarlightPageRouteData({ props: Astro.props, url: Astro.url })}> + <slot /> +</Page> diff --git a/packages/starlight/integrations/virtual-user-config.ts b/packages/starlight/integrations/virtual-user-config.ts index 29a2e512..d752a966 100644 --- a/packages/starlight/integrations/virtual-user-config.ts +++ b/packages/starlight/integrations/virtual-user-config.ts @@ -48,6 +48,11 @@ export function vitePluginStarlightUserConfig( opts.logo.light )}; export const logos = { dark, light };` : 'export const logos = {};', + 'virtual:starlight/collection-config': `let userCollections; + try { + userCollections = (await import('/src/content/config.ts')).collections; + } catch {} + export const collections = userCollections;`, ...virtualComponentModules, } satisfies Record<string, string>; diff --git a/packages/starlight/package.json b/packages/starlight/package.json index ef7fc4c5..e5fa2d2d 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -102,6 +102,10 @@ "types": "./components/Page.astro.tsx", "import": "./components/Page.astro" }, + "./components/StarlightPage.astro": { + "types": "./components/StarlightPage.astro.tsx", + "import": "./components/StarlightPage.astro" + }, "./components/Footer.astro": { "types": "./components/Footer.astro.tsx", "import": "./components/Footer.astro" @@ -173,7 +177,7 @@ "@astrojs/markdown-remark": "^4.2.1", "@types/node": "^18.16.19", "@vitest/coverage-v8": "^1.2.2", - "astro": "^4.3.4", + "astro": "^4.3.5", "vitest": "^1.2.2" }, "dependencies": { diff --git a/packages/starlight/utils/error-map.ts b/packages/starlight/utils/error-map.ts index d1006d35..fd5c3660 100644 --- a/packages/starlight/utils/error-map.ts +++ b/packages/starlight/utils/error-map.ts @@ -11,6 +11,10 @@ type TypeOrLiteralErrByPathEntry = { expected: unknown[]; }; +export function throwValidationError(error: z.ZodError, message: string): never { + throw new Error(`${message}\n${error.issues.map((i) => i.message).join('\n')}`); +} + export const errorMap: z.ZodErrorMap = (baseError, ctx) => { const baseErrorPath = flattenErrorPath(baseError.path); if (baseError.code === 'invalid_union') { diff --git a/packages/starlight/utils/plugins.ts b/packages/starlight/utils/plugins.ts index 40e449d5..bca72087 100644 --- a/packages/starlight/utils/plugins.ts +++ b/packages/starlight/utils/plugins.ts @@ -1,7 +1,7 @@ import type { AstroIntegration } from 'astro'; import { z } from 'astro/zod'; import { StarlightConfigSchema, type StarlightUserConfig } from '../utils/user-config'; -import { errorMap } from '../utils/error-map'; +import { errorMap, throwValidationError } from '../utils/error-map'; /** * Runs Starlight plugins in the order that they are configured after validating the user-provided @@ -82,10 +82,6 @@ export async function runPlugins( return { integrations, starlightConfig: starlightConfig.data }; } -function throwValidationError(error: z.ZodError, message: string): never { - throw new Error(`${message}\n${error.issues.map((i) => i.message).join('\n')}`); -} - // https://github.com/withastro/astro/blob/910eb00fe0b70ca80bd09520ae100e8c78b675b5/packages/astro/src/core/config/schema.ts#L113 const astroIntegrationSchema = z.object({ name: z.string(), diff --git a/packages/starlight/utils/route-data.ts b/packages/starlight/utils/route-data.ts index b7a23e6c..1ad5c9ca 100644 --- a/packages/starlight/utils/route-data.ts +++ b/packages/starlight/utils/route-data.ts @@ -10,7 +10,7 @@ import type { Route } from './routing'; import { localizedId } from './slugs'; import { useTranslations } from './translations'; -interface PageProps extends Route { +export interface PageProps extends Route { headings: MarkdownHeading[]; } @@ -54,7 +54,7 @@ export function generateRouteData({ }; } -function getToC({ entry, locale, headings }: PageProps) { +export function getToC({ entry, locale, headings }: PageProps) { const tocConfig = entry.data.template === 'splash' ? false diff --git a/packages/starlight/utils/slugs.ts b/packages/starlight/utils/slugs.ts index 2b45fd8d..b7e076c9 100644 --- a/packages/starlight/utils/slugs.ts +++ b/packages/starlight/utils/slugs.ts @@ -101,3 +101,21 @@ export function localizedId(id: string, locale: string | undefined): string { return id; } } + +/** Extract the slug from a URL. */ +export function urlToSlug(url: URL): string { + let pathname = url.pathname; + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + if (pathname.startsWith(base)) pathname = pathname.replace(base, ''); + const segments = pathname.split('/'); + const htmlExt = '.html'; + if (segments.at(-1) === 'index.html') { + // Remove trailing `index.html`. + segments.pop(); + } else if (segments.at(-1)?.endsWith(htmlExt)) { + // Remove trailing `.html`. + const last = segments.pop(); + if (last) segments.push(last.slice(0, -1 * htmlExt.length)); + } + return segments.filter(Boolean).join('/'); +} diff --git a/packages/starlight/utils/starlight-page.ts b/packages/starlight/utils/starlight-page.ts new file mode 100644 index 00000000..c08ce4fa --- /dev/null +++ b/packages/starlight/utils/starlight-page.ts @@ -0,0 +1,209 @@ +import { z } from 'astro/zod'; +import { type ContentConfig, type SchemaContext } from 'astro:content'; +import config from 'virtual:starlight/user-config'; +import { errorMap, throwValidationError } from './error-map'; +import { stripLeadingAndTrailingSlashes } from './path'; +import { getToC, type PageProps, type StarlightRouteData } from './route-data'; +import type { StarlightDocsEntry } from './routing'; +import { slugToLocaleData, urlToSlug } from './slugs'; +import { getPrevNextLinks, getSidebar } from './navigation'; +import { useTranslations } from './translations'; +import { docsSchema } from '../schema'; + +/** + * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s + * `docs` content collection. + * The frontmatter schema for Starlight pages cannot include some properties which will be omitted + * and some others needs to be refined to a stricter type. + */ +const StarlightPageFrontmatterSchema = async (context: SchemaContext) => { + const userDocsSchema = await getUserDocsSchema(); + const schema = typeof userDocsSchema === 'function' ? userDocsSchema(context) : userDocsSchema; + + return schema.transform((frontmatter) => { + /** + * Starlight pages can only be edited if an edit URL is explicitly provided. + * The `sidebar` frontmatter prop only works for pages in an autogenerated links group. + * Starlight pages edit links cannot be autogenerated. + * + * These changes to the schema are done using a transformer and not using the usual `omit` + * method because when the frontmatter schema is extended by the user, an intersection between + * the default schema and the user schema is created using the `and` method. Intersections in + * Zod returns a `ZodIntersection` object which does not have some methods like `omit` or + * `pick`. + * + * This transformer only sets the `editUrl` default value and removes the `sidebar` property + * from the validated output but does not appply any changes to the input schema type itself so + * this needs to be done manually. + * + * @see StarlightPageFrontmatter + * @see https://github.com/colinhacks/zod#intersections + */ + const { editUrl, sidebar, ...others } = frontmatter; + const pageEditUrl = editUrl === undefined || editUrl === true ? false : editUrl; + return { ...others, editUrl: pageEditUrl }; + }); +}; + +/** + * Type of Starlight pages frontmatter schema. + * We manually refines the `editUrl` type and omit the `sidebar` property as it's not possible to + * do that on the schema itself using Zod but the proper validation is still using a transformer. + * @see StarlightPageFrontmatterSchema + */ +type StarlightPageFrontmatter = Omit< + z.input<Awaited<ReturnType<typeof StarlightPageFrontmatterSchema>>>, + 'editUrl' | 'sidebar' +> & { editUrl?: string | false }; + +/** + * The props accepted by the `<StarlightPage/>` component. + */ +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'>> & + // Add the sidebar definitions for a Starlight page. + Partial<Pick<StarlightRouteData, 'hasSidebar' | 'sidebar'>> & { + // And finally add the Starlight page frontmatter properties in a `frontmatter` property. + frontmatter: StarlightPageFrontmatter; + } +>; + +/** + * A docs entry used for Starlight pages meant to be rendered by plugins and which is safe to cast + * 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 for this Starlight page which cannot be inferred from codegen like content + * collection entries. + */ + id: string; +}; + +export async function generateStarlightPageRouteData({ + props, + url, +}: { + props: StarlightPageProps; + url: URL; +}): Promise<StarlightRouteData> { + const { isFallback, frontmatter, ...routeProps } = props; + const slug = urlToSlug(url); + const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter); + const id = `${stripLeadingAndTrailingSlashes(slug)}.md`; + const localeData = slugToLocaleData(slug); + const sidebar = props.sidebar ?? getSidebar(url.pathname, localeData.locale); + const headings = props.headings ?? []; + const pageDocsEntry: StarlightPageDocsEntry = { + id, + slug, + body: '', + collection: 'docs', + data: { + ...pageFrontmatter, + sidebar: { + attrs: {}, + hidden: false, + }, + }, + }; + const entry = pageDocsEntry as StarlightDocsEntry; + const entryMeta: StarlightRouteData['entryMeta'] = { + dir: props.dir ?? localeData.dir, + lang: props.lang ?? localeData.lang, + locale: localeData.locale, + }; + const editUrl = pageFrontmatter.editUrl ? new URL(pageFrontmatter.editUrl) : undefined; + const lastUpdated = + pageFrontmatter.lastUpdated instanceof Date ? pageFrontmatter.lastUpdated : undefined; + const routeData: StarlightRouteData = { + ...routeProps, + ...localeData, + id, + editUrl, + entry, + entryMeta, + hasSidebar: props.hasSidebar ?? entry.data.template !== 'splash', + headings, + labels: useTranslations(localeData.locale).all(), + lastUpdated, + pagination: getPrevNextLinks(sidebar, config.pagination, entry.data), + sidebar, + slug, + toc: getToC({ + ...routeProps, + ...localeData, + entry, + entryMeta, + headings, + id, + locale: localeData.locale, + slug, + }), + }; + if (isFallback) { + routeData.isFallback = true; + } + return routeData; +} + +/** Validates the Starlight page frontmatter properties from the props received by a Starlight page. */ +async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter) { + // This needs to be in sync with ImageMetadata. + // https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32 + const schema = await StarlightPageFrontmatterSchema({ + image: () => + z.object({ + src: z.string(), + width: z.number(), + height: z.number(), + format: z.union([ + z.literal('png'), + z.literal('jpg'), + z.literal('jpeg'), + z.literal('tiff'), + z.literal('webp'), + z.literal('gif'), + z.literal('svg'), + z.literal('avif'), + ]), + }), + }); + + const pageFrontmatter = schema.safeParse(frontmatter, { errorMap }); + + if (!pageFrontmatter.success) { + throwValidationError( + pageFrontmatter.error, + 'Invalid frontmatter props passed to the `<StarlightPage/>` component.' + ); + } + + return pageFrontmatter.data; +} + +/** Returns the user docs schema and falls back to the default schema if needed. */ +async function getUserDocsSchema(): Promise< + NonNullable<ContentConfig['collections']['docs']['schema']> +> { + const userCollections = (await import('virtual:starlight/collection-config')).collections; + return userCollections?.docs.schema ?? docsSchema(); +} + +// https://stackoverflow.com/a/66252656/1945960 +type RemoveIndexSignature<T> = { + [K in keyof T as string extends K + ? never + : number extends K + ? never + : symbol extends K + ? never + : K]: T[K]; +}; + +// https://www.totaltypescript.com/concepts/the-prettify-helper +type Prettify<T> = { + [K in keyof T]: T[K]; +} & {}; diff --git a/packages/starlight/virtual.d.ts b/packages/starlight/virtual.d.ts index 9a04a572..4dfce6e3 100644 --- a/packages/starlight/virtual.d.ts +++ b/packages/starlight/virtual.d.ts @@ -24,6 +24,10 @@ declare module 'virtual:starlight/user-images' { }; } +declare module 'virtual:starlight/collection-config' { + export const collections: import('astro:content').ContentConfig['collections'] | undefined; +} + declare module 'virtual:starlight/components/Banner' { const Banner: typeof import('./components/Banner.astro').default; export default Banner; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbf70c87..f9b5845f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^8.2.4 version: 8.2.4(size-limit@8.2.4) astro: - specifier: ^4.3.4 - version: 4.3.4(@types/node@18.16.19) + specifier: ^4.3.5 + version: 4.3.5(@types/node@18.16.19) prettier: specifier: ^3.0.0 version: 3.0.0 @@ -34,7 +34,7 @@ importers: dependencies: '@astro-community/astro-embed-youtube': specifier: ^0.4.4 - version: 0.4.4(astro@4.3.4) + version: 0.4.4(astro@4.3.5) '@astrojs/starlight': specifier: workspace:* version: link:../packages/starlight @@ -45,8 +45,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 astro: - specifier: ^4.3.4 - version: 4.3.4(@types/node@18.16.19) + specifier: ^4.3.5 + version: 4.3.5(@types/node@18.16.19) culori: specifier: ^3.2.0 version: 3.2.0 @@ -74,7 +74,7 @@ importers: version: 13.0.1 starlight-links-validator: specifier: ^0.5.3 - version: 0.5.3(@astrojs/starlight@packages+starlight)(astro@4.3.4) + version: 0.5.3(@astrojs/starlight@packages+starlight)(astro@4.3.5) start-server-and-test: specifier: ^2.0.0 version: 2.0.0 @@ -88,8 +88,8 @@ importers: specifier: ^0.18.1 version: link:../../packages/starlight astro: - specifier: ^4.3.4 - version: 4.3.4(@types/node@18.16.19) + specifier: ^4.3.5 + version: 4.3.5(@types/node@18.16.19) sharp: specifier: ^0.32.5 version: 0.32.6 @@ -104,10 +104,10 @@ importers: version: link:../../packages/tailwind '@astrojs/tailwind': specifier: ^5.1.0 - version: 5.1.0(astro@4.3.4)(tailwindcss@3.4.1) + version: 5.1.0(astro@4.3.5)(tailwindcss@3.4.1) astro: - specifier: ^4.3.4 - version: 4.3.4(@types/node@18.16.19) + specifier: ^4.3.5 + version: 4.3.5(@types/node@18.16.19) sharp: specifier: ^0.32.5 version: 0.32.6 @@ -131,7 +131,7 @@ importers: dependencies: '@astrojs/mdx': specifier: ^2.1.1 - version: 2.1.1(astro@4.3.4) + version: 2.1.1(astro@4.3.5) '@astrojs/sitemap': specifier: ^3.0.5 version: 3.0.5 @@ -146,7 +146,7 @@ importers: version: 4.0.3 astro-expressive-code: specifier: ^0.32.4 - version: 0.32.4(astro@4.3.4) + version: 0.32.4(astro@4.3.5) bcp-47: specifier: ^2.1.0 version: 2.1.0 @@ -194,8 +194,8 @@ importers: specifier: ^1.2.2 version: 1.2.2(vitest@1.2.2) astro: - specifier: ^4.3.4 - version: 4.3.4(@types/node@18.16.19) + specifier: ^4.3.5 + version: 4.3.5(@types/node@18.16.19) vitest: specifier: ^1.2.2 version: 1.2.2(@types/node@18.16.19) @@ -207,7 +207,7 @@ importers: version: link:../starlight '@astrojs/tailwind': specifier: ^5.0.0 - version: 5.1.0(astro@4.3.4)(tailwindcss@3.4.1) + version: 5.1.0(astro@4.3.5)(tailwindcss@3.4.1) tailwindcss: specifier: ^3.3.3 version: 3.4.1 @@ -370,12 +370,12 @@ packages: '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.19 - /@astro-community/astro-embed-youtube@0.4.4(astro@4.3.4): + /@astro-community/astro-embed-youtube@0.4.4(astro@4.3.5): resolution: {integrity: sha512-fYlycLrJFNnibZ9VHPSJO766kO2IgqYQU4mBd4iaDMaicL0gGX9cVZ80QdnpzGrI6w0XOJOY7prx86eWEVBy8w==} peerDependencies: astro: ^2.0.0 || ^3.0.0-beta || ^4.0.0-beta dependencies: - astro: 4.3.4(@types/node@18.16.19) + astro: 4.3.5(@types/node@18.16.19) lite-youtube-embed: 0.2.0 dev: false @@ -409,7 +409,7 @@ packages: transitivePeerDependencies: - supports-color - /@astrojs/mdx@2.1.1(astro@4.3.4): + /@astrojs/mdx@2.1.1(astro@4.3.5): resolution: {integrity: sha512-AgGFdE7HOGmoFooGvMSatkA9FiSKwyVW7ImHot/bXJ6uAbFfu6iG2ht18Cf1pT22Hda/6iSCGWusFvBv0/EnKQ==} engines: {node: '>=18.14.1'} peerDependencies: @@ -418,7 +418,7 @@ packages: '@astrojs/markdown-remark': 4.2.1 '@mdx-js/mdx': 3.0.0 acorn: 8.11.3 - astro: 4.3.4(@types/node@18.16.19) + astro: 4.3.5(@types/node@18.16.19) es-module-lexer: 1.4.1 estree-util-visit: 2.0.0 github-slugger: 2.0.0 @@ -448,13 +448,13 @@ packages: zod: 3.22.4 dev: false - /@astrojs/tailwind@5.1.0(astro@4.3.4)(tailwindcss@3.4.1): + /@astrojs/tailwind@5.1.0(astro@4.3.5)(tailwindcss@3.4.1): resolution: {integrity: sha512-BJoCDKuWhU9FT2qYg+fr6Nfb3qP4ShtyjXGHKA/4mHN94z7BGcmauQK23iy+YH5qWvTnhqkd6mQPQ1yTZTe9Ig==} peerDependencies: astro: ^3.0.0 || ^4.0.0 tailwindcss: ^3.0.24 dependencies: - astro: 4.3.4(@types/node@18.16.19) + astro: 4.3.5(@types/node@18.16.19) autoprefixer: 10.4.15(postcss@8.4.33) postcss: 8.4.33 postcss-load-config: 4.0.2(postcss@8.4.33) @@ -1873,18 +1873,18 @@ packages: hasBin: true dev: false - /astro-expressive-code@0.32.4(astro@4.3.4): + /astro-expressive-code@0.32.4(astro@4.3.5): resolution: {integrity: sha512-/Kq8wLMz0X2gbLWGmPryqEdFV/om/GROsoLtPFqLrLCRD5CpwxXAW185BIGZKf4iYsyJim1vvcpQm5Y9hV5B1g==} peerDependencies: astro: ^3.3.0 || ^4.0.0-beta dependencies: - astro: 4.3.4(@types/node@18.16.19) + astro: 4.3.5(@types/node@18.16.19) hast-util-to-html: 8.0.4 remark-expressive-code: 0.32.4 dev: false - /astro@4.3.4(@types/node@18.16.19): - resolution: {integrity: sha512-BWzGGn/PuwmT0DWX+yXa/jUq99e85AGh9/C5IFAKIfp22Nk88dOfX89RoqKBWPPp2BrK2vsdCFd0WUv8XJh80w==} + /astro@4.3.5(@types/node@18.16.19): + resolution: {integrity: sha512-7jPffNlcmDO94NlkWe/hUWta/pIjlx1LVD/DZb/fyjT1Jv+7mGhKZBIjkDfeVpequW70mep8cAS5RM7Pxa0Gdg==} engines: {node: '>=18.14.1', npm: '>=6.14.0'} hasBin: true dependencies: @@ -6125,7 +6125,7 @@ packages: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true - /starlight-links-validator@0.5.3(@astrojs/starlight@packages+starlight)(astro@4.3.4): + /starlight-links-validator@0.5.3(@astrojs/starlight@packages+starlight)(astro@4.3.5): resolution: {integrity: sha512-v79rwmzjQlEMVL8sZ4dalD/jhFOUvGZ2/f4CvxCySZ9KbEN9nDmgV8zJgfpmTzhbcYQ35wzyUinF4QNxgKVA4g==} engines: {node: '>=18.14.1'} peerDependencies: @@ -6133,7 +6133,7 @@ packages: astro: '>=4.0.0' dependencies: '@astrojs/starlight': link:packages/starlight - astro: 4.3.4(@types/node@18.16.19) + astro: 4.3.5(@types/node@18.16.19) github-slugger: 2.0.0 hast-util-from-html: 2.0.1 hast-util-has-property: 3.0.0 |