feat: add support for light / dark hero images#280
Conversation
🦋 Changeset detectedLatest commit: 383c376 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for astro-starlight ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
lorenzolewis
left a comment
There was a problem hiding this comment.
Since there's been quite a few changes to the codebase this PR has a few conflicts. I'm not sure of the best way to communicate those so I've created a diff that I'll include below. With that diff applied and then the suggestion on updating the docs phrasing this one looks good to me 🥳
diff --git a/packages/starlight/components/Hero.astro b/packages/starlight/components/Hero.astro
index 1fccc61..fbf56b8 100644
--- a/packages/starlight/components/Hero.astro
+++ b/packages/starlight/components/Hero.astro
@@ -4,160 +4,146 @@ import { Image } from 'astro:assets';
import CallToAction from './CallToAction.astro';
interface Props {
- fallbackTitle: string;
- hero: NonNullable<CollectionEntry<'docs'>['data']['hero']>;
+ fallbackTitle: string;
+ hero: NonNullable<CollectionEntry<'docs'>['data']['hero']>;
}
-const {
- title = Astro.props.fallbackTitle,
- tagline,
- image,
- actions,
-} = Astro.props.hero;
+const { title = Astro.props.fallbackTitle, tagline, image, actions } = Astro.props.hero;
const imageAttrs = {
- loading: 'eager' as const,
- decoding: 'async' as const,
- width: 400,
- height: 400,
- alt: image?.alt,
+ loading: 'eager' as const,
+ decoding: 'async' as const,
+ width: 400,
+ height: 400,
+ alt: image?.alt,
};
// darkImage is the default and uses either file, dark or raw html
const darkImage = image?.file ? image.file : image?.dark ? image.dark : null;
// lightImage is only used if darkImage is also used
-const lightImage = image?.file ? null : (image?.dark && image?.light) ? image.light : null;
+const lightImage = image?.file ? null : image?.dark && image?.light ? image.light : null;
// rawHtml is only used if darkImage is not used
const rawHtml = !darkImage && image?.html ? image.html : null;
---
<div class="hero">
-
- {
- darkImage && (
- darkImage.format === 'svg' ? (
- <img src={darkImage.src} {...imageAttrs} class:list={{ 'dark-only': Boolean(lightImage) }}/>
- ) : (
- <Image src={darkImage} {...imageAttrs} class:list={{ 'dark-only': Boolean(lightImage) }}/>
- )
- )
- }
- {
- lightImage && (
- lightImage.format === 'svg' ? (
- <img src={lightImage.src} {...imageAttrs} class="light-only"/>
- ) : (
- <Image src={lightImage} {...imageAttrs} class="light-only"/>
- )
- )
- }
-{
- rawHtml && <div class="hero-html flex" set:html={rawHtml} />
-}
- <div class="flex stack">
- <div class="flex copy">
- <h1 id="_top" data-page-title set:html={title} />
- {tagline && <div class="tagline" set:html={tagline} />}
- </div>
- {
- actions.length > 0 && (
- <div class="flex actions">
- {actions.map(({ text, ...attrs }) => (
- <CallToAction {...attrs} set:html={text} />
- ))}
- </div>
- )
- }
- </div>
+ {
+ darkImage &&
+ (darkImage.format === 'svg' ? (
+ <img
+ src={darkImage.src}
+ {...imageAttrs}
+ class:list={{ 'dark-only': Boolean(lightImage) }}
+ />
+ ) : (
+ <Image src={darkImage} {...imageAttrs} class:list={{ 'dark-only': Boolean(lightImage) }} />
+ ))
+ }
+ {
+ lightImage &&
+ (lightImage.format === 'svg' ? (
+ <img src={lightImage.src} {...imageAttrs} class="light-only" />
+ ) : (
+ <Image src={lightImage} {...imageAttrs} class="light-only" />
+ ))
+ }
+ {rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
+ <div class="sl-flex stack">
+ <div class="sl-flex copy">
+ <h1 id="_top" data-page-title set:html={title} />
+ {tagline && <div class="tagline" set:html={tagline} />}
+ </div>
+ {
+ actions.length > 0 && (
+ <div class="sl-flex actions">
+ {actions.map(({ text, ...attrs }) => (
+ <CallToAction {...attrs} set:html={text} />
+ ))}
+ </div>
+ )
+ }
+ </div>
</div>
<style>
- .hero {
- display: grid;
- align-items: center;
- gap: 1rem;
- padding-bottom: 1rem;
- }
-
- .hero > img,
- .hero > .hero-html {
- object-fit: contain;
- width: min(70%, 20rem);
- height: auto;
- margin-inline: auto;
- }
-
- :global([data-theme='light']) .dark-only {
- display: none;
- }
- :global([data-theme='dark']) .light-only {
- display: none;
- }
-
- .stack {
- flex-direction: column;
- gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
- text-align: center;
- }
-
- .copy {
- flex-direction: column;
- gap: 1rem;
- align-items: center;
- }
-
- .copy > * {
- max-width: 50ch;
- }
-
- h1 {
- font-size: clamp(
- var(--sl-text-3xl),
- calc(0.25rem + 5vw),
- var(--sl-text-6xl)
- );
- line-height: var(--sl-line-height-headings);
- font-weight: 600;
- color: var(--sl-color-white);
- }
-
- .tagline {
- font-size: clamp(
- var(--sl-text-base),
- calc(0.0625rem + 2vw),
- var(--sl-text-xl)
- );
- color: var(--sl-color-gray-2);
- }
-
- .actions {
- gap: 1rem 2rem;
- flex-wrap: wrap;
- justify-content: center;
- }
-
- @media (min-width: 50rem) {
- .hero {
- grid-template-columns: 7fr 4fr;
- gap: 3%;
- padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
- }
-
- .hero > img,
- .hero > .hero-html {
- order: 2;
- width: min(100%, 25rem);
- }
-
- .stack {
- text-align: start;
- }
-
- .copy {
- align-items: flex-start;
- }
-
- .actions {
- justify-content: flex-start;
- }
- }
+ .hero {
+ display: grid;
+ align-items: center;
+ gap: 1rem;
+ padding-bottom: 1rem;
+ }
+
+ .hero > img,
+ .hero > .hero-html {
+ object-fit: contain;
+ width: min(70%, 20rem);
+ height: auto;
+ margin-inline: auto;
+ }
+
+ :global([data-theme='light']) .dark-only {
+ display: none;
+ }
+ :global([data-theme='dark']) .light-only {
+ display: none;
+ }
+
+ .stack {
+ flex-direction: column;
+ gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
+ text-align: center;
+ }
+
+ .copy {
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+ }
+
+ .copy > * {
+ max-width: 50ch;
+ }
+
+ h1 {
+ font-size: clamp(var(--sl-text-3xl), calc(0.25rem + 5vw), var(--sl-text-6xl));
+ line-height: var(--sl-line-height-headings);
+ font-weight: 600;
+ color: var(--sl-color-white);
+ }
+
+ .tagline {
+ font-size: clamp(var(--sl-text-base), calc(0.0625rem + 2vw), var(--sl-text-xl));
+ color: var(--sl-color-gray-2);
+ }
+
+ .actions {
+ gap: 1rem 2rem;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ @media (min-width: 50rem) {
+ .hero {
+ grid-template-columns: 7fr 4fr;
+ gap: 3%;
+ padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
+ }
+
+ .hero > img,
+ .hero > .hero-html {
+ order: 2;
+ width: min(100%, 25rem);
+ }
+
+ .stack {
+ text-align: start;
+ }
+
+ .copy {
+ align-items: flex-start;
+ }
+
+ .actions {
+ justify-content: flex-start;
+ }
+ }
</style>
diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts
index 320b30b..5345c46 100644
--- a/packages/starlight/schema.ts
+++ b/packages/starlight/schema.ts
@@ -1,121 +1,177 @@
-import { z } from "astro/zod";
-import { HeadConfigSchema } from "./schemas/head";
-import { TableOfContentsSchema } from "./schemas/tableOfContents";
-import { Icons } from "./components/Icons";
-export { i18nSchema } from "./schemas/i18n";
+import { z } from 'astro/zod';
+import { HeadConfigSchema } from './schemas/head';
+import { PrevNextLinkConfigSchema } from './schemas/prevNextLink';
+import { TableOfContentsSchema } from './schemas/tableOfContents';
+import { Icons } from './components/Icons';
+import { BadgeConfigSchema } from './schemas/badge';
+export { i18nSchema } from './schemas/i18n';
type IconName = keyof typeof Icons;
const iconNames = Object.keys(Icons) as [IconName, ...IconName[]];
type ImageFunction = () => z.ZodObject<{
- src: z.ZodString;
- width: z.ZodNumber;
- height: z.ZodNumber;
- format: z.ZodUnion<
- [
- z.ZodLiteral<"png">,
- z.ZodLiteral<"jpg">,
- z.ZodLiteral<"jpeg">,
- z.ZodLiteral<"tiff">,
- z.ZodLiteral<"webp">,
- z.ZodLiteral<"gif">,
- z.ZodLiteral<"svg">
- ]
- >;
+ src: z.ZodString;
+ width: z.ZodNumber;
+ height: z.ZodNumber;
+ format: z.ZodUnion<
+ [
+ z.ZodLiteral<'png'>,
+ z.ZodLiteral<'jpg'>,
+ z.ZodLiteral<'jpeg'>,
+ z.ZodLiteral<'tiff'>,
+ z.ZodLiteral<'webp'>,
+ z.ZodLiteral<'gif'>,
+ z.ZodLiteral<'svg'>,
+ ]
+ >;
}>;
export function docsSchema() {
- return ({ image }: { image: ImageFunction }) =>
- z.object({
- /** The title of the current page. Required. */
- title: z.string(),
-
- /**
- * A short description of the current page’s content. Optional, but recommended.
- * A good description is 150–160 characters long and outlines the key content
- * of the page in a clear and engaging way.
- */
- description: z.string().optional(),
-
- /**
- * Custom URL where a reader can edit this page.
- * Overrides the `editLink.baseUrl` global config if set.
- *
- * Can also be set to `false` to disable showing an edit link on this page.
- */
- editUrl: z
- .union([z.string().url(), z.boolean()])
- .optional()
- .default(true),
-
- /** Set custom `<head>` tags just for this page. */
- head: HeadConfigSchema(),
-
- /** Override global table of contents configuration for this page. */
- tableOfContents: TableOfContentsSchema().optional(),
-
- /**
- * Set the layout style for this page.
- * Can be `'doc'` (the default) or `'splash'` for a wider layout without any sidebars.
- */
- template: z.enum(["doc", "splash"]).default("doc"),
-
- /** Display a hero section on this page. */
- hero: z
- .object({
- /**
- * The large title text to show. If not provided, will default to the top-level `title`.
- * Can include HTML.
- */
- title: z.string().optional(),
- /**
- * A short bit of text about your project.
- * Will be displayed in a smaller size below the title.
- */
- tagline: z.string().optional(),
- /** The image to use in the hero. You can provide either a relative `file` path or raw `html`. */
- image: z
- .object({
- /** Alt text for screenreaders and other assistive technologies describing your hero image. */
- alt: z.string().default(""),
- /** Relative path to an image file in your repo, e.g. `../../assets/hero.png`. */
- file: image().optional(),
- /** Relative path to an image file in your repo to use in dark mode, e.g. `../../assets/hero-dark.png`. */
- dark: image().optional(),
- /** Relative path to an image file in your repo to use in light mode, e.g. `../../assets/hero-light.png`. */
- light: image().optional(),
- /** Raw HTML string instead of an image file. Useful for inline SVGs or more complex hero content. */
- html: z.string().optional(),
- })
- .optional(),
- /** An array of call-to-action links displayed at the bottom of the hero. */
- actions: z
- .object({
- /** Text label displayed in the link. */
- text: z.string(),
- /** Value for the link’s `href` attribute, e.g. `/page` or `https://mysite.com`. */
- link: z.string(),
- /** Button style to use. One of `primary`, `secondary`, or `minimal` (the default). */
- variant: z
- .enum(["primary", "secondary", "minimal"])
- .default("minimal"),
- /**
- * An optional icon to display alongside the link text.
- * Can be an inline `<svg>` or the name of one of Starlight’s built-in icons.
- */
- icon: z
- .union([z.enum(iconNames), z.string().startsWith("<svg")])
- .transform((icon) => {
- const parsedIcon = z.enum(iconNames).safeParse(icon);
- return parsedIcon.success
- ? ({ type: "icon", name: parsedIcon.data } as const)
- : ({ type: "raw", html: icon } as const);
- })
- .optional(),
- })
- .array()
- .default([]),
- })
- .optional(),
- });
+ return ({ image }: { image: ImageFunction }) =>
+ z.object({
+ /** The title of the current page. Required. */
+ title: z.string(),
+
+ /**
+ * A short description of the current page’s content. Optional, but recommended.
+ * A good description is 150–160 characters long and outlines the key content
+ * of the page in a clear and engaging way.
+ */
+ description: z.string().optional(),
+
+ /**
+ * Custom URL where a reader can edit this page.
+ * Overrides the `editLink.baseUrl` global config if set.
+ *
+ * Can also be set to `false` to disable showing an edit link on this page.
+ */
+ editUrl: z.union([z.string().url(), z.boolean()]).optional().default(true),
+
+ /** Set custom `<head>` tags just for this page. */
+ head: HeadConfigSchema(),
+
+ /** Override global table of contents configuration for this page. */
+ tableOfContents: TableOfContentsSchema().optional(),
+
+ /**
+ * Set the layout style for this page.
+ * Can be `'doc'` (the default) or `'splash'` for a wider layout without any sidebars.
+ */
+ template: z.enum(['doc', 'splash']).default('doc'),
+
+ /** Display a hero section on this page. */
+ hero: z
+ .object({
+ /**
+ * The large title text to show. If not provided, will default to the top-level `title`.
+ * Can include HTML.
+ */
+ title: z.string().optional(),
+ /**
+ * A short bit of text about your project.
+ * Will be displayed in a smaller size below the title.
+ */
+ tagline: z.string().optional(),
+ /** The image to use in the hero. You can provide either a relative `file` path or raw `html`. */
+ /** The image to use in the hero. You can provide either a relative `file` path or raw `html`. */
+ image: z
+ .object({
+ /** Alt text for screenreaders and other assistive technologies describing your hero image. */
+ alt: z.string().default(''),
+ /** Relative path to an image file in your repo, e.g. `../../assets/hero.png`. */
+ file: image().optional(),
+ /** Relative path to an image file in your repo to use in dark mode, e.g. `../../assets/hero-dark.png`. */
+ dark: image().optional(),
+ /** Relative path to an image file in your repo to use in light mode, e.g. `../../assets/hero-light.png`. */
+ light: image().optional(),
+ /** Raw HTML string instead of an image file. Useful for inline SVGs or more complex hero content. */
+ html: z.string().optional(),
+ })
+ .optional(),
+ /** An array of call-to-action links displayed at the bottom of the hero. */
+ actions: z
+ .object({
+ /** Text label displayed in the link. */
+ text: z.string(),
+ /** Value for the link’s `href` attribute, e.g. `/page` or `https://mysite.com`. */
+ link: z.string(),
+ /** Button style to use. One of `primary`, `secondary`, or `minimal` (the default). */
+ variant: z.enum(['primary', 'secondary', 'minimal']).default('minimal'),
+ /**
+ * An optional icon to display alongside the link text.
+ * Can be an inline `<svg>` or the name of one of Starlight’s built-in icons.
+ */
+ icon: z
+ .union([z.enum(iconNames), z.string().startsWith('<svg')])
+ .transform((icon) => {
+ const parsedIcon = z.enum(iconNames).safeParse(icon);
+ return parsedIcon.success
+ ? ({ type: 'icon', name: parsedIcon.data } as const)
+ : ({ type: 'raw', html: icon } as const);
+ })
+ .optional(),
+ })
+ .array()
+ .default([]),
+ })
+ .optional(),
+
+ /**
+ * The last update date of the current page.
+ * Overrides the `lastUpdated` global config or the date generated from the Git history.
+ */
+ lastUpdated: z.union([z.date(), z.boolean()]).optional(),
+
+ /**
+ * The previous navigation link configuration.
+ * Overrides the `pagination` global config or the link text and/or URL.
+ */
+ prev: PrevNextLinkConfigSchema(),
+ /**
+ * The next navigation link configuration.
+ * Overrides the `pagination` global config or the link text and/or URL.
+ */
+ next: PrevNextLinkConfigSchema(),
+
+ sidebar: z
+ .object({
+ /**
+ * The order of this page in the navigation.
+ * Pages are sorted by this value in ascending order. Then by slug.
+ * If not provided, pages will be sorted alphabetically by slug.
+ * If two pages have the same order value, they will be sorted alphabetically by slug.
+ */
+ order: z.number().optional(),
+
+ /**
+ * The label for this page in the navigation.
+ * Defaults to the page `title` if not set.
+ */
+ label: z.string().optional(),
+
+ /**
+ * Prevents this page from being included in autogenerated sidebar groups.
+ */
+ hidden: z.boolean().default(false),
+ /**
+ * Adds a badge to the sidebar link.
+ * Can be a string or an object with a variant and text.
+ * Variants include 'note', 'tip', 'caution', 'danger', 'success', and 'default'.
+ * Passing only a string defaults to the 'default' variant which uses the site accent color.
+ */
+ badge: BadgeConfigSchema(),
+ })
+ .default({}),
+
+ /** Display an announcement banner at the top of this page. */
+ banner: z
+ .object({
+ /** The content of the banner. Supports HTML syntax. */
+ content: z.string(),
+ })
+ .optional(),
+
+ /** Pagefind indexing for this page - set to false to disable. */
+ pagefind: z.boolean().default(true),
+ });
}|
Oh, and one last thing I forgot is to mention generating a changeset for this one. |
Co-authored-by: Lorenzo Lewis <lorenzo_lewis@icloud.com>
…to latest starlight codebase
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
|
Took a pass at getting this cleaned up so we can finally ship it! Quick summary of the changes I made:
Thanks again for everyone’s patience on this one! |
HiDeoo
left a comment
There was a problem hiding this comment.
Amazing update to make it to the finish line. 👏
The code is very clean, I love the stricter schema. Also tested locally and it works great.
I guess we could potentially have a component to render either <Image/> or <img/> to avoid some repetition, but it happens only 2 times in a single component so it may be better to keep it as is.
This looks good to me 🚀 (altho I almost feel a bit bad with the new alt documentation that we do not use it in our index page 😅)
Great work everyone.
Oh, actually, I should double check. I think Edit: Will run a test, but the change I was thinking of released in 2.8.3, so would definitely be in scope: withastro/astro#7643 |
`<Image />` supports SVGs since Astro v2.8.3
Oh, totally missed that one, would be great indeed. |
|
Update: Yup! |
|
The only minor feedback I have is that |
Yes, that’s true (also the case with the current hero image support). It was chosen to distinguish |
* main: (22 commits) fix(docs-i18n-tracker): update `translations` import (withastro#1025) [ci] format i18n(zh-cn): Update css-and-tailwind.mdx (withastro#1018) [ci] format i18n(zh-cn): Update authoring-content.md (withastro#1016) i18n(ko-KR): update `configuration.mdx` (withastro#1015) i18n(ko-KR): update `sidebar.mdx` (withastro#1014) i18n(ko-KR): update `i18n.mdx` (withastro#1013) [ci] format i18n(ko-KR): update `frontmatter.md` (withastro#1017) [ci] format i18n(pt-BR): Update `css-and-tailwind.mdx`, `authoring-content.md` and `overrides.md` (withastro#1009) [ci] format [ci] release (withastro#996) Fix Prettier-compatibility of i18n test fixture Refactor translation system to be reusable in non-Astro code (withastro#1003) Add social icons to mobile menu footer (withastro#988) [ci] format Add Galician language support (withastro#1004) feat: add support for light / dark hero images (withastro#280) ...
* Update frontmatter.md #280 * Update frontmatter.md * Update frontmatter.md * improve wording
Co-authored-by: Lorenzo Lewis <lorenzo_lewis@icloud.com> Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

What kind of changes does this PR include?
Description