UNPKG

@astrojs/starlight

Version:

Build beautiful, high-performance documentation websites with Astro

216 lines (205 loc) 8.29 kB
import { z } from 'astro/zod'; import { type ContentConfig, type ImageFunction, type SchemaContext } from 'astro:content'; import project from 'virtual:starlight/project-context'; import config from 'virtual:starlight/user-config'; import { getCollectionPathFromRoot } from './collection'; import { parseWithFriendlyErrors, parseAsyncWithFriendlyErrors } from './error-map'; import { stripLeadingAndTrailingSlashes } from './path'; import { getSiteTitle, getSiteTitleHref, getToC, type PageProps, type RouteDataContext, } from './routing/data'; import type { StarlightDocsEntry, StarlightRouteData } from './routing/types'; import { slugToLocaleData, urlToSlug } from './slugs'; import { getPrevNextLinks, getSidebar, getSidebarFromConfig } from './navigation'; import { docsSchema } from '../schema'; import type { Prettify, RemoveIndexSignature } from './types'; import { SidebarItemSchema } from '../schemas/sidebar'; import type { StarlightConfig, StarlightUserConfig } from './user-config'; import { getHead } from './head'; /** * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s * `docs` content collection. * The frontmatter schema for Starlight pages cannot include some properties which will be omitted * and some others needs to be refined to a stricter type. */ const StarlightPageFrontmatterSchema = async (context: SchemaContext) => { const userDocsSchema = await getUserDocsSchema(); const schema = typeof userDocsSchema === 'function' ? userDocsSchema(context) : userDocsSchema; return schema.transform((frontmatter) => { /** * Starlight pages can only be edited if an edit URL is explicitly provided. * The `sidebar` frontmatter prop only works for pages in an autogenerated links group. * Starlight pages edit links cannot be autogenerated. * * These changes to the schema are done using a transformer and not using the usual `omit` * method because when the frontmatter schema is extended by the user, an intersection between * the default schema and the user schema is created using the `and` method. Intersections in * Zod returns a `ZodIntersection` object which does not have some methods like `omit` or * `pick`. * * This transformer only sets the `editUrl` default value and removes the `sidebar` property * from the validated output but does not apply any changes to the input schema type itself so * this needs to be done manually. * * @see StarlightPageFrontmatter * @see https://github.com/colinhacks/zod#intersections */ const { editUrl, sidebar, ...others } = frontmatter; const pageEditUrl = editUrl === undefined || editUrl === true ? false : editUrl; return { ...others, editUrl: pageEditUrl }; }); }; /** * Type of Starlight pages frontmatter schema. * We manually refines the `editUrl` type and omit the `sidebar` property as it's not possible to * do that on the schema itself using Zod but the proper validation is still using a transformer. * @see StarlightPageFrontmatterSchema */ type StarlightPageFrontmatter = Omit< z.input<Awaited<ReturnType<typeof StarlightPageFrontmatterSchema>>>, 'editUrl' | 'sidebar' > & { editUrl?: string | false }; /** Parse sidebar prop to ensure it's valid. */ const validateSidebarProp = ( sidebarProp: StarlightUserConfig['sidebar'] ): StarlightConfig['sidebar'] => { return parseWithFriendlyErrors( SidebarItemSchema.array().optional(), sidebarProp, 'Invalid sidebar prop passed to the `<StarlightPage/>` component.' ); }; /** * The props accepted by the `<StarlightPage/>` component. */ export type StarlightPageProps = Prettify< // Remove the index signature from `Route`, omit undesired properties and make the rest optional. Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> & // Add the sidebar definitions for a Starlight page. Partial<Pick<StarlightRouteData, 'hasSidebar'>> & { sidebar?: StarlightUserConfig['sidebar']; // And finally add the Starlight page frontmatter properties in a `frontmatter` property. frontmatter: StarlightPageFrontmatter; } >; /** * A docs entry used for Starlight pages meant to be rendered by plugins and which is safe to cast * to a `StarlightDocsEntry`. * A Starlight page docs entry cannot be rendered like a content collection entry. */ type StarlightPageDocsEntry = Omit<StarlightDocsEntry, 'id' | 'render'> & { /** * The unique ID if using the `legacy.collections` for this Starlight page which cannot be * inferred from codegen like content collection entries or the slug. */ id: string; }; export async function generateStarlightPageRouteData({ props, context, }: { props: StarlightPageProps; context: RouteDataContext; }): Promise<StarlightRouteData> { const { frontmatter, ...routeProps } = props; const { url } = context; const slug = urlToSlug(url); const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter); const id = project.legacyCollections ? `${stripLeadingAndTrailingSlashes(slug)}.md` : slug; const localeData = slugToLocaleData(slug); const sidebar = props.sidebar ? getSidebarFromConfig(validateSidebarProp(props.sidebar), url.pathname, localeData.locale) : getSidebar(url.pathname, localeData.locale); const headings = props.headings ?? []; const pageDocsEntry: StarlightPageDocsEntry = { id, slug, body: '', collection: 'docs', filePath: `${getCollectionPathFromRoot('docs', project)}/${stripLeadingAndTrailingSlashes(slug)}.md`, data: { ...pageFrontmatter, sidebar: { attrs: {}, hidden: false, }, }, }; const entry = pageDocsEntry as StarlightDocsEntry; const entryMeta: StarlightRouteData['entryMeta'] = { dir: props.dir ?? localeData.dir, lang: props.lang ?? localeData.lang, locale: localeData.locale, }; const editUrl = pageFrontmatter.editUrl ? new URL(pageFrontmatter.editUrl) : undefined; const lastUpdated = pageFrontmatter.lastUpdated instanceof Date ? pageFrontmatter.lastUpdated : undefined; const pageProps: PageProps = { ...routeProps, ...localeData, entry, entryMeta, headings, id, locale: localeData.locale, slug, }; const siteTitle = getSiteTitle(localeData.lang); const routeData: StarlightRouteData = { ...routeProps, ...localeData, id, editUrl, entry, entryMeta, hasSidebar: props.hasSidebar ?? entry.data.template !== 'splash', head: getHead(pageProps, context, siteTitle), headings, lastUpdated, pagination: getPrevNextLinks(sidebar, config.pagination, entry.data), sidebar, siteTitle, siteTitleHref: getSiteTitleHref(localeData.locale), slug, toc: getToC(pageProps), }; return routeData; } /** Validates the Starlight page frontmatter properties from the props received by a Starlight page. */ async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter) { const schema = await StarlightPageFrontmatterSchema({ image: (() => // Mock validator for ImageMetadata. // https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32 // It uses a custom validation approach because imported SVGs have a type of `function` as // well as containing the metadata properties and this ensures we handle those correctly. z.custom( (value) => (value && (typeof value === 'function' || typeof value === 'object') && 'src' in value && 'width' in value && 'height' in value && 'format' in value) as ReturnType<ImageFunction>, 'Invalid image passed to `<StarlightPage>` component. Expected imported `ImageMetadata` object.' )) as ImageFunction, }); // Starting with Astro 4.14.0, a frontmatter schema that contains collection references will // contain an async transform. return parseAsyncWithFriendlyErrors( schema, frontmatter, 'Invalid frontmatter props passed to the `<StarlightPage/>` component.' ); } /** Returns the user docs schema and falls back to the default schema if needed. */ async function getUserDocsSchema(): Promise< NonNullable<ContentConfig['collections']['docs']['schema']> > { const userCollections = (await import('virtual:starlight/collection-config')).collections; return userCollections?.docs?.schema ?? docsSchema(); }