From 140e729a8bf12f805ae0b7e2b5ad959cf68d8e22 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Fri, 6 Oct 2023 18:53:53 +0200 Subject: Support component customisation (#709) Co-authored-by: Sarah Rainsberger Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com> --- .changeset/plenty-donkeys-lay.md | 21 ++ .../content/docs/guides/overriding-components.md | 133 ++++++++ docs/src/content/docs/guides/sidebar.mdx | 2 - docs/src/content/docs/index.mdx | 2 + docs/src/content/docs/reference/configuration.md | 16 + docs/src/content/docs/reference/overrides.md | 374 +++++++++++++++++++++ packages/starlight/404.astro | 13 +- .../starlight/__tests__/basics/route-data.test.ts | 87 +++++ packages/starlight/__tests__/basics/toc.test.ts | 2 +- .../starlight/__tests__/edit-url/edit-url.test.ts | 48 +++ .../starlight/__tests__/edit-url/vitest.config.ts | 8 + packages/starlight/__tests__/test-config.ts | 8 +- packages/starlight/components/Banner.astro | 8 +- packages/starlight/components/ContentPanel.astro | 4 + packages/starlight/components/EditLink.astro | 29 +- .../components/FallbackContentNotice.astro | 7 +- packages/starlight/components/Footer.astro | 37 +- packages/starlight/components/Head.astro | 102 ++++++ packages/starlight/components/HeadSEO.astro | 105 ------ packages/starlight/components/Header.astro | 42 ++- packages/starlight/components/Hero.astro | 15 +- packages/starlight/components/LanguageSelect.astro | 5 +- packages/starlight/components/LastUpdated.astro | 29 +- .../starlight/components/MarkdownContent.astro | 4 + .../starlight/components/MobileMenuFooter.astro | 17 + .../starlight/components/MobileMenuToggle.astro | 6 +- .../components/MobileTableOfContents.astro | 151 +++++++++ packages/starlight/components/Page.astro | 120 +++++++ packages/starlight/components/PageFrame.astro | 98 ++++++ packages/starlight/components/PageSidebar.astro | 58 ++++ packages/starlight/components/PageTitle.astro | 16 + packages/starlight/components/Pagination.astro | 74 ++++ packages/starlight/components/PrevNextLinks.astro | 80 ----- packages/starlight/components/RightSidebar.astro | 36 -- .../starlight/components/RightSidebarPanel.astro | 46 --- packages/starlight/components/Search.astro | 8 +- packages/starlight/components/Sidebar.astro | 43 +-- packages/starlight/components/SiteTitle.astro | 21 +- packages/starlight/components/SkipLink.astro | 8 +- packages/starlight/components/SocialIcons.astro | 41 +-- .../starlight/components/TableOfContents.astro | 27 +- .../TableOfContents/MobileTableOfContents.astro | 154 --------- .../TableOfContents/TableOfContentsList.astro | 2 +- .../components/TableOfContents/generateToC.ts | 32 -- .../components/TableOfContents/starlight-toc.ts | 4 +- packages/starlight/components/ThemeProvider.astro | 1 + packages/starlight/components/ThemeSelect.astro | 5 +- .../starlight/components/TwoColumnContent.astro | 56 +++ packages/starlight/constants.ts | 4 + packages/starlight/index.astro | 6 +- .../starlight/integrations/virtual-user-config.ts | 3 + packages/starlight/layout/Page.astro | 132 -------- packages/starlight/layout/PageFrame.astro | 90 ----- packages/starlight/layout/TwoColumnContent.astro | 58 ---- packages/starlight/package.json | 129 +++++++ packages/starlight/props.ts | 1 + packages/starlight/schemas/components.ts | 256 ++++++++++++++ packages/starlight/schemas/social.ts | 65 ++++ packages/starlight/utils/generateToC.ts | 33 ++ packages/starlight/utils/navigation.ts | 2 + packages/starlight/utils/route-data.ts | 97 ++++++ packages/starlight/utils/routing.ts | 10 + packages/starlight/utils/user-config.ts | 40 +-- packages/starlight/utils/validateLogoImports.ts | 21 ++ packages/starlight/virtual.d.ts | 37 ++ packages/starlight/vitest.config.ts | 13 +- 66 files changed, 2179 insertions(+), 1023 deletions(-) create mode 100644 .changeset/plenty-donkeys-lay.md create mode 100644 docs/src/content/docs/guides/overriding-components.md create mode 100644 docs/src/content/docs/reference/overrides.md create mode 100644 packages/starlight/__tests__/basics/route-data.test.ts create mode 100644 packages/starlight/__tests__/edit-url/edit-url.test.ts create mode 100644 packages/starlight/__tests__/edit-url/vitest.config.ts create mode 100644 packages/starlight/components/Head.astro delete mode 100644 packages/starlight/components/HeadSEO.astro create mode 100644 packages/starlight/components/MobileMenuFooter.astro create mode 100644 packages/starlight/components/MobileTableOfContents.astro create mode 100644 packages/starlight/components/Page.astro create mode 100644 packages/starlight/components/PageFrame.astro create mode 100644 packages/starlight/components/PageSidebar.astro create mode 100644 packages/starlight/components/PageTitle.astro create mode 100644 packages/starlight/components/Pagination.astro delete mode 100644 packages/starlight/components/PrevNextLinks.astro delete mode 100644 packages/starlight/components/RightSidebar.astro delete mode 100644 packages/starlight/components/RightSidebarPanel.astro delete mode 100644 packages/starlight/components/TableOfContents/MobileTableOfContents.astro delete mode 100644 packages/starlight/components/TableOfContents/generateToC.ts create mode 100644 packages/starlight/components/TwoColumnContent.astro create mode 100644 packages/starlight/constants.ts delete mode 100644 packages/starlight/layout/Page.astro delete mode 100644 packages/starlight/layout/PageFrame.astro delete mode 100644 packages/starlight/layout/TwoColumnContent.astro create mode 100644 packages/starlight/props.ts create mode 100644 packages/starlight/schemas/components.ts create mode 100644 packages/starlight/schemas/social.ts create mode 100644 packages/starlight/utils/generateToC.ts create mode 100644 packages/starlight/utils/route-data.ts create mode 100644 packages/starlight/utils/validateLogoImports.ts diff --git a/.changeset/plenty-donkeys-lay.md b/.changeset/plenty-donkeys-lay.md new file mode 100644 index 00000000..cb041fbd --- /dev/null +++ b/.changeset/plenty-donkeys-lay.md @@ -0,0 +1,21 @@ +--- +'@astrojs/starlight': minor +--- + +Add support for overriding Starlight’s built-in components + +⚠️ **BREAKING CHANGE** — The page footer is now included on pages with `template: splash` in their frontmatter. Previously, this was not the case. If you are using `template: splash` and want to continue to hide footer elements, disable them in your frontmatter: + +```md +--- +title: Landing page +template: splash +# Disable unwanted footer elements as needed +editUrl: false +lastUpdated: false +prev: false +next: false +--- +``` + +⚠️ **BREAKING CHANGE** — This change involved refactoring the structure of some of Starlight’s built-in components slightly. If you were previously overriding these using other techniques, you may need to adjust your code. \ No newline at end of file diff --git a/docs/src/content/docs/guides/overriding-components.md b/docs/src/content/docs/guides/overriding-components.md new file mode 100644 index 00000000..71ea7e1d --- /dev/null +++ b/docs/src/content/docs/guides/overriding-components.md @@ -0,0 +1,133 @@ +--- +title: Overriding Components +description: Learn how to override Starlight’s built-in components to add custom elements to your documentation site’s UI. +sidebar: + badge: New +--- + +Starlight’s default UI and configuration options are designed to be flexible and work for a range of content. Much of Starlight's default appearance can be customized with [CSS](/guides/css-and-tailwind/) and [configuration options](/guides/customization/). + +When you need more than what’s possible out of the box, Starlight supports building your own custom components to extend or override (completely replace) its default components. + +## When to override + +Overriding Starlight’s default components can be useful when: + +- You want to change how a part of Starlight’s UI looks in a way not possible with [custom CSS](/css-and-tailwind/). +- You want to change how a part of Starlight’s UI behaves. +- You want to add some additional UI alongside Starlight’s existing UI. + +## How to override + +1. Choose the Starlight component you want to override. + You can find a full list of components in the [Overrides Reference](/reference/overrides/). + + This example will override Starlight’s [`SocialIcons`](/reference/overrides/#socialicons) component in the page nav bar. + +2. Create an Astro component to replace the Starlight component with. + This example renders a contact link. + + ```astro + --- + // src/components/EmailLink.astro + import type { Props } from '@astrojs/starlight/props'; + --- + + E-mail Me + ``` + +3. Tell Starlight to use your custom component in the [`components`](/reference/configuration/#components) configuration option in `astro.config.mjs`: + + ```js {9-12} + // astro.config.mjs + import { defineConfig } from 'astro/config'; + import starlight from '@astrojs/starlight'; + + export default defineConfig({ + integrations: [ + starlight({ + title: 'My Docs with Overrides', + components: { + // Override the default `SocialLinks` component. + SocialIcons: './src/components/EmailLink.astro', + }, + }), + ], + }); + ``` + +## Reuse a built-in component + +You can build with Starlight’s default UI components just as you would with your own: importing and rendering them in your own custom components. This allows you to keep all of Starlight's basic UI within your design, while adding extra UI alongside them. + +The example below shows a custom component that renders an e-mail link along with the default `SocialLinks` component: + +```astro {4,8} +--- +// src/components/EmailLink.astro +import type { Props } from '@astrojs/starlight/props'; +import Default from '@astrojs/starlight/SocialIcons.astro'; +--- + +E-mail Me + +``` + +When rendering a built-in component inside a custom component: + +- Spread `Astro.props` into it. This makes sure that it receives all the data it needs to render. +- Add a [``](https://docs.astro.build/en/core-concepts/astro-components/#slots) inside the default component. This makes sure that if the component is passed any child elements, Astro knows where to render them. + +## Use page data + +When overriding a Starlight component, your custom implementation receives a standard `Astro.props` object containing all the data for the current page. +This allows you to use these values to control how your component template renders. + +For example, you can read the page’s frontmatter values as `Astro.props.entry.data`. In the following example, a replacement [`PageTitle`](/reference/overrides/#pagetitle) component uses this to display the current page’s title: + +```astro {5} "{title}" +--- +// src/components/Title.astro +import type { Props } from '@astrojs/starlight/props'; + +const { title } = Astro.props.entry.data; +--- + +

{title}

+ + +``` + +Learn more about all the available props in the [Overrides Reference](/reference/overrides/#prop-types). + +### Only override on specific pages + +Component overrides apply to all pages. However, you can conditionally render using values from `Astro.props` to determine when to show your custom UI, when to show Starlight’s default UI, or even when to show something entirely different. + +In the following example, a component overriding Starlight's [`Footer`](/reference/overrides/#footer-1) displays "Built with Starlight 🌟" on the homepage only, and otherwise shows the default footer on all other pages: + +```astro +--- +// src/components/ConditionalFooter.astro +import type { Props } from '@astrojs/starlight/props'; +import Default from '@astrojs/starlight/Footer.astro'; + +const isHomepage = Astro.props.slug === ''; +--- + +{ + isHomepage ? ( +
Built with Starlight 🌟
+ ) : ( + + + + ) +} +``` + +Learn more about conditional rendering in [Astro’s Template Syntax guide](https://docs.astro.build/en/core-concepts/astro-syntax/#dynamic-html). diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx index 6a732e6d..33256366 100644 --- a/docs/src/content/docs/guides/sidebar.mdx +++ b/docs/src/content/docs/guides/sidebar.mdx @@ -1,8 +1,6 @@ --- title: Sidebar Navigation description: Learn how to set up and customize your Starlight site’s sidebar navigation links. -sidebar: - badge: New --- import FileTree from '../../../components/file-tree.astro'; diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 6836d865..6cef6afa 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -5,6 +5,8 @@ head: content: Starlight 🌟 Build documentation sites with Astro description: Starlight helps you build beautiful, high-performance documentation websites with Astro. template: splash +editUrl: false +lastUpdated: false banner: content: | diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index ad2d8591..b392f7f5 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -436,3 +436,19 @@ Sets the delimiter between page title and site title in the page’s `` t By default, every page has a `<title>` of `Page Title | Site Title`. For example, this page is titled “Configuration Reference” and this site is titled “Starlight”, so the `<title>` for this page is “Configuration Reference | Starlight”. + +### `components` + +**type:** `Record<string, string>` + +Provide the paths to components to override Starlight’s default implementations. + +```js +starlight({ + components: { + SocialLinks: './src/components/MySocialLinks.astro', + }, +}); +``` + +See the [Overrides Reference](/reference/overrides/) for details of all the components that you can override. diff --git a/docs/src/content/docs/reference/overrides.md b/docs/src/content/docs/reference/overrides.md new file mode 100644 index 00000000..6b2ea2c4 --- /dev/null +++ b/docs/src/content/docs/reference/overrides.md @@ -0,0 +1,374 @@ +--- +title: Overrides Reference +description: An overview of the components and component props supported by Starlight overrides. +tableOfContents: + maxHeadingLevel: 4 +--- + +You can override Starlight’s built-in components by providing paths to replacement components in Starlight’s [`components`](/reference/configuration#components) configuration option. +This page lists all components available to override and links to their default implementations on GitHub. + +Learn more in the [Guide to Overriding Components](/guides/overriding-components/). + +## Component props + +All components can access a standard `Astro.props` object that contains information about the current page. + +To type your custom components, import the `Props` type from Starlight: + +```astro +--- +import type { Props } from '@astrojs/starlight/props'; + +const { hasSidebar } = Astro.props; +// ^ type: boolean +--- +``` + +This will give you autocomplete and types when accessing `Astro.props`. + +### Props + +Starlight will pass the following props to your custom components. + +#### `dir` + +**Type:** `'ltr' | 'rtl'` + +Page writing direction. + +#### `lang` + +**Type:** `string` + +BCP-47 language tag for this page’s locale, e.g. `en`, `zh`, or `pt-BR`. + +#### `locale` + +**Type:** `string | undefined` + +The base path at which a language is served. `undefined` for root locale slugs. + +#### `slug` + +**Type:** `string` + +The slug for this page generated from the content filename. + +#### `id` + +**Type:** `string` + +The unique ID for this page based on the content filename. + +#### `isFallback` + +**Type:** `true | undefined` + +`true` if this page is untranslated in the current language and using fallback content from the default locale. +Only used in multilingual sites. + +#### `entryMeta` + +**Type:** `{ dir: 'ltr' | 'rtl'; lang: string }` + +Locale metadata for the page content. Can be different from top-level locale values when a page is using fallback content. + +#### `entry` + +The Astro content collection entry for the current page. +Includes frontmatter values for the current page at `entry.data`. + +```ts +entry: { + data: { + title: string; + description: string | undefined; + // etc. + } +} +``` + +Learn more about the shape of this object in [Astro’s Collection Entry Type](https://docs.astro.build/en/reference/api-reference/#collection-entry-type) reference. + +#### `sidebar` + +**Type:** `SidebarEntry[]` + +Site navigation sidebar entries for this page. + +#### `hasSidebar` + +**Type:** `boolean` + +Whether or not the sidebar should be displayed on this page. + +#### `pagination` + +**Type:** `{ prev?: Link; next?: Link }` + +Links to the previous and next page in the sidebar if enabled. + +#### `toc` + +**Type:** `{ minHeadingLevel: number; maxHeadingLevel: number; items: TocItem[] } | undefined` + +Table of contents for this page if enabled. + +#### `headings` + +**Type:** `{ depth: number; slug: string; text: string }[]` + +Array of all Markdown headings extracted from the current page. +Use [`toc`](#toc) instead if you want to build a table of contents that respects Starlight’s configuration options. + +#### `lastUpdated` + +**Type:** `Date | undefined` + +JavaScript `Date` object representing when this page was last updated if enabled. + +#### `editUrl` + +**Type:** `URL | undefined` + +`URL` object for the address where this page can be edited if enabled. + +--- + +## Components + +### Head + +These components are rendered inside each page’s `<head>` element. +They should only include [elements permitted inside `<head>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/head#see_also). + +#### `Head` + +**Default component:** [`Head.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Head.astro) + +Component rendered inside each page’s `<head>`. +Includes important tags including `<title>`, and `<meta charset="utf-8">`. + +Override this component as a last resort. +Prefer the [`head`](/reference/configuration#head) option Starlight config if possible. + +#### `ThemeProvider` + +**Default component:** [`ThemeProvider.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/ThemeProvider.astro) + +Component rendered inside `<head>` that sets up dark/light theme support. +The default implementation includes an inline script and a `<template>` used by the script in [`<ThemeSelect />`](#themeselect). + +--- + +### Accessibility + +#### `SkipLink` + +**Default component:** [`SkipLink.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/SkipLink.astro) + +Component rendered as the first element inside `<body>` which links to the main page content for accessibility. +The default implementation is hidden until a user focuses it by tabbing with their keyboard. + +--- + +### Layout + +These components are responsible for laying out Starlight’s components and managing views across different breakpoints. +Overriding these comes with significant complexity. +When possible, prefer overriding a lower-level component. + +#### `PageFrame` + +**Default component:** [`PageFrame.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageFrame.astro) + +Layout component wrapped around most of the page content. +The default implementation sets up the header–sidebar–main layout and includes `header` and `sidebar` named slots along with a default slot for the main content. +It also renders [`<MobileMenuToggle />`](#mobilemenutoggle) to support toggling the sidebar navigation on small (mobile) viewports. + +#### `MobileMenuToggle` + +**Default component:** [`MobileMenuToggle.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileMenuToggle.astro) + +Component rendered inside [`<PageFrame>`](#pageframe) that is responsible for toggling the sidebar navigation on small (mobile) viewports. + +#### `TwoColumnContent` + +**Default component:** [`TwoColumnContent.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/TwoColumnContent.astro) + +Layout component wrapped around the main content column and right sidebar (table of contents). +The default implementation handles the switch between a single-column, small-viewport layout and a two-column, larger-viewport layout. + +--- + +### Header + +These components render Starlight’s top navigation bar. + +#### `Header` + +**Default component:** [`Header.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Header.astro) + +Header component displayed at the top of every page. +The default implementation displays [`<SiteTitle />`](#sitetitle), [`<Search />`](#search), [`<SocialIcons />`](#socialicons), [`<ThemeSelect />`](#themeselect), and [`<LanguageSelect />`](#languageselect). + +#### `SiteTitle` + +**Default component:** [`SiteTitle.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/SiteTitle.astro) + +Component rendered at the start of the site header to render the site title. +The default implementation includes logic for rendering logos defined in Starlight config. + +#### `Search` + +**Default component:** [`Search.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Search.astro) + +Component used to render Starlight’s search UI. +The default implementation includes the button in the header and the code for displaying a search modal when it is clicked and loading [Pagefind’s UI](https://pagefind.app/). + +#### `SocialIcons` + +**Default component:** [`SocialIcons.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/SocialIcons.astro) + +Component rendered in the site header including social icon links. +The default implementation uses the [`social`](/reference/configuration#social) option in Starlight config to render icons and links. + +#### `ThemeSelect` + +**Default component:** [`ThemeSelect.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/ThemeSelect.astro) + +Component rendered in the site header that allows users to select their preferred color scheme. + +#### `LanguageSelect` + +**Default component:** [`LanguageSelect.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/LanguageSelect.astro) + +Component rendered in the site header that allows users to switch to a different language. + +--- + +### Global Sidebar + +Starlight’s global sidebar includes the main site navigation. +On narrow viewports this is hidden behind a drop-down menu. + +#### `Sidebar` + +**Default component:** [`Sidebar.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Sidebar.astro) + +Component rendered before page content that contains global navigation. +The default implementation displays as a sidebar on wide enough viewports and inside a drop-down menu on small (mobile) viewports. +It also renders [`<MobileMenuFooter />`](#mobilemenufooter) to show additional items inside the mobile menu. + +#### `MobileMenuFooter` + +**Default component:** [`MobileMenuFooter.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileMenuFooter.astro) + +Component rendered at the bottom of the mobile drop-down menu. +The default implementation renders [`<ThemeSelect />`](#themeselect) and [`<LanguageSelect />`](#languageselect). + +--- + +### Page Sidebar + +Starlight’s page sidebar is responsible for displaying a table of contents outlining the current page’s subheadings. +On narrow viewports this collapse into a sticky, drop-down menu. + +#### `PageSidebar` + +**Default component:** [`PageSidebar.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageSidebar.astro) + +Component rendered before the main page’s content to display a table of contents. +The default implementation renders [`<TableOfContents />`](#tableofcontents) and [`<MobileTableOfContents />`](#mobiletableofcontents). + +#### `TableOfContents` + +**Default component:** [`TableOfContents.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/TableOfContents.astro) + +Component that renders the current page’s table of contents on wider viewports. + +#### `MobileTableOfContents` + +**Default component:** [`MobileTableOfContents.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileTableOfContents.astro) + +Component that renders the current page’s table of contents on small (mobile) viewports. + +--- + +### Content + +These components are rendered in the main column of page content. + +#### `Banner` + +**Default component:** [`Banner.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Banner.astro) + +Banner component rendered at the top of each page. +The default implementation uses the page’s [`banner`](/reference/frontmatter#banner) frontmatter value to decide whether or not to render. + +#### `ContentPanel` + +**Default component:** [`ContentPanel.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/ContentPanel.astro) + +Layout component used to wrap sections of the main content column. + +#### `PageTitle` + +**Default component:** [`PageTitle.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageTitle.astro) + +Component containing the `<h1>` element for the current page. + +Implementations should ensure they set `id="_top"` on the `<h1>` element as in the default implementation. + +#### `FallbackContentNotice` + +**Default component:** [`FallbackContentNotice.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/FallbackContentNotice.astro) + +Notice displayed to users on pages where a translation for the current language is not available. +Only used on multilingual sites. + +#### `Hero` + +**Default component:** [`Hero.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Hero.astro) + +Component rendered at the top of the page when [`hero`](/reference/frontmatter#hero) is set in frontmatter. +The default implementation shows a large title, tagline, and call-to-action links alongside an optional image. + +#### `MarkdownContent` + +**Default component:** [`MarkdownContent.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/MarkdownContent.astro) + +Component rendered around each page’s main content. +The default implementation sets up basic styles to apply to Markdown content. + +--- + +### Footer + +These components are rendered at the bottom of the main column of page content. + +#### `Footer` + +**Default component:** [`Footer.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Footer.astro) + +Footer component displayed at the bottom of each page. +The default implementation displays [`<LastUpdated />`](#lastupdated), [`<Pagination />`](#pagination), and [`<EditLink />`](#editlink). + +#### `LastUpdated` + +**Default component:** [`LastUpdated.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/LastUpdated.astro) + +Component rendered in the page footer to display the last-updated date. + +#### `EditLink` + +**Default component:** [`EditLink.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/EditLink.astro) + +Component rendered in the page footer to display a link to where the page can be edited. + +#### `Pagination` + +**Default component:** [`Pagination.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Pagination.astro) + +Component rendered in the page footer to display navigation arrows between previous/next pages. diff --git a/packages/starlight/404.astro b/packages/starlight/404.astro index 3b10bf45..913c5143 100644 --- a/packages/starlight/404.astro +++ b/packages/starlight/404.astro @@ -2,7 +2,8 @@ import { getEntry } from 'astro:content'; import config from 'virtual:starlight/user-config'; import EmptyContent from './components/EmptyMarkdown.md'; -import Page from './layout/Page.astro'; +import Page from './components/Page.astro'; +import { generateRouteData } from './utils/route-data'; import type { StarlightDocsEntry } from './utils/routing'; import { useTranslations } from './utils/translations'; @@ -24,6 +25,8 @@ const fallbackEntry: StarlightDocsEntry = { editUrl: false, head: [], hero: { tagline: t('404.text'), actions: [] }, + pagefind: false, + sidebar: { hidden: false }, }, render: async () => ({ Content: EmptyContent, @@ -35,8 +38,10 @@ const fallbackEntry: StarlightDocsEntry = { const userEntry = await getEntry('docs', '404'); const entry = userEntry || fallbackEntry; const { Content, headings } = await entry.render(); +const route = generateRouteData({ + props: { ...entryMeta, entryMeta, headings, entry, id: entry.id, slug: entry.slug }, + url: Astro.url, +}); --- -<Page {headings} entry={entry} id={entry.id} slug={entry.slug} {...entryMeta} {entryMeta}> - <Content /> -</Page> +<Page {...route}><Content /></Page> diff --git a/packages/starlight/__tests__/basics/route-data.test.ts b/packages/starlight/__tests__/basics/route-data.test.ts new file mode 100644 index 00000000..13522d02 --- /dev/null +++ b/packages/starlight/__tests__/basics/route-data.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from 'vitest'; +import { generateRouteData } from '../../utils/route-data'; +import { routes } from '../../utils/routing'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['getting-started.mdx', { title: 'Splash', template: 'splash' }], + ['showcase.mdx', { title: 'ToC Disabled', tableOfContents: false }], + ['environmental-impact.md', { title: 'Explicit update date', lastUpdated: new Date() }], + ], + }) +); + +test('adds data to route shape', () => { + const route = routes[0]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + url: new URL('https://example.com'), + }); + expect(data.hasSidebar).toBe(true); + expect(data).toHaveProperty('lastUpdated'); + expect(data.toc).toMatchInlineSnapshot(` + { + "items": [ + { + "children": [], + "depth": 2, + "slug": "_top", + "text": "Overview", + }, + ], + "maxHeadingLevel": 3, + "minHeadingLevel": 2, + } + `); + expect(data.pagination).toMatchInlineSnapshot(` + { + "next": { + "attrs": {}, + "badge": undefined, + "href": "/environmental-impact/", + "isCurrent": false, + "label": "Explicit update date", + "type": "link", + }, + "prev": undefined, + } + `); + expect(data.sidebar.map((entry) => entry.label)).toMatchInlineSnapshot(` + [ + "Home Page", + "Explicit update date", + "Splash", + "ToC Disabled", + ] + `); +}); + +test('disables table of contents for splash template', () => { + const route = routes[1]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + url: new URL('https://example.com/getting-started/'), + }); + expect(data.toc).toBeUndefined(); +}); + +test('disables table of contents if frontmatter includes `tableOfContents: false`', () => { + const route = routes[2]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + url: new URL('https://example.com/showcase/'), + }); + expect(data.toc).toBeUndefined(); +}); + +test('uses explicit last updated date from frontmatter', () => { + const route = routes[3]!; + const data = generateRouteData({ + props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, + url: new URL('https://example.com/showcase/'), + }); + expect(data.lastUpdated).toBeInstanceOf(Date); + expect(data.lastUpdated).toEqual(route.entry.data.lastUpdated); +}); diff --git a/packages/starlight/__tests__/basics/toc.test.ts b/packages/starlight/__tests__/basics/toc.test.ts index 51745d7b..ae1136f9 100644 --- a/packages/starlight/__tests__/basics/toc.test.ts +++ b/packages/starlight/__tests__/basics/toc.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { generateToC } from '../../components/TableOfContents/generateToC'; +import { generateToC } from '../../utils/generateToC'; const defaultOpts = { minHeadingLevel: 2, maxHeadingLevel: 3, title: 'Overview' }; diff --git a/packages/starlight/__tests__/edit-url/edit-url.test.ts b/packages/starlight/__tests__/edit-url/edit-url.test.ts new file mode 100644 index 00000000..25ab98aa --- /dev/null +++ b/packages/starlight/__tests__/edit-url/edit-url.test.ts @@ -0,0 +1,48 @@ +import { expect, test, vi } from 'vitest'; +import { generateRouteData } from '../../utils/route-data'; +import { routes } from '../../utils/routing'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [ + ['index.mdx', { title: 'Home Page' }], + ['getting-started.mdx', { title: 'Getting Started' }], + [ + 'showcase.mdx', + { title: 'Custom edit link', editUrl: 'https://example.com/custom-edit?link' }, + ], + ], + }) +); + +test('synthesizes edit URL using file location and `editLink.baseUrl`', () => { + { + const route = routes[0]!; + const data = generateRouteData({ + props: { ...route, headings: [] }, + url: new URL('https://example.com'), + }); + expect(data.editUrl?.href).toBe( + 'https://github.com/withastro/starlight/edit/main/docs/src/content/docs/index.mdx' + ); + } + { + const route = routes[1]!; + const data = generateRouteData({ + props: { ...route, headings: [] }, + url: new URL('https://example.com'), + }); + expect(data.editUrl?.href).toBe( + 'https://github.com/withastro/starlight/edit/main/docs/src/content/docs/getting-started.mdx' + ); + } +}); + +test('uses frontmatter `editUrl` if defined', () => { + const route = routes[2]!; + const data = generateRouteData({ + props: { ...route, headings: [] }, + url: new URL('https://example.com'), + }); + expect(data.editUrl?.href).toBe('https://example.com/custom-edit?link'); +}); diff --git a/packages/starlight/__tests__/edit-url/vitest.config.ts b/packages/starlight/__tests__/edit-url/vitest.config.ts new file mode 100644 index 00000000..4035c234 --- /dev/null +++ b/packages/starlight/__tests__/edit-url/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineVitestConfig } from '../test-config'; + +export default defineVitestConfig({ + title: 'Docs With Edit Links', + editLink: { + baseUrl: 'https://github.com/withastro/starlight/edit/main/docs/', + }, +}); diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts index 9d06a693..8d0c42e6 100644 --- a/packages/starlight/__tests__/test-config.ts +++ b/packages/starlight/__tests__/test-config.ts @@ -6,11 +6,9 @@ import { vitePluginStarlightUserConfig } from '../integrations/virtual-user-conf import { StarlightConfigSchema } from '../utils/user-config'; export function defineVitestConfig(config: z.input<typeof StarlightConfigSchema>) { + const root = new URL('./', import.meta.url); + const srcDir = new URL('./src/', root); return getViteConfig({ - plugins: [ - vitePluginStarlightUserConfig(StarlightConfigSchema.parse(config), { - root: new URL(import.meta.url), - }), - ], + plugins: [vitePluginStarlightUserConfig(StarlightConfigSchema.parse(config), { root, srcDir })], }); } diff --git a/packages/starlight/components/Banner.astro b/packages/starlight/components/Banner.astro index e6307bec..107baa70 100644 --- a/packages/starlight/components/Banner.astro +++ b/packages/starlight/components/Banner.astro @@ -1,10 +1,10 @@ --- -interface Props { - content: string; -} +import type { Props } from '../props'; + +const { banner } = Astro.props.entry.data; --- -<div class="sl-banner" set:html={Astro.props.content} /> +{banner && <div class="sl-banner" set:html={banner.content} />} <style> .sl-banner { diff --git a/packages/starlight/components/ContentPanel.astro b/packages/starlight/components/ContentPanel.astro index d4089408..3f23fcd0 100644 --- a/packages/starlight/components/ContentPanel.astro +++ b/packages/starlight/components/ContentPanel.astro @@ -1,3 +1,7 @@ +--- +import type { Props } from '../props'; +--- + <div class="content-panel"> <div class="sl-container"><slot /></div> </div> diff --git a/packages/starlight/components/EditLink.astro b/packages/starlight/components/EditLink.astro index 4de01348..f5f5f65b 100644 --- a/packages/starlight/components/EditLink.astro +++ b/packages/starlight/components/EditLink.astro @@ -1,34 +1,15 @@ --- -import type { CollectionEntry } from 'astro:content'; -import config from 'virtual:starlight/user-config'; -import project from 'virtual:starlight/project-context'; -import { useTranslations } from '../utils/translations'; import Icon from '../user-components/Icon.astro'; - -interface Props { - data: CollectionEntry<'docs'>['data']; - id: CollectionEntry<'docs'>['id']; - locale: string | undefined; -} +import type { Props } from '../props'; +import { useTranslations } from '../utils/translations'; const t = useTranslations(Astro.props.locale); -const { editUrl } = Astro.props.data; -const srcPath = project.srcDir.replace(project.root, ''); - -let { baseUrl } = config.editLink; -if (baseUrl && baseUrl.at(-1) !== '/') baseUrl += '/'; - -const url = - typeof editUrl === 'string' - ? editUrl - : baseUrl - ? baseUrl + srcPath + 'content/docs/' + Astro.props.id - : undefined; +const { editUrl } = Astro.props; --- { - editUrl !== false && url && ( - <a href={url} class="sl-flex"> + editUrl && ( + <a href={editUrl} class="sl-flex"> <Icon name="pencil" size="1.2em" /> {t('page.editLink')} </a> diff --git a/packages/starlight/components/FallbackContentNotice.astro b/packages/starlight/components/FallbackContentNotice.astro index 9b7ca623..171eeb15 100644 --- a/packages/starlight/components/FallbackContentNotice.astro +++ b/packages/starlight/components/FallbackContentNotice.astro @@ -1,10 +1,7 @@ --- -import { useTranslations } from '../utils/translations'; import Icon from '../user-components/Icon.astro'; - -interface Props { - locale: string | undefined; -} +import type { Props } from '../props'; +import { useTranslations } from '../utils/translations'; const t = useTranslations(Astro.props.locale); --- diff --git a/packages/starlight/components/Footer.astro b/packages/starlight/components/Footer.astro index 2d14dee7..f5936f01 100644 --- a/packages/starlight/components/Footer.astro +++ b/packages/starlight/components/Footer.astro @@ -1,42 +1,15 @@ --- -import config from 'virtual:starlight/user-config'; -import { type SidebarEntry, getPrevNextLinks } from '../utils/navigation'; -import type { StarlightDocsEntry } from '../utils/routing'; -import type { LocaleData } from '../utils/slugs'; +import type { Props } from '../props'; -import LastUpdated from '../components/LastUpdated.astro'; -import PrevNextLinks from '../components/PrevNextLinks.astro'; -import EditLink from './EditLink.astro'; - -interface Props extends LocaleData { - entry: StarlightDocsEntry; - sidebar: SidebarEntry[]; -} - -const { entry, dir, lang, locale, sidebar } = Astro.props; -const prevNextLinks = getPrevNextLinks(sidebar, config.pagination, { - prev: entry.data.prev, - next: entry.data.next, -}); +import { EditLink, LastUpdated, Pagination } from 'virtual:starlight/components'; --- <footer> <div class="meta sl-flex"> - {config.editLink.baseUrl && <EditLink data={entry.data} id={entry.id} {locale} />} - { - (entry.data.lastUpdated ?? config.lastUpdated) && ( - <LastUpdated - id={entry.id} - {lang} - lastUpdated={ - typeof entry.data.lastUpdated !== 'boolean' ? entry.data.lastUpdated : undefined - } - {locale} - /> - ) - } + <EditLink {...Astro.props} /> + <LastUpdated {...Astro.props} /> </div> - <PrevNextLinks {...prevNextLinks} {dir} {locale} /> + <Pagination {...Astro.props} /> </footer> <style> diff --git a/packages/starlight/components/Head.astro b/packages/starlight/components/Head.astro new file mode 100644 index 00000000..ec2431fc --- /dev/null +++ b/packages/starlight/components/Head.astro @@ -0,0 +1,102 @@ +--- +import type { z } from 'astro/zod'; +import config from 'virtual:starlight/user-config'; +import { version } from '../package.json'; +import type { HeadConfigSchema } from '../schemas/head'; +import { fileWithBase } from '../utils/base'; +import { createHead } from '../utils/head'; +import { localizedUrl } from '../utils/localizedUrl'; +import type { Props } from '../props'; + +const { entry, lang } = Astro.props; +const { data } = entry; + +const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined; +const description = data.description || config.description; + +const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [ + { tag: 'meta', attrs: { charset: 'utf-8' } }, + { + tag: 'meta', + attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + }, + { tag: 'title', content: `${data.title} ${config.titleDelimiter} ${config.title}` }, + { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } }, + { tag: 'meta', attrs: { name: 'generator', content: Astro.generator } }, + { + tag: 'meta', + attrs: { name: 'generator', content: `Starlight v${version}` }, + }, + // Favicon + { + tag: 'link', + attrs: { + rel: 'shortcut icon', + href: fileWithBase(config.favicon.href), + type: config.favicon.type, + }, + }, + // OpenGraph Tags + { tag: 'meta', attrs: { property: 'og:title', content: data.title } }, + { tag: 'meta', attrs: { property: 'og:type', content: 'article' } }, + { tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } }, + { tag: 'meta', attrs: { property: 'og:locale', content: lang } }, + { tag: 'meta', attrs: { property: 'og:description', content: description } }, + { tag: 'meta', attrs: { property: 'og:site_name', content: config.title } }, + // Twitter Tags + { + tag: 'meta', + attrs: { name: 'twitter:card', content: 'summary_large_image' }, + }, + { tag: 'meta', attrs: { name: 'twitter:title', content: data.title } }, + { tag: 'meta', attrs: { name: 'twitter:description', content: description } }, +]; + +if (description) + headDefaults.push({ + tag: 'meta', + attrs: { name: 'description', content: description }, + }); + +// Link to language alternates. +if (canonical && config.isMultilingual) { + for (const locale in config.locales) { + const localeOpts = config.locales[locale]; + if (!localeOpts) continue; + headDefaults.push({ + tag: 'link', + attrs: { + rel: 'alternate', + hreflang: localeOpts.lang, + href: localizedUrl(canonical, locale).href, + }, + }); + } +} + +// Link to sitemap, but only when `site` is set. +if (Astro.site) { + headDefaults.push({ + tag: 'link', + attrs: { + rel: 'sitemap', + href: fileWithBase('/sitemap-index.xml'), + }, + }); +} + +// Link to Twitter account if set in Starlight config. +if (config.social?.twitter) { + headDefaults.push({ + tag: 'meta', + attrs: { + name: 'twitter:site', + content: new URL(config.social.twitter.url).pathname, + }, + }); +} + +const head = createHead(headDefaults, config.head, data.head); +--- + +{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)} diff --git a/packages/starlight/components/HeadSEO.astro b/packages/starlight/components/HeadSEO.astro deleted file mode 100644 index e744103c..00000000 --- a/packages/starlight/components/HeadSEO.astro +++ /dev/null @@ -1,105 +0,0 @@ ---- -import type { CollectionEntry, z } from 'astro:content'; -import config from 'virtual:starlight/user-config'; -import type { HeadConfigSchema } from '../schemas/head'; -import { createHead } from '../utils/head'; -import { localizedUrl } from '../utils/localizedUrl'; -import { fileWithBase } from '../utils/base'; -import { version } from '../package.json'; - -interface Props { - data: CollectionEntry<'docs'>['data']; - lang: string; -} - -const { data, lang } = Astro.props; - -const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined; -const description = data.description || config.description; - -const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [ - { tag: 'meta', attrs: { charset: 'utf-8' } }, - { - tag: 'meta', - attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - }, - { tag: 'title', content: `${data.title} ${config.titleDelimiter} ${config.title}` }, - { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } }, - { tag: 'meta', attrs: { name: 'generator', content: Astro.generator } }, - { - tag: 'meta', - attrs: { name: 'generator', content: `Starlight v${version}` }, - }, - // Favicon - { - tag: 'link', - attrs: { - rel: 'shortcut icon', - href: fileWithBase(config.favicon.href), - type: config.favicon.type, - }, - }, - // OpenGraph Tags - { tag: 'meta', attrs: { property: 'og:title', content: data.title } }, - { tag: 'meta', attrs: { property: 'og:type', content: 'article' } }, - { tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } }, - { tag: 'meta', attrs: { property: 'og:locale', content: lang } }, - { tag: 'meta', attrs: { property: 'og:description', content: description } }, - { tag: 'meta', attrs: { property: 'og:site_name', content: config.title } }, - // Twitter Tags - { - tag: 'meta', - attrs: { name: 'twitter:card', content: 'summary_large_image' }, - }, - { tag: 'meta', attrs: { name: 'twitter:title', content: data.title } }, - { tag: 'meta', attrs: { name: 'twitter:description', content: description } }, -]; - -if (description) - headDefaults.push({ - tag: 'meta', - attrs: { name: 'description', content: description }, - }); - -// Link to language alternates. -if (canonical && config.isMultilingual) { - for (const locale in config.locales) { - const localeOpts = config.locales[locale]; - if (!localeOpts) continue; - headDefaults.push({ - tag: 'link', - attrs: { - rel: 'alternate', - hreflang: localeOpts.lang, - href: localizedUrl(canonical, locale).href, - }, - }); - } -} - -// Link to sitemap, but only when `site` is set. -if (Astro.site) { - headDefaults.push({ - tag: 'link', - attrs: { - rel: 'sitemap', - href: fileWithBase('/sitemap-index.xml'), - }, - }); -} - -// Link to Twitter account if set in Starlight config. -if (config.social?.twitter) { - headDefaults.push({ - tag: 'meta', - attrs: { - name: 'twitter:site', - content: new URL(config.social.twitter).pathname, - }, - }); -} - -const head = createHead(headDefaults, config.head, data.head); ---- - -{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)} diff --git a/packages/starlight/components/Header.astro b/packages/starlight/components/Header.astro index 6ed8aa1c..51bf9a08 100644 --- a/packages/starlight/components/Header.astro +++ b/packages/starlight/components/Header.astro @@ -1,24 +1,28 @@ --- -import LanguageSelect from './LanguageSelect.astro'; -import Search from './Search.astro'; -import SiteTitle from './SiteTitle.astro'; -import SocialIcons from './SocialIcons.astro'; -import ThemeSelect from './ThemeSelect.astro'; +import type { Props } from '../props'; -interface Props { - locale: string | undefined; -} - -const { locale } = Astro.props; +import { + LanguageSelect, + Search, + SiteTitle, + SocialIcons, + ThemeSelect, +} from 'virtual:starlight/components'; --- <div class="header sl-flex"> - <SiteTitle {locale} /> - <Search {locale} /> + <div class="sl-flex"> + <SiteTitle {...Astro.props} /> + </div> + <div class="sl-flex"> + <Search {...Astro.props} /> + </div> <div class="sl-hidden md:sl-flex right-group"> - <SocialIcons /> - <ThemeSelect {locale} /> - <LanguageSelect {locale} /> + <div class="sl-flex social-icons"> + <SocialIcons {...Astro.props} /> + </div> + <ThemeSelect {...Astro.props} /> + <LanguageSelect {...Astro.props} /> </div> </div> @@ -30,10 +34,16 @@ const { locale } = Astro.props; height: 100%; } - .right-group { + .right-group, + .social-icons { gap: 1rem; align-items: center; } + .social-icons::after { + content: ''; + height: 2rem; + border-inline-end: 1px solid var(--sl-color-gray-5); + } @media (min-width: 50rem) { :global(:root[data-has-sidebar]) { diff --git a/packages/starlight/components/Hero.astro b/packages/starlight/components/Hero.astro index fbc3692c..bd9e8202 100644 --- a/packages/starlight/components/Hero.astro +++ b/packages/starlight/components/Hero.astro @@ -1,21 +1,18 @@ --- -import type { CollectionEntry } from 'astro:content'; import { Image } from 'astro:assets'; +import { PAGE_TITLE_ID } from '../constants'; +import type { Props } from '../props'; import CallToAction from './CallToAction.astro'; -interface Props { - fallbackTitle: string; - hero: NonNullable<CollectionEntry<'docs'>['data']['hero']>; -} - -const { title = Astro.props.fallbackTitle, tagline, image, actions } = Astro.props.hero; +const { data } = Astro.props.entry; +const { title = data.title, tagline, image, actions = [] } = data.hero || {}; const imageAttrs = { loading: 'eager' as const, decoding: 'async' as const, width: 400, height: 400, - alt: image?.alt, + alt: image?.alt || '', }; --- @@ -33,7 +30,7 @@ const imageAttrs = { } <div class="sl-flex stack"> <div class="sl-flex copy"> - <h1 id="_top" data-page-title set:html={title} /> + <h1 id={PAGE_TITLE_ID} data-page-title set:html={title} /> {tagline && <div class="tagline" set:html={tagline} />} </div> { diff --git a/packages/starlight/components/LanguageSelect.astro b/packages/starlight/components/LanguageSelect.astro index d59df1c1..077935b3 100644 --- a/packages/starlight/components/LanguageSelect.astro +++ b/packages/starlight/components/LanguageSelect.astro @@ -3,10 +3,7 @@ import config from 'virtual:starlight/user-config'; import { localizedUrl } from '../utils/localizedUrl'; import { useTranslations } from '../utils/translations'; import Select from './Select.astro'; - -interface Props { - locale: string | undefined; -} +import type { Props } from '../props'; /** * Get the equivalent of the current page path for the passed locale. diff --git a/packages/starlight/components/LastUpdated.astro b/packages/starlight/components/LastUpdated.astro index 278057e9..73a6c2d0 100644 --- a/packages/starlight/components/LastUpdated.astro +++ b/packages/starlight/components/LastUpdated.astro @@ -1,36 +1,17 @@ --- -import type { CollectionEntry } from 'astro:content'; -import { fileURLToPath } from 'node:url'; -import project from 'virtual:starlight/project-context'; -import { getFileCommitDate } from '../utils/git'; +import type { Props } from '../props'; import { useTranslations } from '../utils/translations'; -interface Props { - id: CollectionEntry<'docs'>['id']; - lang: string; - lastUpdated: Date | undefined; - locale: string | undefined; -} - -const { id, lang, lastUpdated, locale } = Astro.props; +const { lang, lastUpdated, locale } = Astro.props; const t = useTranslations(locale); - -const currentFilePath = fileURLToPath(new URL('src/content/docs/' + id, project.root)); - -let date = lastUpdated; -try { - if (!date) { - ({ date } = getFileCommitDate(currentFilePath, 'newest')); - } -} catch {} --- { - date && ( + lastUpdated && ( <p> {t('page.lastUpdated')}{' '} - <time datetime={date.toISOString()}> - {date.toLocaleDateString(lang, { dateStyle: 'medium' })} + <time datetime={lastUpdated.toISOString()}> + {lastUpdated.toLocaleDateString(lang, { dateStyle: 'medium' })} </time> </p> ) diff --git a/packages/starlight/components/MarkdownContent.astro b/packages/starlight/components/MarkdownContent.astro index ea44bd6c..5e866a1a 100644 --- a/packages/starlight/components/MarkdownContent.astro +++ b/packages/starlight/components/MarkdownContent.astro @@ -1,3 +1,7 @@ +--- +import type { Props } from '../props'; +--- + <div class="content"><slot /></div> <style> diff --git a/packages/starlight/components/MobileMenuFooter.astro b/packages/starlight/components/MobileMenuFooter.astro new file mode 100644 index 00000000..a5258da1 --- /dev/null +++ b/packages/starlight/components/MobileMenuFooter.astro @@ -0,0 +1,17 @@ +--- +import { LanguageSelect, ThemeSelect } from 'virtual:starlight/components'; +import type { Props } from '../props'; +--- + +<div class="mobile-preferences sl-flex"> + <ThemeSelect {...Astro.props} /> + <LanguageSelect {...Astro.props} /> +</div> + +<style> + .mobile-preferences { + justify-content: space-between; + border-top: 1px solid var(--sl-color-gray-6); + padding: 0.5rem 0; + } +</style> diff --git a/packages/starlight/components/MobileMenuToggle.astro b/packages/starlight/components/MobileMenuToggle.astro index a6e620b0..e6619bbb 100644 --- a/packages/starlight/components/MobileMenuToggle.astro +++ b/packages/starlight/components/MobileMenuToggle.astro @@ -1,10 +1,8 @@ --- -import Icon from '../user-components/Icon.astro'; +import type { Props } from '../props'; import { useTranslations } from '../utils/translations'; -interface Props { - locale: string | undefined; -} +import Icon from '../user-components/Icon.astro'; const t = useTranslations(Astro.props.locale); --- diff --git a/packages/starlight/components/MobileTableOfContents.astro b/packages/starlight/components/MobileTableOfContents.astro new file mode 100644 index 00000000..d5ec3cc6 --- /dev/null +++ b/packages/starlight/components/MobileTableOfContents.astro @@ -0,0 +1,151 @@ +--- +import { useTranslations } from '../utils/translations'; +import Icon from '../user-components/Icon.astro'; +import TableOfContentsList from './TableOfContents/TableOfContentsList.astro'; +import type { Props } from '../props'; + +const { locale, toc } = Astro.props; +const t = useTranslations(locale); +--- + +{ + toc && ( + <mobile-starlight-toc data-min-h={toc.minHeadingLevel} data-max-h={toc.maxHeadingLevel}> + <nav aria-labelledby="starlight__on-this-page--mobile"> + <details id="starlight__mobile-toc"> + <summary id="starlight__on-this-page--mobile" class="sl-flex"> + <div class="toggle sl-flex"> + {t('tableOfContents.onThisPage')} + <Icon name={'right-caret'} class="caret" size="1rem" /> + </div> + <span class="display-current" /> + </summary> + <div class="dropdown"> + <TableOfContentsList toc={toc.items} isMobile /> + </div> + </details> + </nav> + </mobile-starlight-toc> + ) +} + +<style> + nav { + position: fixed; + z-index: var(--sl-z-index-toc); + top: calc(var(--sl-nav-height) - 1px); + inset-inline: 0; + border-top: 1px solid var(--sl-color-gray-5); + background-color: var(--sl-color-bg-nav); + } + @media (min-width: 50rem) { + nav { + inset-inline-start: var(--sl-content-inline-start, 0); + } + } + + summary { + gap: 0.5rem; + align-items: center; + height: var(--sl-mobile-toc-height); + border-bottom: 1px solid var(--sl-color-hairline-shade); + padding: 0.5rem 1rem; + font-size: var(--sl-text-xs); + outline-offset: var(--sl-outline-offset-inside); + } + summary::marker, + summary::-webkit-details-marker { + display: none; + } + + .toggle { + flex-shrink: 0; + gap: 1rem; + align-items: center; + justify-content: space-between; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + padding-block: 0.5rem; + padding-inline-start: 0.75rem; + padding-inline-end: 0.5rem; + line-height: 1; + background-color: var(--sl-color-black); + user-select: none; + cursor: pointer; + } + details[open] .toggle { + color: var(--sl-color-white); + border-color: var(--sl-color-accent); + } + details .toggle:hover { + color: var(--sl-color-white); + border-color: var(--sl-color-gray-2); + } + + :global([dir='rtl']) .caret { + transform: rotateZ(180deg); + } + details[open] .caret { + transform: rotateZ(90deg); + } + + .display-current { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + color: var(--sl-color-white); + } + + .dropdown { + --border-top: 1px; + margin-top: calc(-1 * var(--border-top)); + border: var(--border-top) solid var(--sl-color-gray-6); + border-top-color: var(--sl-color-hairline-shade); + max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height)); + overflow-y: auto; + background-color: var(--sl-color-black); + box-shadow: var(--sl-shadow-md); + } +</style> + +<script> + import { StarlightTOC } from './TableOfContents/starlight-toc'; + + class MobileStarlightTOC extends StarlightTOC { + override set current(link: HTMLAnchorElement) { + super.current = link; + const display = this.querySelector('.display-current') as HTMLSpanElement; + if (display) display.textContent = link.textContent; + } + + constructor() { + super(); + const details = this.querySelector('details'); + if (!details) return; + const closeToC = () => { + details.open = false; + }; + // Close the table of contents whenever a link is clicked. + details.querySelectorAll('a').forEach((a) => { + a.addEventListener('click', closeToC); + }); + // Close the table of contents when a user clicks outside of it. + window.addEventListener('click', (e) => { + if (!details.contains(e.target as Node)) closeToC(); + }); + // Or when they press the escape key. + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && details.open) { + const hasFocus = details.contains(document.activeElement); + closeToC(); + if (hasFocus) { + const summary = details.querySelector('summary'); + if (summary) summary.focus(); + } + } + }); + } + } + + customElements.define('mobile-starlight-toc', MobileStarlightTOC); +</script> diff --git a/packages/starlight/components/Page.astro b/packages/starlight/components/Page.astro new file mode 100644 index 00000000..306a0ed2 --- /dev/null +++ b/packages/starlight/components/Page.astro @@ -0,0 +1,120 @@ +--- +import type { Props } from '../props'; + +// Built-in CSS styles. +import '../style/props.css'; +import '../style/reset.css'; +import '../style/shiki.css'; +import '../style/util.css'; + +// Components — can override built-in CSS, but not user CSS. +import { + Banner, + ContentPanel, + PageTitle, + FallbackContentNotice, + Footer, + Header, + Head, + Hero, + MarkdownContent, + PageSidebar, + Sidebar, + SkipLink, + ThemeProvider, + PageFrame, + TwoColumnContent, +} from 'virtual:starlight/components'; + +// Remark component CSS (needs to override `MarkdownContent.astro`) +import '../style/asides.css'; + +// Important that this is the last import so it can override built-in styles. +import 'virtual:starlight/user-css'; + +const pagefindEnabled = + Astro.props.entry.slug !== '404' && + !Astro.props.entry.slug.endsWith('/404') && + Astro.props.entry.data.pagefind !== false; +--- + +<html + lang={Astro.props.lang} + dir={Astro.props.dir} + data-has-toc={Boolean(Astro.props.toc)} + data-has-sidebar={Astro.props.hasSidebar} + data-has-hero={Boolean(Astro.props.entry.data.hero)} +> + <head> + <Head {...Astro.props} /> + <style> + html:not([data-has-toc]) { + --sl-mobile-toc-height: 0rem; + } + html:not([data-has-sidebar]) { + --sl-content-width: 67.5rem; + } + /* Add scroll padding to ensure anchor headings aren't obscured by nav */ + html { + /* Additional padding is needed to account for the mobile TOC */ + scroll-padding-top: calc(1.5rem + var(--sl-nav-height) + var(--sl-mobile-toc-height)); + } + main { + padding-bottom: 3vh; + } + @media (min-width: 50em) { + [data-has-sidebar] { + --sl-content-inline-start: var(--sl-sidebar-width); + } + } + @media (min-width: 72em) { + html { + scroll-padding-top: calc(1.5rem + var(--sl-nav-height)); + } + } + </style> + <ThemeProvider {...Astro.props} /> + </head> + <body> + <SkipLink {...Astro.props} /> + <PageFrame {...Astro.props}> + <Header slot="header" {...Astro.props} /> + {Astro.props.hasSidebar && <Sidebar slot="sidebar" {...Astro.props} />} + <TwoColumnContent {...Astro.props}> + <PageSidebar slot="right-sidebar" {...Astro.props} /> + <main + data-pagefind-body={pagefindEnabled} + lang={Astro.props.entryMeta.lang} + dir={Astro.props.entryMeta.dir} + > + {/* TODO: Revisit how this logic flows. */} + <Banner {...Astro.props} /> + { + Astro.props.entry.data.hero ? ( + <ContentPanel {...Astro.props}> + <Hero {...Astro.props} /> + <MarkdownContent {...Astro.props}> + <slot /> + </MarkdownContent> + <Footer {...Astro.props} /> + </ContentPanel> + ) : ( + <> + <ContentPanel {...Astro.props}> + <PageTitle {...Astro.props} /> + {Astro.props.isFallback && <FallbackContentNotice {...Astro.props} />} + </ContentPanel> + <ContentPanel {...Astro.props}> + <MarkdownContent {...Astro.props}> + <slot /> + </MarkdownContent> + <Footer {...Astro.props} /> + </ContentPanel> + </> + ) + } + </main> + </TwoColumnContent> + </PageFrame> + </body> +</html> diff --git a/packages/starlight/components/PageFrame.astro b/packages/starlight/components/PageFrame.astro new file mode 100644 index 00000000..86e98725 --- /dev/null +++ b/packages/starlight/components/PageFrame.astro @@ -0,0 +1,98 @@ +--- +import type { Props } from '../props'; +import { useTranslations } from '../utils/translations'; + +import { MobileMenuToggle } from 'virtual:starlight/components'; + +const { hasSidebar, locale } = Astro.props; +const t = useTranslations(locale); +--- + +<div class="page sl-flex"> + <header class="header"><slot name="header" /></header> + { + hasSidebar && ( + <nav class="sidebar" aria-label={t('sidebarNav.accessibleLabel')}> + <MobileMenuToggle {...Astro.props} /> + <div id="starlight__sidebar" class="sidebar-pane"> + <div class="sidebar-content sl-flex"> + <slot name="sidebar" /> + </div> + </div> + </nav> + ) + } + <div class="main-frame"><slot /></div> +</div> + +<style> + .page { + flex-direction: column; + min-height: 100vh; + } + + .header { + z-index: var(--sl-z-index-navbar); + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + width: 100%; + height: var(--sl-nav-height); + border-bottom: 1px solid var(--sl-color-hairline-shade); + padding: var(--sl-nav-pad-y) var(--sl-nav-pad-x); + padding-inline-end: var(--sl-nav-pad-x); + background-color: var(--sl-color-bg-nav); + } + + :global([data-has-sidebar]) .header { + padding-inline-end: calc(var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size)); + } + + .sidebar-pane { + visibility: var(--sl-sidebar-visibility, hidden); + position: fixed; + z-index: var(--sl-z-index-menu); + inset-block: 0; + inset-inline-start: 0; + padding-top: var(--sl-nav-height); + width: 100%; + background-color: var(--sl-color-black); + overflow-y: auto; + } + + :global([aria-expanded='true']) ~ .sidebar-pane { + --sl-sidebar-visibility: visible; + } + + .sidebar-content { + height: 100%; + min-height: max-content; + padding: 1rem var(--sl-sidebar-pad-x) 0; + flex-direction: column; + gap: 1rem; + } + + @media (min-width: 50rem) { + .sidebar-content::after { + content: ''; + padding-bottom: 1px; + } + } + + .main-frame { + padding-top: calc(var(--sl-nav-height) + var(--sl-mobile-toc-height)); + padding-inline-start: var(--sl-content-inline-start); + } + + @media (min-width: 50rem) { + :global([data-has-sidebar]) .header { + padding-inline-end: var(--sl-nav-pad-x); + } + .sidebar-pane { + --sl-sidebar-visibility: visible; + width: var(--sl-sidebar-width); + background-color: var(--sl-color-bg-sidebar); + border-inline-end: 1px solid var(--sl-color-hairline-shade); + } + } +</style> diff --git a/packages/starlight/components/PageSidebar.astro b/packages/starlight/components/PageSidebar.astro new file mode 100644 index 00000000..eab3d391 --- /dev/null +++ b/packages/starlight/components/PageSidebar.astro @@ -0,0 +1,58 @@ +--- +import type { Props } from '../props'; + +import { TableOfContents, MobileTableOfContents } from 'virtual:starlight/components'; +--- + +{ + Astro.props.toc && ( + <> + <div class="lg:sl-hidden"> + <MobileTableOfContents {...Astro.props} /> + </div> + <div class="right-sidebar-panel sl-hidden lg:sl-block"> + <div class="sl-container"> + <TableOfContents {...Astro.props} /> + </div> + </div> + </> + ) +} + +<style> + .right-sidebar-panel { + padding: 1rem var(--sl-sidebar-pad-x); + } + .sl-container { + width: calc(var(--sl-sidebar-width) - 2 * var(--sl-sidebar-pad-x)); + } + .right-sidebar-panel :global(h2) { + color: var(--sl-color-white); + font-size: var(--sl-text-h5); + font-weight: 600; + line-height: var(--sl-line-height-headings); + margin-bottom: 0.5rem; + } + .right-sidebar-panel :global(a) { + display: block; + font-size: var(--sl-text-xs); + text-decoration: none; + color: var(--sl-color-gray-3); + overflow-wrap: anywhere; + } + .right-sidebar-panel :global(a:hover) { + color: var(--sl-color-white); + } + @media (min-width: 72rem) { + .sl-container { + max-width: calc( + ( + ( + 100vw - var(--sl-sidebar-width) - 2 * var(--sl-content-pad-x) - 2 * + var(--sl-sidebar-pad-x) + ) * 0.25 /* MAGIC NUMBER 🥲 */ + ) + ); + } + } +</style> diff --git a/packages/starlight/components/PageTitle.astro b/packages/starlight/components/PageTitle.astro new file mode 100644 index 00000000..8c6d932b --- /dev/null +++ b/packages/starlight/components/PageTitle.astro @@ -0,0 +1,16 @@ +--- +import { PAGE_TITLE_ID } from '../constants'; +import type { Props } from '../props'; +--- + +<h1 id={PAGE_TITLE_ID}>{Astro.props.entry.data.title}</h1> + +<style> + h1 { + margin-top: 1rem; + font-size: var(--sl-text-h1); + line-height: var(--sl-line-height-headings); + font-weight: 600; + color: var(--sl-color-white); + } +</style> diff --git a/packages/starlight/components/Pagination.astro b/packages/starlight/components/Pagination.astro new file mode 100644 index 00000000..2b66ced5 --- /dev/null +++ b/packages/starlight/components/Pagination.astro @@ -0,0 +1,74 @@ +--- +import { useTranslations } from '../utils/translations'; +import Icon from '../user-components/Icon.astro'; +import type { Props } from '../props'; + +const { dir, locale, pagination } = Astro.props; +const { prev, next } = pagination; +const isRtl = dir === 'rtl'; +const t = useTranslations(locale); +--- + +<div class="pagination-links sl-flex" dir={dir}> + { + prev && ( + <a href={prev.href} rel="prev"> + <Icon name={isRtl ? 'right-arrow' : 'left-arrow'} size="1.5rem" /> + <span> + {t('page.previousLink')} + <br /> + <span class="link-title">{prev.label}</span> + </span> + </a> + ) + } + { + next && ( + <a href={next.href} rel="next"> + <Icon name={isRtl ? 'left-arrow' : 'right-arrow'} size="1.5rem" /> + <span> + {t('page.nextLink')} + <br /> + <span class="link-title">{next.label}</span> + </span> + </a> + ) + } +</div> + +<style> + .pagination-links { + flex-wrap: wrap; + gap: 1rem; + } + + a { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + flex-basis: calc(50% - 0.5rem); + flex-grow: 1; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + padding: 1rem; + text-decoration: none; + color: var(--sl-color-gray-2); + box-shadow: var(--sl-shadow-md); + } + [rel='next'] { + justify-content: end; + text-align: end; + flex-direction: row-reverse; + } + a:hover { + border-color: var(--sl-color-gray-2); + } + + .link-title { + color: var(--sl-color-white); + font-size: var(--sl-text-2xl); + line-height: var(--sl-line-height-headings); + } +</style> diff --git a/packages/starlight/components/PrevNextLinks.astro b/packages/starlight/components/PrevNextLinks.astro deleted file mode 100644 index d18f1557..00000000 --- a/packages/starlight/components/PrevNextLinks.astro +++ /dev/null @@ -1,80 +0,0 @@ ---- -import type { Link } from '../utils/navigation'; -import { useTranslations } from '../utils/translations'; -import Icon from '../user-components/Icon.astro'; - -interface Props { - prev: Link | undefined; - next: Link | undefined; - dir: 'ltr' | 'rtl'; - locale: string | undefined; -} - -const { prev, next, dir, locale } = Astro.props; -const isRtl = dir === 'rtl'; -const t = useTranslations(locale); ---- - -<div class="pagination-links sl-flex" dir={dir}> - { - prev && ( - <a href={prev.href} rel="prev"> - <Icon name={isRtl ? 'right-arrow' : 'left-arrow'} size="1.5rem" /> - <span> - {t('page.previousLink')} - <br /> - <span class="link-title">{prev.label}</span> - </span> - </a> - ) - } - { - next && ( - <a href={next.href} rel="next"> - <Icon name={isRtl ? 'left-arrow' : 'right-arrow'} size="1.5rem" /> - <span> - {t('page.nextLink')} - <br /> - <span class="link-title">{next.label}</span> - </span> - </a> - ) - } -</div> - -<style> - .pagination-links { - flex-wrap: wrap; - gap: 1rem; - } - - a { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 0.5rem; - width: 100%; - flex-basis: calc(50% - 0.5rem); - flex-grow: 1; - border: 1px solid var(--sl-color-gray-5); - border-radius: 0.5rem; - padding: 1rem; - text-decoration: none; - color: var(--sl-color-gray-2); - box-shadow: var(--sl-shadow-md); - } - [rel='next'] { - justify-content: end; - text-align: end; - flex-direction: row-reverse; - } - a:hover { - border-color: var(--sl-color-gray-2); - } - - .link-title { - color: var(--sl-color-white); - font-size: var(--sl-text-2xl); - line-height: var(--sl-line-height-headings); - } -</style> diff --git a/packages/starlight/components/RightSidebar.astro b/packages/starlight/components/RightSidebar.astro deleted file mode 100644 index 142a7f0f..00000000 --- a/packages/starlight/components/RightSidebar.astro +++ /dev/null @@ -1,36 +0,0 @@ ---- -import type { MarkdownHeading } from 'astro'; -import RightSidebarPanel from './RightSidebarPanel.astro'; -import MobileTableOfContents from './TableOfContents/MobileTableOfContents.astro'; -import TableOfContents from './TableOfContents.astro'; -import { generateToC } from './TableOfContents/generateToC'; -import { useTranslations } from '../utils/translations'; - -interface Props { - headings: MarkdownHeading[]; - locale: string | undefined; - tocConfig: { maxHeadingLevel: number; minHeadingLevel: number } | false; -} - -const { headings, locale, tocConfig } = Astro.props; -const t = useTranslations(locale); -const tocProps = tocConfig && { - ...tocConfig, - locale, - toc: generateToC(headings, { - ...tocConfig, - title: t('tableOfContents.overview'), - }), -}; ---- - -{ - tocProps && ( - <> - <MobileTableOfContents {...tocProps} /> - <RightSidebarPanel> - <TableOfContents {...tocProps} /> - </RightSidebarPanel> - </> - ) -} diff --git a/packages/starlight/components/RightSidebarPanel.astro b/packages/starlight/components/RightSidebarPanel.astro deleted file mode 100644 index bc4244f2..00000000 --- a/packages/starlight/components/RightSidebarPanel.astro +++ /dev/null @@ -1,46 +0,0 @@ -<div class="right-sidebar-panel sl-hidden lg:sl-block"> - <div class="sl-container"> - <slot /> - </div> -</div> - -<style> - .right-sidebar-panel { - padding: 1rem var(--sl-sidebar-pad-x); - } - .right-sidebar-panel + .right-sidebar-panel { - border-top: 1px solid var(--sl-color-hairline); - } - .sl-container { - width: calc(var(--sl-sidebar-width) - 2 * var(--sl-sidebar-pad-x)); - } - .right-sidebar-panel :global(h2) { - color: var(--sl-color-white); - font-size: var(--sl-text-h5); - font-weight: 600; - line-height: var(--sl-line-height-headings); - margin-bottom: 0.5rem; - } - .right-sidebar-panel :global(a) { - display: block; - font-size: var(--sl-text-xs); - text-decoration: none; - color: var(--sl-color-gray-3); - overflow-wrap: anywhere; - } - .right-sidebar-panel :global(a:hover) { - color: var(--sl-color-white); - } - @media (min-width: 72rem) { - .sl-container { - max-width: calc( - ( - ( - 100vw - var(--sl-sidebar-width) - 2 * var(--sl-content-pad-x) - 2 * - var(--sl-sidebar-pad-x) - ) * 0.25 /* MAGIC NUMBER 🥲 */ - ) - ); - } - } -</style> diff --git a/packages/starlight/components/Search.astro b/packages/starlight/components/Search.astro index f5417b60..e88e5986 100644 --- a/packages/starlight/components/Search.astro +++ b/packages/starlight/components/Search.astro @@ -2,10 +2,7 @@ import '@pagefind/default-ui/css/ui.css'; import { useTranslations } from '../utils/translations'; import Icon from '../user-components/Icon.astro'; - -interface Props { - locale: string | undefined; -} +import type { Props } from '../props'; const t = useTranslations(Astro.props.locale); const pagefindTranslations = { @@ -129,6 +126,9 @@ const pagefindTranslations = { </script> <style> + site-search { + display: contents; + } button[data-open-modal] { display: flex; align-items: center; diff --git a/packages/starlight/components/Sidebar.astro b/packages/starlight/components/Sidebar.astro index 7b898e91..00374ee0 100644 --- a/packages/starlight/components/Sidebar.astro +++ b/packages/starlight/components/Sidebar.astro @@ -1,42 +1,13 @@ --- -import type { getSidebar } from '../utils/navigation'; -import LanguageSelect from './LanguageSelect.astro'; -import SidebarSublist from './SidebarSublist.astro'; -import ThemeSelect from './ThemeSelect.astro'; +import type { Props } from '../props'; -interface Props { - sidebar: ReturnType<typeof getSidebar>; - locale: string | undefined; -} +import { MobileMenuFooter } from 'virtual:starlight/components'; +import SidebarSublist from './SidebarSublist.astro'; -const { sidebar, locale } = Astro.props; +const { sidebar } = Astro.props; --- -<div class="sidebar sl-flex"> - <SidebarSublist sublist={sidebar} /> - <div class="mobile-preferences sl-flex md:sl-hidden"> - <ThemeSelect {locale} /> - <LanguageSelect {locale} /> - </div> +<SidebarSublist sublist={sidebar} /> +<div class="md:sl-hidden"> + <MobileMenuFooter {...Astro.props} /> </div> - -<style> - .sidebar { - height: 100%; - padding: 1rem var(--sl-sidebar-pad-x); - flex-direction: column; - gap: 1rem; - } - - .mobile-preferences { - justify-content: space-between; - border-top: 1px solid var(--sl-color-gray-6); - padding: 0.5rem 0; - } - - @media (min-width: 50rem) { - .sidebar > :global(:nth-last-child(2)) { - padding-bottom: 1rem; - } - } -</style> diff --git a/packages/starlight/components/SiteTitle.astro b/packages/starlight/components/SiteTitle.astro index b4cf4c90..bb1c2038 100644 --- a/packages/starlight/components/SiteTitle.astro +++ b/packages/starlight/components/SiteTitle.astro @@ -2,26 +2,7 @@ import { logos } from 'virtual:starlight/user-images'; import config from 'virtual:starlight/user-config'; import { pathWithBase } from '../utils/base'; - -interface Props { - locale: string | undefined; -} - -if (config.logo) { - let err: string | undefined; - if ('src' in config.logo) { - if (!logos.dark || !logos.light) { - err = `Could not resolve logo import for "${config.logo.src}" (logo.src)`; - } - } else { - if (!logos.dark) { - err = `Could not resolve logo import for "${config.logo.dark}" (logo.dark)`; - } else if (!logos.light) { - err = `Could not resolve logo import for "${config.logo.light}" (logo.light)`; - } - } - if (err) throw new Error(err); -} +import type { Props } from '../props'; const href = pathWithBase(Astro.props.locale || '/'); --- diff --git a/packages/starlight/components/SkipLink.astro b/packages/starlight/components/SkipLink.astro index 3363dbe8..79de547c 100644 --- a/packages/starlight/components/SkipLink.astro +++ b/packages/starlight/components/SkipLink.astro @@ -1,14 +1,12 @@ --- +import { PAGE_TITLE_ID } from '../constants'; import { useTranslations } from '../utils/translations'; - -interface Props { - locale: string | undefined; -} +import type { Props } from '../props'; const t = useTranslations(Astro.props.locale); --- -<a href="#_top">{t('skipLink.label')}</a> +<a href={`#${PAGE_TITLE_ID}`}>{t('skipLink.label')}</a> <style> a { diff --git a/packages/starlight/components/SocialIcons.astro b/packages/starlight/components/SocialIcons.astro index 4f5803a3..75625255 100644 --- a/packages/starlight/components/SocialIcons.astro +++ b/packages/starlight/components/SocialIcons.astro @@ -1,49 +1,22 @@ --- import config from 'virtual:starlight/user-config'; import Icon from '../user-components/Icon.astro'; +import type { Props } from '../props'; type Platform = keyof NonNullable<typeof config.social>; - -const labels: Record<Platform, string> = { - github: 'GitHub', - gitlab: 'GitLab', - bitbucket: 'Bitbucket', - discord: 'Discord', - gitter: 'Gitter', - twitter: 'Twitter', - mastodon: 'Mastodon', - codeberg: 'Codeberg', - codePen: 'CodePen', - youtube: 'YouTube', - threads: 'Threads', - linkedin: 'LinkedIn', - twitch: 'Twitch', - microsoftTeams: 'Microsoft Teams', - instagram: 'Instagram', - stackOverflow: 'Stack Overflow', - 'x.com': 'X', - telegram: 'Telegram', - rss: 'RSS', - facebook: 'Facebook', - email: 'Email', -}; - -const links = Object.entries(config.social || {}).filter(([, url]) => Boolean(url)) as [ - platform: Platform, - url: string, -][]; +type SocialConfig = NonNullable<NonNullable<typeof config.social>[Platform]>; +const links = Object.entries(config.social || {}) as [Platform, SocialConfig][]; --- { links.length > 0 && ( <> - {links.map(([platform, url]) => ( + {links.map(([platform, { label, url }]) => ( <a href={url} rel="me" class="sl-flex"> - <span class="sr-only">{labels[platform]}</span> + <span class="sr-only">{label}</span> <Icon name={platform} /> </a> ))} - <div class="divider" /> </> ) } @@ -55,8 +28,4 @@ const links = Object.entries(config.social || {}).filter(([, url]) => Boolean(ur a:hover { opacity: 0.66; } - .divider { - height: 2rem; - border-inline-end: 1px solid var(--sl-color-gray-5); - } </style> diff --git a/packages/starlight/components/TableOfContents.astro b/packages/starlight/components/TableOfContents.astro index 570305af..4b0f1d11 100644 --- a/packages/starlight/components/TableOfContents.astro +++ b/packages/starlight/components/TableOfContents.astro @@ -1,24 +1,21 @@ --- import { useTranslations } from '../utils/translations'; import TableOfContentsList from './TableOfContents/TableOfContentsList.astro'; -import type { TocItem } from './TableOfContents/generateToC'; +import type { Props } from '../props'; -interface Props { - toc: TocItem[]; - locale: string | undefined; - maxHeadingLevel: number; - minHeadingLevel: number; -} - -const { locale, toc, maxHeadingLevel, minHeadingLevel } = Astro.props; +const { locale, toc } = Astro.props; const t = useTranslations(locale); --- -<starlight-toc data-min-h={minHeadingLevel} data-max-h={maxHeadingLevel}> - <nav aria-labelledby="starlight__on-this-page"> - <h2 id="starlight__on-this-page">{t('tableOfContents.onThisPage')}</h2> - <TableOfContentsList {toc} /> - </nav> -</starlight-toc> +{ + toc && ( + <starlight-toc data-min-h={toc.minHeadingLevel} data-max-h={toc.maxHeadingLevel}> + <nav aria-labelledby="starlight__on-this-page"> + <h2 id="starlight__on-this-page">{t('tableOfContents.onThisPage')}</h2> + <TableOfContentsList toc={toc.items} /> + </nav> + </starlight-toc> + ) +} <script src="./TableOfContents/starlight-toc"></script> diff --git a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro b/packages/starlight/components/TableOfContents/MobileTableOfContents.astro deleted file mode 100644 index 34fe1d5e..00000000 --- a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro +++ /dev/null @@ -1,154 +0,0 @@ ---- -import { useTranslations } from '../../utils/translations'; -import Icon from '../../user-components/Icon.astro'; -import TableOfContentsList from './TableOfContentsList.astro'; -import type { TocItem } from './generateToC'; - -interface Props { - toc: TocItem[]; - locale: string | undefined; - maxHeadingLevel: number; - minHeadingLevel: number; -} - -const { locale, toc, maxHeadingLevel, minHeadingLevel } = Astro.props; -const t = useTranslations(locale); ---- - -<mobile-starlight-toc data-min-h={minHeadingLevel} data-max-h={maxHeadingLevel}> - <nav aria-labelledby="starlight__on-this-page--mobile" class="lg:sl-hidden"> - <details id="starlight__mobile-toc"> - <summary id="starlight__on-this-page--mobile" class="sl-flex"> - <div class="toggle sl-flex"> - {t('tableOfContents.onThisPage')} - <Icon name={'right-caret'} class="caret" size="1rem" /> - </div> - <span class="display-current">{toc[0]?.text}</span> - </summary> - <div class="dropdown"> - <TableOfContentsList toc={toc} isMobile /> - </div> - </details> - </nav> -</mobile-starlight-toc> - -<style> - nav { - position: fixed; - z-index: var(--sl-z-index-toc); - top: calc(var(--sl-nav-height) - 1px); - inset-inline: 0; - border-top: 1px solid var(--sl-color-gray-5); - background-color: var(--sl-color-bg-nav); - } - @media (min-width: 50rem) { - nav { - inset-inline-start: var(--sl-content-inline-start, 0); - } - } - - summary { - gap: 0.5rem; - align-items: center; - height: var(--sl-mobile-toc-height); - border-bottom: 1px solid var(--sl-color-hairline-shade); - padding: 0.5rem 1rem; - font-size: var(--sl-text-xs); - outline-offset: var(--sl-outline-offset-inside); - } - summary::marker, - summary::-webkit-details-marker { - display: none; - } - - .toggle { - flex-shrink: 0; - gap: 1rem; - align-items: center; - justify-content: space-between; - border: 1px solid var(--sl-color-gray-5); - border-radius: 0.5rem; - padding-block: 0.5rem; - padding-inline-start: 0.75rem; - padding-inline-end: 0.5rem; - line-height: 1; - background-color: var(--sl-color-black); - user-select: none; - cursor: pointer; - } - details[open] .toggle { - color: var(--sl-color-white); - border-color: var(--sl-color-accent); - } - details .toggle:hover { - color: var(--sl-color-white); - border-color: var(--sl-color-gray-2); - } - - :global([dir='rtl']) .caret { - transform: rotateZ(180deg); - } - details[open] .caret { - transform: rotateZ(90deg); - } - - .display-current { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - color: var(--sl-color-white); - } - - .dropdown { - --border-top: 1px; - margin-top: calc(-1 * var(--border-top)); - border: var(--border-top) solid var(--sl-color-gray-6); - border-top-color: var(--sl-color-hairline-shade); - max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height)); - overflow-y: auto; - background-color: var(--sl-color-black); - box-shadow: var(--sl-shadow-md); - } -</style> - -<script> - import { StarlightTOC } from './starlight-toc'; - - class MobileStarlightTOC extends StarlightTOC { - override set current(link: HTMLAnchorElement) { - super.current = link; - const display = this.querySelector('.display-current') as HTMLSpanElement; - if (display) display.textContent = link.textContent; - } - - constructor() { - super(); - const details = this.querySelector('details'); - if (!details) return; - const closeToC = () => { - details.open = false; - }; - // Close the table of contents whenever a link is clicked. - details.querySelectorAll('a').forEach((a) => { - a.addEventListener('click', closeToC); - }); - // Close the table of contents when a user clicks outside of it. - window.addEventListener('click', (e) => { - if (!details.contains(e.target as Node)) closeToC(); - }); - // Or when they press the escape key. - window.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && details.open) { - const hasFocus = details.contains(document.activeElement); - closeToC(); - if (hasFocus) { - const summary = details.querySelector('summary'); - if (summary) summary.focus(); - } - } - }); - } - } - - customElements.define('mobile-starlight-toc', MobileStarlightTOC); -</script> diff --git a/packages/starlight/components/TableOfContents/TableOfContentsList.astro b/packages/starlight/components/TableOfContents/TableOfContentsList.astro index 7c290a1e..db370b61 100644 --- a/packages/starlight/components/TableOfContents/TableOfContentsList.astro +++ b/packages/starlight/components/TableOfContents/TableOfContentsList.astro @@ -1,5 +1,5 @@ --- -import type { TocItem } from './generateToC'; +import type { TocItem } from '../../utils/generateToC'; interface Props { toc: TocItem[]; diff --git a/packages/starlight/components/TableOfContents/generateToC.ts b/packages/starlight/components/TableOfContents/generateToC.ts deleted file mode 100644 index 73c08e3d..00000000 --- a/packages/starlight/components/TableOfContents/generateToC.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { MarkdownHeading } from 'astro'; - -export interface TocItem extends MarkdownHeading { - children: TocItem[]; -} - -interface TocOpts { - minHeadingLevel: number; - maxHeadingLevel: number; - title: string; -} - -/** Convert the flat headings array generated by Astro into a nested tree structure. */ -export function generateToC( - headings: MarkdownHeading[], - { minHeadingLevel, maxHeadingLevel, title }: TocOpts -) { - headings = headings.filter(({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel); - const toc: Array<TocItem> = [{ depth: 2, slug: '_top', text: title, children: [] }]; - for (const heading of headings) injectChild(toc, { ...heading, children: [] }); - return toc; -} - -/** Inject a ToC entry as deep in the tree as its `depth` property requires. */ -function injectChild(items: TocItem[], item: TocItem): void { - const lastItem = items.at(-1); - if (!lastItem || lastItem.depth >= item.depth) { - items.push(item); - } else { - return injectChild(lastItem.children, item); - } -} diff --git a/packages/starlight/components/TableOfContents/starlight-toc.ts b/packages/starlight/components/TableOfContents/starlight-toc.ts index 4283f361..a5de15bd 100644 --- a/packages/starlight/components/TableOfContents/starlight-toc.ts +++ b/packages/starlight/components/TableOfContents/starlight-toc.ts @@ -1,3 +1,5 @@ +import { PAGE_TITLE_ID } from '../../constants'; + export class StarlightTOC extends HTMLElement { private _current = this.querySelector('a[aria-current="true"]') as HTMLAnchorElement | null; private minH = parseInt(this.dataset.minH || '2', 10); @@ -20,7 +22,7 @@ export class StarlightTOC extends HTMLElement { const isHeading = (el: Element): el is HTMLHeadingElement => { if (el instanceof HTMLHeadingElement) { // Special case for page title h1 - if ('pageTitle' in el.dataset) return true; + if (el.id === PAGE_TITLE_ID) return true; // Check the heading level is within the user-configured limits for the ToC const level = el.tagName[1]; if (level) { diff --git a/packages/starlight/components/ThemeProvider.astro b/packages/starlight/components/ThemeProvider.astro index 8751721f..759ebc5f 100644 --- a/packages/starlight/components/ThemeProvider.astro +++ b/packages/starlight/components/ThemeProvider.astro @@ -1,4 +1,5 @@ --- +import type { Props } from '../props'; import Icon from '../user-components/Icon.astro'; --- diff --git a/packages/starlight/components/ThemeSelect.astro b/packages/starlight/components/ThemeSelect.astro index 0146e09e..7d8e3fd4 100644 --- a/packages/starlight/components/ThemeSelect.astro +++ b/packages/starlight/components/ThemeSelect.astro @@ -1,10 +1,7 @@ --- import { useTranslations } from '../utils/translations'; import Select from './Select.astro'; - -interface Props { - locale: string | undefined; -} +import type { Props } from '../props'; const t = useTranslations(Astro.props.locale); --- diff --git a/packages/starlight/components/TwoColumnContent.astro b/packages/starlight/components/TwoColumnContent.astro new file mode 100644 index 00000000..5eefb3e7 --- /dev/null +++ b/packages/starlight/components/TwoColumnContent.astro @@ -0,0 +1,56 @@ +--- +import type { Props } from '../props'; +--- + +<div class="lg:sl-flex"> + { + Astro.props.toc && ( + <aside class="right-sidebar-container"> + <div class="right-sidebar"> + <slot name="right-sidebar" /> + </div> + </aside> + ) + } + <div class="main-pane"><slot /></div> +</div> + +<style> + .main-pane { + isolation: isolate; + } + + @media (min-width: 72rem) { + .right-sidebar-container { + order: 2; + position: relative; + width: calc( + var(--sl-sidebar-width) + (100% - var(--sl-content-width) - var(--sl-sidebar-width)) / 2 + ); + } + + .right-sidebar { + position: fixed; + top: 0; + border-inline-start: 1px solid var(--sl-color-gray-6); + padding-top: var(--sl-nav-height); + width: 100%; + height: 100vh; + overflow-y: auto; + scrollbar-width: none; + } + + .main-pane { + width: 100%; + } + + :global([data-has-sidebar][data-has-toc]) .main-pane { + --sl-content-margin-inline: auto 0; + + order: 1; + width: calc( + var(--sl-content-width) + (100% - var(--sl-content-width) - var(--sl-sidebar-width)) / 2 + ); + } + } +</style> diff --git a/packages/starlight/constants.ts b/packages/starlight/constants.ts new file mode 100644 index 00000000..f679b6ae --- /dev/null +++ b/packages/starlight/constants.ts @@ -0,0 +1,4 @@ +// N.B. THIS FILE IS IMPORTED IN BOTH SERVER- AND CLIENT-SIDE CODE. +// THINK TWICE BEFORE ADDING STUFF AS IT WILL GET SHIPPED TO THE CLIENT. + +export const PAGE_TITLE_ID = '_top'; diff --git a/packages/starlight/index.astro b/packages/starlight/index.astro index d480f420..65128ae8 100644 --- a/packages/starlight/index.astro +++ b/packages/starlight/index.astro @@ -1,8 +1,9 @@ --- import type { InferGetStaticPropsType } from 'astro'; +import { generateRouteData } from './utils/route-data'; import { paths } from './utils/routing'; -import Page from './layout/Page.astro'; +import Page from './components/Page.astro'; export async function getStaticPaths() { return paths; @@ -10,6 +11,7 @@ export async function getStaticPaths() { type Props = InferGetStaticPropsType<typeof getStaticPaths>; const { Content, headings } = await Astro.props.entry.render(); +const route = generateRouteData({ props: { ...Astro.props, headings }, url: Astro.url }); --- -<Page {...Astro.props} {headings}><Content /></Page> +<Page {...route}><Content /></Page> diff --git a/packages/starlight/integrations/virtual-user-config.ts b/packages/starlight/integrations/virtual-user-config.ts index 86c2b2a4..f8b8cd21 100644 --- a/packages/starlight/integrations/virtual-user-config.ts +++ b/packages/starlight/integrations/virtual-user-config.ts @@ -29,6 +29,9 @@ export function vitePluginStarlightUserConfig( opts.logo.light )}; export const logos = { dark, light };` : 'export const logos = {};', + 'virtual:starlight/components': Object.entries(opts.components) + .map(([name, path]) => `export { default as ${name} } from ${resolveId(path)};`) + .join(''), } satisfies Record<string, string>; /** Mapping names prefixed with `\0` to their original form. */ diff --git a/packages/starlight/layout/Page.astro b/packages/starlight/layout/Page.astro deleted file mode 100644 index 0999122d..00000000 --- a/packages/starlight/layout/Page.astro +++ /dev/null @@ -1,132 +0,0 @@ ---- -import config from 'virtual:starlight/user-config'; -import type { MarkdownHeading } from 'astro'; -import { getSidebar } from '../utils/navigation'; -import type { Route } from '../utils/routing'; - -// Built-in CSS styles. -import '../style/props.css'; -import '../style/reset.css'; -import '../style/shiki.css'; -import '../style/util.css'; - -// Components — can override built-in CSS, but not user CSS. -import ContentPanel from '../components/ContentPanel.astro'; -import FallbackContentNotice from '../components/FallbackContentNotice.astro'; -import Footer from '../components/Footer.astro'; -import HeadSEO from '../components/HeadSEO.astro'; -import Header from '../components/Header.astro'; -import Hero from '../components/Hero.astro'; -import MarkdownContent from '../components/MarkdownContent.astro'; -import RightSidebar from '../components/RightSidebar.astro'; -import Sidebar from '../components/Sidebar.astro'; -import SkipLink from '../components/SkipLink.astro'; -import ThemeProvider from '../components/ThemeProvider.astro'; -import PageFrame from '../layout/PageFrame.astro'; -import TwoColumnContent from '../layout/TwoColumnContent.astro'; -import Banner from '../components/Banner.astro'; - -// Remark component CSS (needs to override `MarkdownContent.astro`) -import '../style/asides.css'; - -// Important that this is the last import so it can override built-in styles. -import 'virtual:starlight/user-css'; - -type Props = Route & { headings: MarkdownHeading[] }; - -const { dir, entry, entryMeta, headings, isFallback, lang, locale } = Astro.props; -const sidebar = getSidebar(Astro.url.pathname, locale); - -const hasSidebar = entry.data.template !== 'splash'; -const tocConfig = !hasSidebar - ? false - : entry.data.tableOfContents !== undefined - ? entry.data.tableOfContents - : config.tableOfContents; -const hasToC = Boolean(tocConfig); -const hasHero = Boolean(entry.data.hero); -const pagefindEnabled = - entry.slug !== '404' && !entry.slug.endsWith('/404') && entry.data.pagefind !== false; ---- - -<html - lang={lang} - dir={dir} - data-has-toc={hasToC} - data-has-sidebar={hasSidebar} - data-has-hero={hasHero} -> - <head> - <HeadSEO data={entry.data} lang={lang} /> - <style> - html:not([data-has-toc]) { - --sl-mobile-toc-height: 0rem; - } - html:not([data-has-sidebar]) { - --sl-content-width: 67.5rem; - } - /* Add scroll padding to ensure anchor headings aren't obscured by nav */ - html { - /* Additional padding is needed to account for the mobile TOC */ - scroll-padding-top: calc(1.5rem + var(--sl-nav-height) + var(--sl-mobile-toc-height)); - } - main { - padding-bottom: 3vh; - } - @media (min-width: 50em) { - [data-has-sidebar] { - --sl-content-inline-start: var(--sl-sidebar-width); - } - } - @media (min-width: 72em) { - html { - scroll-padding-top: calc(1.5rem + var(--sl-nav-height)); - } - } - </style> - </head> - <body> - <ThemeProvider /> - <SkipLink {locale} /> - <PageFrame {locale} {hasSidebar}> - <Header slot="header" {locale} /> - {hasSidebar && <Sidebar slot="sidebar" {sidebar} {locale} />} - <TwoColumnContent {hasToC}> - <RightSidebar slot="right-sidebar" {headings} {locale} {tocConfig} /> - <main data-pagefind-body={pagefindEnabled} lang={entryMeta.lang} dir={entryMeta.dir}> - {/* TODO: Revisit how this logic flows. */} - {entry.data.banner && <Banner {...entry.data.banner} />} - { - entry.data.hero ? ( - <ContentPanel> - <Hero hero={entry.data.hero} fallbackTitle={entry.data.title} /> - <MarkdownContent> - <slot /> - </MarkdownContent> - </ContentPanel> - ) : ( - <> - <ContentPanel> - <h1 - id="_top" - data-page-title - style="font-size: var(--sl-text-h1); line-height: var(--sl-line-height-headings); font-weight: 600; color: var(--sl-color-white); margin-top: 1rem;" - > - {entry.data.title} - </h1> - {isFallback && <FallbackContentNotice {locale} />} - </ContentPanel> - <ContentPanel> - <MarkdownContent> - <slot /> - </MarkdownContent> - <Footer {...{ entry, dir, lang, locale, sidebar }} /> - </ContentPanel> - </> - ) - } - </main> - </TwoColumnContent> - </PageFrame> - </body> -</html> diff --git a/packages/starlight/layout/PageFrame.astro b/packages/starlight/layout/PageFrame.astro deleted file mode 100644 index f0f1f7c9..00000000 --- a/packages/starlight/layout/PageFrame.astro +++ /dev/null @@ -1,90 +0,0 @@ ---- -import MobileMenuToggle from '../components/MobileMenuToggle.astro'; -import { useTranslations } from '../utils/translations'; - -interface Props { - hasSidebar: boolean; - locale: string | undefined; -} - -const { hasSidebar, locale } = Astro.props; -const t = useTranslations(locale); ---- - -<div class="page sl-flex"> - <header class="header"><slot name="header" /></header> - { - hasSidebar && ( - <nav class="sidebar" aria-label={t('sidebarNav.accessibleLabel')}> - <MobileMenuToggle {locale} /> - <div id="starlight__sidebar" class="sidebar-pane"> - <div class="sidebar-content"> - <slot name="sidebar" /> - </div> - </div> - </nav> - ) - } - <div class="main-frame"><slot /></div> -</div> - -<style> - .page { - flex-direction: column; - min-height: 100vh; - } - - .header { - z-index: var(--sl-z-index-navbar); - position: fixed; - inset-inline-start: 0; - inset-block-start: 0; - width: 100%; - height: var(--sl-nav-height); - border-bottom: 1px solid var(--sl-color-hairline-shade); - padding: var(--sl-nav-pad-y) var(--sl-nav-pad-x); - padding-inline-end: var(--sl-nav-pad-x); - background-color: var(--sl-color-bg-nav); - } - - :global([data-has-sidebar]) .header { - padding-inline-end: calc(var(--sl-nav-gap) + var(--sl-nav-pad-x) + var(--sl-menu-button-size)); - } - - .sidebar-pane { - visibility: var(--sl-sidebar-visibility, hidden); - position: fixed; - z-index: var(--sl-z-index-menu); - inset-block: 0; - inset-inline-start: 0; - padding-top: var(--sl-nav-height); - width: 100%; - background-color: var(--sl-color-black); - } - - :global([aria-expanded='true']) ~ .sidebar-pane { - --sl-sidebar-visibility: visible; - } - - .sidebar-content { - height: 100%; - overflow-y: auto; - } - - .main-frame { - padding-top: calc(var(--sl-nav-height) + var(--sl-mobile-toc-height)); - padding-inline-start: var(--sl-content-inline-start); - } - - @media (min-width: 50rem) { - :global([data-has-sidebar]) .header { - padding-inline-end: var(--sl-nav-pad-x); - } - .sidebar-pane { - --sl-sidebar-visibility: visible; - width: var(--sl-sidebar-width); - background-color: var(--sl-color-bg-sidebar); - border-inline-end: 1px solid var(--sl-color-hairline-shade); - } - } -</style> diff --git a/packages/starlight/layout/TwoColumnContent.astro b/packages/starlight/layout/TwoColumnContent.astro deleted file mode 100644 index 381632fb..00000000 --- a/packages/starlight/layout/TwoColumnContent.astro +++ /dev/null @@ -1,58 +0,0 @@ ---- -interface Props { - hasToC: boolean; -} ---- - -<div class="lg:sl-flex"> - { - Astro.props.hasToC && ( - <aside class="right-sidebar-container"> - <div class="right-sidebar"> - <slot name="right-sidebar" /> - </div> - </aside> - ) - } - <div class="main-pane"><slot /></div> -</div> - -<style> - .main-pane { - isolation: isolate; - } - - @media (min-width: 72rem) { - .right-sidebar-container { - order: 2; - position: relative; - width: calc( - var(--sl-sidebar-width) + (100% - var(--sl-content-width) - var(--sl-sidebar-width)) / 2 - ); - } - - .right-sidebar { - position: fixed; - top: 0; - border-inline-start: 1px solid var(--sl-color-gray-6); - padding-top: var(--sl-nav-height); - width: 100%; - height: 100vh; - overflow-y: auto; - scrollbar-width: none; - } - - .main-pane { - width: 100%; - } - - :global([data-has-sidebar][data-has-toc]) .main-pane { - --sl-content-margin-inline: auto 0; - - order: 1; - width: calc( - var(--sl-content-width) + (100% - var(--sl-content-width) - var(--sl-sidebar-width)) / 2 - ); - } - } -</style> diff --git a/packages/starlight/package.json b/packages/starlight/package.json index dc9f928a..f301e36f 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -26,6 +26,135 @@ "exports": { ".": "./index.ts", "./components": "./components.ts", + "./components/Badge.astro": { + "types": "./components/Badge.astro.tsx", + "import": "./components/Badge.astro" + }, + "./components/LanguageSelect.astro": { + "types": "./components/LanguageSelect.astro.tsx", + "import": "./components/LanguageSelect.astro" + }, + "./components/Select.astro": { + "types": "./components/Select.astro.tsx", + "import": "./components/Select.astro" + }, + "./components/Banner.astro": { + "types": "./components/Banner.astro.tsx", + "import": "./components/Banner.astro" + }, + "./components/LastUpdated.astro": { + "types": "./components/LastUpdated.astro.tsx", + "import": "./components/LastUpdated.astro" + }, + "./components/Sidebar.astro": { + "types": "./components/Sidebar.astro.tsx", + "import": "./components/Sidebar.astro" + }, + "./components/CallToAction.astro": { + "types": "./components/CallToAction.astro.tsx", + "import": "./components/CallToAction.astro" + }, + "./components/MarkdownContent.astro": { + "types": "./components/MarkdownContent.astro.tsx", + "import": "./components/MarkdownContent.astro" + }, + "./components/SidebarSublist.astro": { + "types": "./components/SidebarSublist.astro.tsx", + "import": "./components/SidebarSublist.astro" + }, + "./components/ContentPanel.astro": { + "types": "./components/ContentPanel.astro.tsx", + "import": "./components/ContentPanel.astro" + }, + "./components/MobileMenuFooter.astro": { + "types": "./components/MobileMenuFooter.astro.tsx", + "import": "./components/MobileMenuFooter.astro" + }, + "./components/SiteTitle.astro": { + "types": "./components/SiteTitle.astro.tsx", + "import": "./components/SiteTitle.astro" + }, + "./components/EditLink.astro": { + "types": "./components/EditLink.astro.tsx", + "import": "./components/EditLink.astro" + }, + "./components/MobileMenuToggle.astro": { + "types": "./components/MobileMenuToggle.astro.tsx", + "import": "./components/MobileMenuToggle.astro" + }, + "./components/SkipLink.astro": { + "types": "./components/SkipLink.astro.tsx", + "import": "./components/SkipLink.astro" + }, + "./components/MobileTableOfContents.astro": { + "types": "./components/MobileTableOfContents.astro.tsx", + "import": "./components/MobileTableOfContents.astro" + }, + "./components/SocialIcons.astro": { + "types": "./components/SocialIcons.astro.tsx", + "import": "./components/SocialIcons.astro" + }, + "./components/FallbackContentNotice.astro": { + "types": "./components/FallbackContentNotice.astro.tsx", + "import": "./components/FallbackContentNotice.astro" + }, + "./components/Page.astro": { + "types": "./components/Page.astro.tsx", + "import": "./components/Page.astro" + }, + "./components/Footer.astro": { + "types": "./components/Footer.astro.tsx", + "import": "./components/Footer.astro" + }, + "./components/PageFrame.astro": { + "types": "./components/PageFrame.astro.tsx", + "import": "./components/PageFrame.astro" + }, + "./components/TableOfContents.astro": { + "types": "./components/TableOfContents.astro.tsx", + "import": "./components/TableOfContents.astro" + }, + "./components/Head.astro": { + "types": "./components/Head.astro.tsx", + "import": "./components/Head.astro" + }, + "./components/PageSidebar.astro": { + "types": "./components/PageSidebar.astro.tsx", + "import": "./components/PageSidebar.astro" + }, + "./components/ThemeProvider.astro": { + "types": "./components/ThemeProvider.astro.tsx", + "import": "./components/ThemeProvider.astro" + }, + "./components/Header.astro": { + "types": "./components/Header.astro.tsx", + "import": "./components/Header.astro" + }, + "./components/PageTitle.astro": { + "types": "./components/PageTitle.astro.tsx", + "import": "./components/PageTitle.astro" + }, + "./components/ThemeSelect.astro": { + "types": "./components/ThemeSelect.astro.tsx", + "import": "./components/ThemeSelect.astro" + }, + "./components/Hero.astro": { + "types": "./components/Hero.astro.tsx", + "import": "./components/Hero.astro" + }, + "./components/Pagination.astro": { + "types": "./components/Pagination.astro.tsx", + "import": "./components/Pagination.astro" + }, + "./components/TwoColumnContent.astro": { + "types": "./components/TwoColumnContent.astro.tsx", + "import": "./components/TwoColumnContent.astro" + }, + "./components/Search.astro": { + "types": "./components/Search.astro.tsx", + "import": "./components/Search.astro" + }, + "./props": "./props.ts", "./schema": "./schema.ts", "./types": "./types.ts", "./index.astro": "./index.astro", diff --git a/packages/starlight/props.ts b/packages/starlight/props.ts new file mode 100644 index 00000000..76c119e4 --- /dev/null +++ b/packages/starlight/props.ts @@ -0,0 +1 @@ +export type { StarlightRouteData as Props } from './utils/route-data'; diff --git a/packages/starlight/schemas/components.ts b/packages/starlight/schemas/components.ts new file mode 100644 index 00000000..26253031 --- /dev/null +++ b/packages/starlight/schemas/components.ts @@ -0,0 +1,256 @@ +import { z } from 'astro/zod'; + +export function ComponentConfigSchema() { + return z + .object({ + /* + HEAD ---------------------------------------------------------------------------------------- + */ + + /** + * Component rendered inside each page’s `<head>`. + * Includes important tags including `<title>`, and `<meta charset="utf-8">`. + * + * Override this component as a last resort. Prefer the `head` option Starlight config if possible. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Head.astro `Head` default implementation} + */ + Head: z.string().default('@astrojs/starlight/components/Head.astro'), + + /** + * Component rendered inside `<head>` that sets up dark/light theme support. + * The default implementation includes an inline script and a `<template>` used by the + * script in `ThemeSelect.astro`. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/ThemeProvider.astro `ThemeProvider` default implementation} + */ + ThemeProvider: z.string().default('@astrojs/starlight/components/ThemeProvider.astro'), + + /* + BODY ---------------------------------------------------------------------------------------- + */ + + /** + * Component rendered as the first element inside `<body>` which links to the main page + * content for accessibility. The default implementation is hidden until a user focuses it + * by tabbing with their keyboard. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/SkipLink.astro `SkipLink` default implementation} + */ + SkipLink: z.string().default('@astrojs/starlight/components/SkipLink.astro'), + + /* + LAYOUT -------------------------------------------------------------------------------------- + */ + + /** + * Layout component wrapped around most of the page content. + * The default implementation sets up the header–sidebar–main layout and includes + * `header` and `sidebar` named slots along with a default slot for the main content. + * It also renders `<MobileMenuToggle />` to support toggling the sidebar navigation + * on small (mobile) viewports. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageFrame.astro `PageFrame` default implementation} + */ + PageFrame: z.string().default('@astrojs/starlight/components/PageFrame.astro'), + /** + * Component rendered inside `<PageFrame>` that is responsible for toggling the + * sidebar navigation on small (mobile) viewports. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileMenuToggle.astro `MobileMenuToggle` default implementation} + */ + MobileMenuToggle: z.string().default('@astrojs/starlight/components/MobileMenuToggle.astro'), + + /** + * Layout component wrapped around the main content column and right sidebar (table of contents). + * The default implementation handles the switch between a single-column, small-viewport layout + * and a two-column, larger-viewport layout. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/TwoColumnContent.astro `TwoColumnContent` default implementation} + */ + TwoColumnContent: z.string().default('@astrojs/starlight/components/TwoColumnContent.astro'), + + /* + HEADER -------------------------------------------------------------------------------------- + */ + + /** + * Header component displayed at the top of every page. + * The default implementation displays `<SiteTitle />`, `<Search />`, `<SocialIcons />`, + * `<ThemeSelect />`, and `<LanguageSelect />`. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Header.astro `Header` default implementation} + */ + Header: z.string().default('@astrojs/starlight/components/Header.astro'), + /** + * Component rendered at the start of the site header to render the site title. + * The default implementation includes logic for rendering logos defined in Starlight config. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/SiteTitle.astro `SiteTitle` default implementation} + */ + SiteTitle: z.string().default('@astrojs/starlight/components/SiteTitle.astro'), + /** + * Component used to render Starlight’s search UI. The default implementation includes the + * button in the header and the code for displaying a search modal when it is clicked. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Search.astro `Search` default implementation} + */ + Search: z.string().default('@astrojs/starlight/components/Search.astro'), + /** + * Component rendered in the site header including social icon links. The default + * implementation uses the `social` option in Starlight config to render icons and links. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/SocialIcons.astro `SocialIcons` default implementation} + */ + SocialIcons: z.string().default('@astrojs/starlight/components/SocialIcons.astro'), + /** + * Component rendered in the site header that allows users to select their preferred color scheme. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/ThemeSelect.astro `ThemeSelect` default implementation} + */ + ThemeSelect: z.string().default('@astrojs/starlight/components/ThemeSelect.astro'), + /** + * Component rendered in the site header that allows users to switch to a different language. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/LanguageSelect.astro `LanguageSelect` default implementation} + */ + LanguageSelect: z.string().default('@astrojs/starlight/components/LanguageSelect.astro'), + + /* + SIDEBAR ------------------------------------------------------------------------------------- + */ + + /** + * Component rendered before page content that contains global navigation. + * The default implementation displays as a sidebar on wide enough viewports and inside a + * drop-down menu on small (mobile) viewports. It also renders `<MobileMenuFooter />` to + * show additional items inside the mobile menu. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Sidebar.astro `Sidebar` default implementation} + */ + Sidebar: z.string().default('@astrojs/starlight/components/Sidebar.astro'), + /** + * Component rendered at the bottom of the mobile drop-down menu. + * The default implementation renders `<ThemeSelect />` and `<LanguageSelect />`. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileMenuFooter.astro `MobileMenuFooter` default implementation} + */ + MobileMenuFooter: z.string().default('@astrojs/starlight/components/MobileMenuFooter.astro'), + + /* + TOC ----------------------------------------------------------------------------------------- + */ + + /** + * Component rendered before the main page’s content to display a table of contents. + * The default implementation renders `<TableOfContents />` and `<MobileTableOfContents />`. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageSidebar.astro `PageSidebar` default implementation} + */ + PageSidebar: z.string().default('@astrojs/starlight/components/PageSidebar.astro'), + /** + * Component that renders the current page’s table of contents on wider viewports. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/TableOfContents.astro `TableOfContents` default implementation} + */ + TableOfContents: z.string().default('@astrojs/starlight/components/TableOfContents.astro'), + /** + * Component that renders the current page’s table of contents on small (mobile) viewports. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/MobileTableOfContents.astro `MobileTableOfContents` default implementation} + */ + MobileTableOfContents: z + .string() + .default('@astrojs/starlight/components/MobileTableOfContents.astro'), + + /* + CONTENT HEADER ------------------------------------------------------------------------------ + */ + + /** + * Banner component rendered at the top of each page. The default implementation uses the + * page’s `banner` frontmatter value to decide whether or not to render. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Banner.astro `Banner` default implementation} + */ + Banner: z.string().default('@astrojs/starlight/components/Banner.astro'), + + /** + * Layout component used to wrap sections of the main content column. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/ContentPanel.astro `ContentPanel` default implementation} + */ + ContentPanel: z.string().default('@astrojs/starlight/components/ContentPanel.astro'), + + /** + * Component containing the `<h1>` element for the current page. + * + * Implementations should ensure they set `id="_top"` on the `<h1>` element as in the default + * implementation. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/PageTitle.astro `PageTitle` default implementation} + */ + PageTitle: z.string().default('@astrojs/starlight/components/PageTitle.astro'), + + /** + * Notice displayed to users on pages where a translation for the current language is not + * available. Only used on multilingual sites. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/FallbackContentNotice.astro `FallbackContentNotice` default implementation} + */ + FallbackContentNotice: z + .string() + .default('@astrojs/starlight/components/FallbackContentNotice.astro'), + + /** + * Component rendered at the top of the page when `hero` is set in frontmatter. The default + * implementation shows a large title, tagline, and call-to-action links alongside an optional image. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Hero.astro `Hero` default implementation} + */ + Hero: z.string().default('@astrojs/starlight/components/Hero.astro'), + + /* + CONTENT ------------------------------------------------------------------------------------- + */ + + /** + * Component rendered around each page’s main content. + * The default implementation sets up basic styles to apply to Markdown content. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/MarkdownContent.astro `MarkdownContent` default implementation} + */ + MarkdownContent: z.string().default('@astrojs/starlight/components/MarkdownContent.astro'), + + /* + CONTENT FOOTER ------------------------------------------------------------------------------ + */ + + /** + * Footer component displayed at the bottom of each documentation page. + * The default implementation displays `<LastUpdated />`, `<Pagination />`, and `<EditLink />`. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Footer.astro `Footer` default implementation} + */ + Footer: z.string().default('@astrojs/starlight/components/Footer.astro'), + /** + * Component rendered in the page footer to display the last-updated date. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/LastUpdated.astro `LastUpdated` default implementation} + */ + LastUpdated: z.string().default('@astrojs/starlight/components/LastUpdated.astro'), + /** + * Component rendered in the page footer to display navigation arrows between previous/next pages. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/Pagination.astro `Pagination` default implementation} + */ + Pagination: z.string().default('@astrojs/starlight/components/Pagination.astro'), + /** + * Component rendered in the page footer to display a link to where the page can be edited. + * + * @see {@link https://github.com/withastro/starlight/blob/main/packages/starlight/components/EditLink.astro `EditLink` default implementation} + */ + EditLink: z.string().default('@astrojs/starlight/components/EditLink.astro'), + }) + .default({}); +} diff --git a/packages/starlight/schemas/social.ts b/packages/starlight/schemas/social.ts new file mode 100644 index 00000000..f199e766 --- /dev/null +++ b/packages/starlight/schemas/social.ts @@ -0,0 +1,65 @@ +import { z } from 'astro/zod'; + +export const SocialLinksSchema = () => + z + .record( + z.enum([ + 'twitter', + 'mastodon', + 'github', + 'gitlab', + 'bitbucket', + 'discord', + 'gitter', + 'codeberg', + 'codePen', + 'youtube', + 'threads', + 'linkedin', + 'twitch', + 'microsoftTeams', + 'instagram', + 'stackOverflow', + 'x.com', + 'telegram', + 'rss', + 'facebook', + 'email', + ]), + // Link to the respective social profile for this site + z.string().url() + ) + .transform((links) => { + const labelledLinks: Partial<Record<keyof typeof links, { label: string; url: string }>> = {}; + for (const _k in links) { + const key = _k as keyof typeof links; + const url = links[key]; + if (!url) continue; + const label = { + github: 'GitHub', + gitlab: 'GitLab', + bitbucket: 'Bitbucket', + discord: 'Discord', + gitter: 'Gitter', + twitter: 'Twitter', + mastodon: 'Mastodon', + codeberg: 'Codeberg', + codePen: 'CodePen', + youtube: 'YouTube', + threads: 'Threads', + linkedin: 'LinkedIn', + twitch: 'Twitch', + microsoftTeams: 'Microsoft Teams', + instagram: 'Instagram', + stackOverflow: 'Stack Overflow', + 'x.com': 'X', + telegram: 'Telegram', + rss: 'RSS', + facebook: 'Facebook', + email: 'Email', + }[key]; + labelledLinks[key] = { label, url }; + } + return labelledLinks; + }) + .optional(); diff --git a/packages/starlight/utils/generateToC.ts b/packages/starlight/utils/generateToC.ts new file mode 100644 index 00000000..b961ad54 --- /dev/null +++ b/packages/starlight/utils/generateToC.ts @@ -0,0 +1,33 @@ +import type { MarkdownHeading } from 'astro'; +import { PAGE_TITLE_ID } from '../constants'; + +export interface TocItem extends MarkdownHeading { + children: TocItem[]; +} + +interface TocOpts { + minHeadingLevel: number; + maxHeadingLevel: number; + title: string; +} + +/** Convert the flat headings array generated by Astro into a nested tree structure. */ +export function generateToC( + headings: MarkdownHeading[], + { minHeadingLevel, maxHeadingLevel, title }: TocOpts +) { + headings = headings.filter(({ depth }) => depth >= minHeadingLevel && depth <= maxHeadingLevel); + const toc: Array<TocItem> = [{ depth: 2, slug: PAGE_TITLE_ID, text: title, children: [] }]; + for (const heading of headings) injectChild(toc, { ...heading, children: [] }); + return toc; +} + +/** Inject a ToC entry as deep in the tree as its `depth` property requires. */ +function injectChild(items: TocItem[], item: TocItem): void { + const lastItem = items.at(-1); + if (!lastItem || lastItem.depth >= item.depth) { + items.push(item); + } else { + return injectChild(lastItem.children, item); + } +} diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index a8af46b5..c8a45b4a 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -287,7 +287,9 @@ export function getPrevNextLinks( next?: PrevNextLinkConfig; } ): { + /** Link to previous page in the sidebar. */ prev: Link | undefined; + /** Link to next page in the sidebar. */ next: Link | undefined; } { const entries = flattenSidebar(sidebar); diff --git a/packages/starlight/utils/route-data.ts b/packages/starlight/utils/route-data.ts new file mode 100644 index 00000000..ae204f80 --- /dev/null +++ b/packages/starlight/utils/route-data.ts @@ -0,0 +1,97 @@ +import type { MarkdownHeading } from 'astro'; +import { fileURLToPath } from 'node:url'; +import project from 'virtual:starlight/project-context'; +import config from 'virtual:starlight/user-config'; +import { generateToC, type TocItem } from './generateToC'; +import { getFileCommitDate } from './git'; +import { getPrevNextLinks, getSidebar, type SidebarEntry } from './navigation'; +import type { Route } from './routing'; +import { useTranslations } from './translations'; +import { ensureTrailingSlash } from './path'; + +interface PageProps extends Route { + headings: MarkdownHeading[]; +} + +export interface StarlightRouteData extends Route { + /** Array of Markdown headings extracted from the current page. */ + headings: MarkdownHeading[]; + /** Site navigation sidebar entries for this page. */ + sidebar: SidebarEntry[]; + /** Whether or not the sidebar should be displayed on this page. */ + hasSidebar: boolean; + /** Links to the previous and next page in the sidebar if enabled. */ + pagination: ReturnType<typeof getPrevNextLinks>; + /** Table of contents for this page if enabled. */ + toc: { minHeadingLevel: number; maxHeadingLevel: number; items: TocItem[] } | undefined; + /** JS Date object representing when this page was last updated if enabled. */ + lastUpdated: Date | undefined; + /** URL object for the address where this page can be edited if enabled. */ + editUrl: URL | undefined; +} + +export function generateRouteData({ + props, + url, +}: { + props: PageProps; + url: URL; +}): StarlightRouteData { + const { entry, locale } = props; + const sidebar = getSidebar(url.pathname, locale); + return { + ...props, + sidebar, + hasSidebar: entry.data.template !== 'splash', + pagination: getPrevNextLinks(sidebar, config.pagination, entry.data), + toc: getToC(props), + lastUpdated: getLastUpdated(props), + editUrl: getEditUrl(props), + }; +} + +function getToC({ entry, locale, headings }: PageProps) { + const tocConfig = + entry.data.template === 'splash' + ? false + : entry.data.tableOfContents !== undefined + ? entry.data.tableOfContents + : config.tableOfContents; + if (!tocConfig) return; + const t = useTranslations(locale); + return { + ...tocConfig, + items: generateToC(headings, { ...tocConfig, title: t('tableOfContents.overview') }), + }; +} + +function getLastUpdated({ entry, id }: PageProps): Date | undefined { + if (entry.data.lastUpdated ?? config.lastUpdated) { + const currentFilePath = fileURLToPath(new URL('src/content/docs/' + id, project.root)); + let date = typeof entry.data.lastUpdated !== 'boolean' ? entry.data.lastUpdated : undefined; + if (!date) { + try { + ({ date } = getFileCommitDate(currentFilePath, 'newest')); + } catch {} + } + return date; + } + return; +} + +function getEditUrl({ entry, id }: PageProps): URL | undefined { + const { editUrl } = entry.data; + // If frontmatter value is false, editing is disabled for this page. + if (editUrl === false) return; + + let url: string | undefined; + if (typeof editUrl === 'string') { + // If a URL was provided in frontmatter, use that. + url = editUrl; + } else if (config.editLink.baseUrl) { + const srcPath = project.srcDir.replace(project.root, ''); + // If a base URL was added in Starlight config, synthesize the edit URL from it. + url = ensureTrailingSlash(config.editLink.baseUrl) + srcPath + 'content/docs/' + id; + } + return url ? new URL(url) : undefined; +} diff --git a/packages/starlight/utils/routing.ts b/packages/starlight/utils/routing.ts index 496578ce..5b0b7091 100644 --- a/packages/starlight/utils/routing.ts +++ b/packages/starlight/utils/routing.ts @@ -8,16 +8,26 @@ import { slugToLocaleData, slugToParam, } from './slugs'; +import { validateLogoImports } from './validateLogoImports'; + +// Validate any user-provided logos imported correctly. +// We do this here so all pages trigger it and at the top level so it runs just once. +validateLogoImports(); export type StarlightDocsEntry = Omit<CollectionEntry<'docs'>, 'slug'> & { slug: string; }; export interface Route extends LocaleData { + /** Content collection entry for the current page. Includes frontmatter at `data`. */ 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; + /** The slug, a.k.a. permalink, for this page. */ slug: string; + /** The unique ID for this page. */ id: string; + /** True if this page is untranslated in the current language and using fallback content from the default locale. */ isFallback?: true; [key: string]: unknown; } diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index 4cb01293..03e991f6 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -1,10 +1,13 @@ import { z } from 'astro/zod'; import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47'; +import { BadgeConfigSchema } from '../schemas/badge'; +import { ComponentConfigSchema } from '../schemas/components'; +import { FaviconSchema } from '../schemas/favicon'; import { HeadConfigSchema } from '../schemas/head'; import { LogoConfigSchema } from '../schemas/logo'; -import { TableOfContentsSchema } from '../schemas/tableOfContents'; -import { FaviconSchema } from '../schemas/favicon'; import { SidebarItemSchema } from '../schemas/sidebar'; +import { SocialLinksSchema } from '../schemas/social'; +import { TableOfContentsSchema } from '../schemas/tableOfContents'; const LocaleSchema = z.object({ /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */ @@ -60,35 +63,7 @@ const UserConfigSchema = z.object({ * youtube: 'https://youtube.com/@astrodotbuild', * } */ - social: z - .record( - z.enum([ - 'twitter', - 'mastodon', - 'github', - 'gitlab', - 'bitbucket', - 'discord', - 'gitter', - 'codeberg', - 'codePen', - 'youtube', - 'threads', - 'linkedin', - 'twitch', - 'microsoftTeams', - 'instagram', - 'stackOverflow', - 'x.com', - 'telegram', - 'rss', - 'facebook', - 'email', - ]), - // Link to the respective social profile for this site - z.string().url() - ) - .optional(), + social: SocialLinksSchema(), /** The tagline for your website. */ tagline: z.string().optional().describe('The tagline for your website.'), @@ -209,6 +184,9 @@ const UserConfigSchema = z.object({ /** The default favicon for your site which should be a path to an image in the `public/` directory. */ favicon: FaviconSchema(), + /** Specify paths to components that should override Starlight’s default components */ + components: ComponentConfigSchema(), + /** Will be used as title delimiter in the generated `<title>` tag. */ titleDelimiter: z .string() diff --git a/packages/starlight/utils/validateLogoImports.ts b/packages/starlight/utils/validateLogoImports.ts new file mode 100644 index 00000000..773fae98 --- /dev/null +++ b/packages/starlight/utils/validateLogoImports.ts @@ -0,0 +1,21 @@ +import config from 'virtual:starlight/user-config'; +import { logos } from 'virtual:starlight/user-images'; + +/** Check user-imported logo images have resolved correctly. */ +export function validateLogoImports(): void { + if (config.logo) { + let err: string | undefined; + if ('src' in config.logo) { + if (!logos.dark || !logos.light) { + err = `Could not resolve logo import for "${config.logo.src}" (logo.src)`; + } + } else { + if (!logos.dark) { + err = `Could not resolve logo import for "${config.logo.dark}" (logo.dark)`; + } else if (!logos.light) { + err = `Could not resolve logo import for "${config.logo.light}" (logo.light)`; + } + } + if (err) throw new Error(err); + } +} diff --git a/packages/starlight/virtual.d.ts b/packages/starlight/virtual.d.ts index 1230c460..f448489c 100644 --- a/packages/starlight/virtual.d.ts +++ b/packages/starlight/virtual.d.ts @@ -15,3 +15,40 @@ declare module 'virtual:starlight/user-images' { light?: ImageMetadata; }; } + +declare module 'virtual:starlight/components' { + export const Banner: typeof import('./components/Banner.astro').default; + export const ContentPanel: typeof import('./components/ContentPanel.astro').default; + export const PageTitle: typeof import('./components/PageTitle.astro').default; + export const FallbackContentNotice: typeof import('./components/FallbackContentNotice.astro').default; + + export const Footer: typeof import('./components/Footer.astro').default; + export const LastUpdated: typeof import('./components/LastUpdated.astro').default; + export const Pagination: typeof import('./components/Pagination.astro').default; + export const EditLink: typeof import('./components/EditLink.astro').default; + + export const Header: typeof import('./components/Header.astro').default; + export const LanguageSelect: typeof import('./components/LanguageSelect.astro').default; + export const Search: typeof import('./components/Search.astro').default; + export const SiteTitle: typeof import('./components/SiteTitle.astro').default; + export const SocialIcons: typeof import('./components/SocialIcons.astro').default; + export const ThemeSelect: typeof import('./components/ThemeSelect.astro').default; + + export const Head: typeof import('./components/Head.astro').default; + export const Hero: typeof import('./components/Hero.astro').default; + export const MarkdownContent: typeof import('./components/MarkdownContent.astro').default; + + export const PageSidebar: typeof import('./components/PageSidebar.astro').default; + export const TableOfContents: typeof import('./components/TableOfContents.astro').default; + export const MobileTableOfContents: typeof import('./components/MobileTableOfContents.astro').default; + + export const Sidebar: typeof import('./components/Sidebar.astro').default; + export const SkipLink: typeof import('./components/SkipLink.astro').default; + export const ThemeProvider: typeof import('./components/ThemeProvider.astro').default; + + export const PageFrame: typeof import('./components/PageFrame.astro').default; + export const MobileMenuToggle: typeof import('./components/MobileMenuToggle.astro').default; + export const MobileMenuFooter: typeof import('./components/MobileMenuFooter.astro').default; + + export const TwoColumnContent: typeof import('./components/TwoColumnContent.astro').default; +} diff --git a/packages/starlight/vitest.config.ts b/packages/starlight/vitest.config.ts index ab9b2f4f..fe6acc49 100644 --- a/packages/starlight/vitest.config.ts +++ b/packages/starlight/vitest.config.ts @@ -20,7 +20,18 @@ export default defineConfig({ coverage: { all: true, reportsDirectory: './__coverage__', - exclude: [...defaultCoverageExcludes, '**/vitest.*', 'components.ts', 'types.ts'], + exclude: [ + ...defaultCoverageExcludes, + '**/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', + // Types-only export. + 'props.ts', + // Main integration entrypoint — don’t think we’re able to test this directly currently. + 'index.ts', + ], thresholdAutoUpdate: true, lines: 69.21, functions: 90.24, -- cgit