UNPKG

@astrojs/starlight

Version:

Build beautiful, high-performance documentation websites with Astro

378 lines (341 loc) 14 kB
import { z } from 'astro/zod'; import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47'; import { ComponentConfigSchema } from '../schemas/components'; import { ExpressiveCodeSchema } from '../schemas/expressiveCode'; import { FaviconSchema } from '../schemas/favicon'; import { HeadConfigSchema } from '../schemas/head'; import { LogoConfigSchema } from '../schemas/logo'; import { PagefindConfigDefaults, PagefindConfigSchema } from '../schemas/pagefind'; import { SidebarItemSchema } from '../schemas/sidebar'; import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title'; import { SocialLinksSchema } from '../schemas/social'; import { TableOfContentsSchema } from '../schemas/tableOfContents'; import { BuiltInDefaultLocale } from './i18n'; const LocaleSchema = z.object({ /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */ label: z .string() .describe( 'The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`.' ), /** The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`. */ lang: z .string() .optional() .describe('The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`.'), /** The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left. */ dir: z .enum(['rtl', 'ltr']) .optional() .default('ltr') .describe( 'The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left.' ), }); const UserConfigSchema = z.object({ /** Title for your website. Will be used in metadata and as browser tab title. */ title: TitleConfigSchema(), /** Description metadata for your website. Can be used in page metadata. */ description: z .string() .optional() .describe('Description metadata for your website. Can be used in page metadata.'), /** Set a logo image to show in the navigation bar alongside or instead of the site title. */ logo: LogoConfigSchema(), /** * Optional details about the social media accounts for this site. * * @example * social: [ * { icon: 'codeberg', label: 'Codeberg', href: 'https://codeberg.org/knut' }, * { icon: 'discord', label: 'Discord', href: 'https://astro.build/chat' }, * { icon: 'github', label: 'GitHub', href: 'https://github.com/withastro' }, * { icon: 'gitlab', label: 'GitLab', href: 'https://gitlab.com/delucis' }, * { icon: 'mastodon', label: 'Mastodon', href: 'https://m.webtoo.ls/@astro' }, * ] */ social: SocialLinksSchema(), /** The tagline for your website. */ tagline: z.string().optional().describe('The tagline for your website.'), /** Configure the defaults for the table of contents on each page. */ tableOfContents: TableOfContentsSchema(), /** Enable and configure “Edit this page” links. */ editLink: z .object({ /** Set the base URL for edit links. The final link will be `baseUrl` + the current page path. */ baseUrl: z.string().url().optional(), }) .optional() .default({}), /** Configure locales for internationalization (i18n). */ locales: z .object({ /** Configure a “root” locale to serve a default language from `/`. */ root: LocaleSchema.required({ lang: true }).optional(), }) .catchall(LocaleSchema) .transform((locales, ctx) => { for (const key in locales) { const locale = locales[key]!; // Fall back to the key in the locales object as the lang. let lang = locale.lang || key; // Parse the lang tag so we can check it is valid according to BCP-47. const schema = bcpParse(lang, { forgiving: true }); schema.region = schema.region?.toUpperCase(); const normalizedLang = bcpStringify(schema); // Error if parsing the language tag failed. if (!normalizedLang) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Could not validate language tag "${lang}" at locales.${key}.lang.`, }); return z.NEVER; } // Let users know we’re modifying their configured `lang`. if (normalizedLang !== lang) { console.warn( `Warning: using "${normalizedLang}" language tag for locales.${key}.lang instead of "${lang}".` ); lang = normalizedLang; } // Set the final value as the normalized lang, based on the key if needed. locale.lang = lang; } return locales; }) .optional() .describe('Configure locales for internationalization (i18n).'), /** * Specify the default language for this site. * * The default locale will be used to provide fallback content where translations are missing. */ defaultLocale: z.string().optional(), /** Configure your site’s sidebar navigation items. */ sidebar: SidebarItemSchema.array().optional(), /** * Add extra tags to your site’s `<head>`. * * Can also be set for a single page in a page’s frontmatter. * * @example * // Add Fathom analytics to your site * starlight({ * head: [ * { * tag: 'script', * attrs: { * src: 'https://cdn.usefathom.com/script.js', * 'data-site': 'MY-FATHOM-ID', * defer: true, * }, * }, * ], * }) */ head: HeadConfigSchema({ source: 'config' }), /** * Provide CSS files to customize the look and feel of your Starlight site. * * Supports local CSS files relative to the root of your project, * e.g. `'/src/custom.css'`, and CSS you installed as an npm * module, e.g. `'@fontsource/roboto'`. * * @example * starlight({ * customCss: ['/src/custom-styles.css', '@fontsource/roboto'], * }) */ customCss: z .string() .array() .optional() .default([]) .superRefine((paths, ctx) => { const invalidPathRegex = /^\.?\/public\/.+$/; const invalidPaths = paths.filter((path) => invalidPathRegex.test(path)); if (invalidPaths.length > 0) { ctx.addIssue({ code: 'custom', message: `These paths in your Starlight \`customCss\` config are invalid: ${invalidPaths.map((path) => `\`"${path}"\``).join(', ')}\n\n` + `CSS files specified in \`customCss\` should be in the \`src/\` directory, not the \`public/\` directory.\n\n` + `You should move these CSS files into the \`src/\` directory and update the path in \`customCss\` to match.`, }); } }), /** Define if the last update date should be visible in the page footer. */ lastUpdated: z .boolean() .default(false) .describe('Define if the last update date should be visible in the page footer.'), /** Define if the previous and next page links should be visible in the page footer. */ pagination: z .boolean() .default(true) .describe('Define if the previous and next page links should be visible in the page footer.'), /** The default favicon for your site which should be a path to an image in the `public/` directory. */ favicon: FaviconSchema(), /** * Define how code blocks are rendered by passing options to Expressive Code, * or disable the integration by passing `false`. */ expressiveCode: ExpressiveCodeSchema(), /** * Configure Starlight’s default site search provider Pagefind. Set to `false` to disable indexing * your site with Pagefind, which will also hide the default search UI if in use. */ pagefind: z .boolean() // Transform `true` to our default config object. .transform((val) => val && PagefindConfigDefaults()) .or(PagefindConfigSchema()) .optional(), /** 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() .default('|') .describe('Will be used as title delimiter in the generated `<title>` tag.'), /** Disable Starlight's default 404 page. */ disable404Route: z.boolean().default(false).describe("Disable Starlight's default 404 page."), /** * Define whether Starlight pages should be prerendered or not. * Defaults to always prerender Starlight pages, even when the project is * set to "server" output mode. */ prerender: z.boolean().default(true), /** Enable displaying a “Built with Starlight” link in your site’s footer. */ credits: z .boolean() .default(false) .describe('Enable displaying a “Built with Starlight” link in your site’s footer.'), /** Add middleware to process Starlight’s route data for each page. */ routeMiddleware: z .string() .transform((string) => [string]) .or(z.string().array()) .default([]) .superRefine((middlewares, ctx) => { // Regex pattern to match invalid middleware paths: https://regex101.com/r/kQH7xm/2 const invalidPathRegex = /^\.?\/src\/middleware(?:\/index)?\.[jt]s$/; const invalidPaths = middlewares.filter((middleware) => invalidPathRegex.test(middleware)); for (const invalidPath of invalidPaths) { ctx.addIssue({ code: 'custom', message: `The \`"${invalidPath}"\` path in your Starlight \`routeMiddleware\` config conflicts with Astro’s middleware locations.\n\n` + `You should rename \`${invalidPath}\` to something else like \`./src/starlightRouteData.ts\` and update the \`routeMiddleware\` file path to match.\n\n` + '- More about Starlight route middleware: https://starlight.astro.build/guides/route-data/#how-to-customize-route-data\n' + '- More about Astro middleware: https://docs.astro.build/en/guides/middleware/', }); } }) .describe('Add middleware to process Starlight’s route data for each page.'), /** Configure features that impact Starlight’s Markdown processing. */ markdown: z .object({ /** Define whether headings in content should be rendered with clickable anchor links. Default: `true`. */ headingLinks: z .boolean() .default(true) .describe( 'Define whether headings in content should be rendered with clickable anchor links. Default: `true`.' ), /** * Define additional directories where files should be processed by Starlight’s Markdown pipeline. * * Supports local directories relative to the root of your project, e.g. './src/data/comments/'. * Content of the `docs` content collection is always processed by Starlight’s Markdown pipeline. */ processedDirs: z .string() .array() .default([]) .describe( 'Define additional directories where files should be processed by Starlight’s Markdown pipeline. Default: `[]`.' ), }) .default({}) .describe('Configure features that impact Starlight’s Markdown processing.'), }); export const StarlightConfigSchema = UserConfigSchema.strict() .transform((config) => ({ ...config, // Pagefind only defaults to true if prerender is also true. pagefind: typeof config.pagefind === 'undefined' ? config.prerender && PagefindConfigDefaults() : config.pagefind, })) .refine((config) => !(!config.prerender && config.pagefind), { message: 'Pagefind search is not supported with prerendering disabled.', }) .transform(({ title, locales, defaultLocale, ...config }, ctx) => { const configuredLocales = Object.keys(locales ?? {}); // This is a multilingual site (more than one locale configured) or a monolingual site with // only one locale configured (not a root locale). // Monolingual sites with only one non-root locale needs their configuration to be defined in // `config.locales` so that slugs can be correctly generated by taking into consideration the // base path at which a language is served which is the key of the `config.locales` object. if ( locales !== undefined && (configuredLocales.length > 1 || (configuredLocales.length === 1 && locales.root === undefined)) ) { // Make sure we can find the default locale and if not, help the user set it. // We treat the root locale as the default if present and no explicit default is set. const defaultLocaleConfig = locales[defaultLocale || 'root']; if (!defaultLocaleConfig) { const availableLocales = configuredLocales.map((l) => `"${l}"`).join(', '); ctx.addIssue({ code: 'custom', message: 'Could not determine the default locale. ' + 'Please make sure `defaultLocale` in your Starlight config is one of ' + availableLocales, }); return z.NEVER; } // Transform the title const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang as string); const parsedTitle = TitleSchema.parse(title); return { ...config, title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: configuredLocales.length > 1, /** Flag indicating if the Starlight built-in default locale is used. */ isUsingBuiltInDefaultLocale: false, /** Full locale object for this site’s default language. */ defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale }, locales, } as const; } // This is a monolingual site with no locales configured or only a root locale, so things are // pretty simple. /** Full locale object for this site’s default language. */ const defaultLocaleConfig = { label: BuiltInDefaultLocale.label, lang: BuiltInDefaultLocale.lang, dir: BuiltInDefaultLocale.dir, locale: undefined, ...locales?.root, }; /** Transform the title */ const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang); const parsedTitle = TitleSchema.parse(title); return { ...config, title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: false, /** Flag indicating if the Starlight built-in default locale is used. */ isUsingBuiltInDefaultLocale: locales?.root === undefined, defaultLocale: defaultLocaleConfig, locales: undefined, } as const; }); export type StarlightConfig = z.infer<typeof StarlightConfigSchema>; export type StarlightUserConfig = z.input<typeof StarlightConfigSchema>;