@grove-dev/starlight 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/i18n.ts ADDED
@@ -0,0 +1,175 @@
1
+ export type LocaleLookup = {
2
+ /** BCP-47 tag from the active locale (`locales[x].lang`), e.g. `en`. */
3
+ lang?: string;
4
+ /** Locale path key (`Astro.currentLocale`), e.g. `en`. */
5
+ locale?: string;
6
+ /** Default locale BCP-47 tag or path used as fallback. */
7
+ defaultLang: string;
8
+ /** Default locale path key, e.g. `en`. */
9
+ defaultLocale?: string;
10
+ };
11
+
12
+ /**
13
+ * Pick a value from a locale dictionary.
14
+ *
15
+ * Tries exact then case-insensitive matches for active locale candidates first,
16
+ * then the same for fallback/default candidates. This covers Starlight setups
17
+ * where config keys are BCP-47 (`es-ES`) but `Astro.currentLocale` is the path
18
+ * (`es-es`), without letting the default locale shadow an active case-insensitive hit.
19
+ */
20
+ export function pickLocalized(
21
+ dictionary: Record<string, string> | undefined,
22
+ candidates: Array<string | undefined>,
23
+ fallbacks: Array<string | undefined> = []
24
+ ): string | undefined {
25
+ if (!dictionary) {
26
+ return undefined;
27
+ }
28
+
29
+ const lowerMap = Object.fromEntries(
30
+ Object.entries(dictionary).map(([key, value]) => [key.toLowerCase(), value])
31
+ );
32
+
33
+ const pickExact = (keys: Array<string | undefined>) => {
34
+ for (const key of keys) {
35
+ if (key && dictionary[key]) {
36
+ return dictionary[key];
37
+ }
38
+ }
39
+ return undefined;
40
+ };
41
+
42
+ const pickCaseInsensitive = (keys: Array<string | undefined>) => {
43
+ for (const key of keys) {
44
+ if (!key) {
45
+ continue;
46
+ }
47
+
48
+ const match = lowerMap[key.toLowerCase()];
49
+ if (match) {
50
+ return match;
51
+ }
52
+ }
53
+ return undefined;
54
+ };
55
+
56
+ return (
57
+ pickExact(candidates) ??
58
+ pickCaseInsensitive(candidates) ??
59
+ pickExact(fallbacks) ??
60
+ pickCaseInsensitive(fallbacks)
61
+ );
62
+ }
63
+
64
+ /** @deprecated Prefer `pickLocalized`. Kept for existing call sites/tests. */
65
+ export function pickLang(
66
+ dictionary: Record<string, string> | undefined,
67
+ lang: string
68
+ ): string | undefined {
69
+ return pickLocalized(dictionary, [lang]);
70
+ }
71
+
72
+ function activeKeys({ lang, locale }: LocaleLookup): Array<string | undefined> {
73
+ return [lang, locale];
74
+ }
75
+
76
+ function fallbackKeys({ defaultLang, defaultLocale }: LocaleLookup): Array<string | undefined> {
77
+ return [defaultLang, defaultLocale];
78
+ }
79
+
80
+ /**
81
+ * Resolve a nav link label.
82
+ *
83
+ * Supports both APIs:
84
+ * - Starlight sidebar style: `label: string` + optional `translations`
85
+ * - Locale map style: `label: Record<BCP-47 | locale-path, string>`
86
+ */
87
+ export function resolveNavLabel(
88
+ label: string | Record<string, string>,
89
+ translations: Record<string, string> | undefined,
90
+ keys: LocaleLookup
91
+ ): string {
92
+ const primary = activeKeys(keys);
93
+ const fallback = fallbackKeys(keys);
94
+
95
+ if (typeof label === 'string') {
96
+ return pickLocalized(translations, primary, fallback) || label;
97
+ }
98
+
99
+ const resolved = pickLocalized(label, primary, fallback);
100
+ if (resolved) {
101
+ return resolved;
102
+ }
103
+
104
+ throw new Error(
105
+ `Localized label must include a key for the default language "${keys.defaultLang}".`
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Resolve a sidebar-style label: `translations[lang] ?? label`.
111
+ * Accepts a LocaleLookup or a bare lang string for convenience.
112
+ */
113
+ export function resolveLabel(
114
+ label: string,
115
+ translations: Record<string, string> | undefined,
116
+ langOrKeys: string | LocaleLookup
117
+ ): string {
118
+ if (typeof langOrKeys === 'string') {
119
+ return pickLocalized(translations, [langOrKeys]) || label;
120
+ }
121
+
122
+ return resolveNavLabel(label, translations, langOrKeys);
123
+ }
124
+
125
+ /**
126
+ * Resolve a title-style localized string (`string | Record<string, string>`).
127
+ * Prefers the active language/locale, then falls back to the default language.
128
+ */
129
+ export function resolveLocalizedString(
130
+ value: string | Record<string, string>,
131
+ langOrKeys: string | LocaleLookup,
132
+ defaultLang?: string
133
+ ): string {
134
+ if (typeof value === 'string') {
135
+ return value;
136
+ }
137
+
138
+ const keys: LocaleLookup =
139
+ typeof langOrKeys === 'string'
140
+ ? { lang: langOrKeys, defaultLang: defaultLang ?? langOrKeys }
141
+ : langOrKeys;
142
+
143
+ const resolved = pickLocalized(value, activeKeys(keys), fallbackKeys(keys));
144
+ if (resolved) {
145
+ return resolved;
146
+ }
147
+
148
+ throw new Error(
149
+ `Localized string must include a key for the default language "${keys.defaultLang}".`
150
+ );
151
+ }
152
+
153
+ /** Build a LocaleLookup from Starlight + Astro locale values. */
154
+ export function createLocaleLookup(options: {
155
+ lang?: string;
156
+ locale?: string;
157
+ defaultLang?: string;
158
+ defaultLocale?: string;
159
+ }): LocaleLookup {
160
+ const result: LocaleLookup = {
161
+ defaultLang: options.defaultLang || options.defaultLocale || 'en',
162
+ };
163
+
164
+ if (options.lang !== undefined) {
165
+ result.lang = options.lang;
166
+ }
167
+ if (options.locale !== undefined) {
168
+ result.locale = options.locale;
169
+ }
170
+ if (options.defaultLocale !== undefined) {
171
+ result.defaultLocale = options.defaultLocale;
172
+ }
173
+
174
+ return result;
175
+ }
package/core/plugin.ts CHANGED
@@ -3,30 +3,32 @@ import { override, COMPONENT_OVERRIDES } from './config/override';
3
3
  import { expressiveCode } from './config/expresive-code';
4
4
  import { vitePlugin } from './config/vite';
5
5
  import {
6
- LucodeStarlightConfigSchema,
7
- type LucodeStarlightConfig,
8
- type LucodeStarlightUserConfig,
6
+ GroveStarlightConfigSchema,
7
+ type GroveStarlightConfig,
8
+ type GroveStarlightUserConfig,
9
9
  } from './config/schemas';
10
10
 
11
- const parseConfig = (userConfig?: LucodeStarlightUserConfig): LucodeStarlightConfig => {
12
- const parsedConfig = LucodeStarlightConfigSchema.safeParse(userConfig ?? {});
11
+ const parseConfig = (userConfig?: GroveStarlightUserConfig): GroveStarlightConfig => {
12
+ const parsedConfig = GroveStarlightConfigSchema.safeParse(userConfig ?? {});
13
13
 
14
14
  if (!parsedConfig.success) {
15
15
  throw new Error(
16
- `The provided plugin configuration for @grove-dev/starlight is invalid.\n${parsedConfig.error.issues.map((issue) => issue.message).join('\n')}`
16
+ `The provided plugin configuration for grove-starlight is invalid.\n${parsedConfig.error.issues.map((issue) => issue.message).join('\n')}`
17
17
  );
18
18
  }
19
19
 
20
20
  return parsedConfig.data;
21
21
  };
22
22
 
23
- const plugin = (userConfig?: LucodeStarlightUserConfig): StarlightPlugin =>
23
+ const plugin = (userConfig: GroveStarlightUserConfig = {}): StarlightPlugin =>
24
24
  ({
25
- name: '@grove-dev/starlight',
25
+ name: 'grove-starlight',
26
26
  hooks: {
27
27
  'config:setup': ({ config, logger, updateConfig, addIntegration }) => {
28
+ const pluginConfig = parseConfig(userConfig);
29
+
28
30
  updateConfig({
29
- components: override(config, COMPONENT_OVERRIDES, logger),
31
+ components: override(config, pluginConfig, COMPONENT_OVERRIDES, logger),
30
32
  customCss: [
31
33
  ...(config.customCss ?? []),
32
34
  '@grove-dev/starlight/styles/layers',
@@ -37,11 +39,11 @@ const plugin = (userConfig?: LucodeStarlightUserConfig): StarlightPlugin =>
37
39
  });
38
40
 
39
41
  addIntegration({
40
- name: '@grove-dev/starlight/integration',
42
+ name: 'grove-starlight-integration',
41
43
  hooks: {
42
44
  'astro:config:setup': ({ updateConfig }) => {
43
45
  updateConfig({
44
- vite: { plugins: [vitePlugin(parseConfig(userConfig))] },
46
+ vite: { plugins: [vitePlugin(pluginConfig)] },
45
47
  });
46
48
  },
47
49
  },
@@ -0,0 +1,22 @@
1
+ import type { StarlightRouteData } from '@astrojs/starlight/route-data';
2
+
3
+ export type SidebarEntry = StarlightRouteData['sidebar'][number];
4
+ export type SidebarGroup = Extract<SidebarEntry, { type: 'group' }>;
5
+ export type SidebarLink = Extract<SidebarEntry, { type: 'link' }>;
6
+
7
+ /** Every link reachable from `entries`, at any depth. */
8
+ export function flattenSidebar(entries: SidebarEntry[]): SidebarLink[] {
9
+ return entries.flatMap((entry) =>
10
+ entry.type === 'group' ? flattenSidebar(entry.entries) : [entry]
11
+ );
12
+ }
13
+
14
+ /**
15
+ * Whether a group renders expanded.
16
+ *
17
+ * `collapsed` is the author's default, but a group holding the current page always opens, otherwise
18
+ * the reader would land on a page with no idea where they are in the tree.
19
+ */
20
+ export function isSidebarGroupOpen(group: SidebarGroup): boolean {
21
+ return !group.collapsed || flattenSidebar(group.entries).some((link) => link.isCurrent);
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grove-dev/starlight",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Grove's theme for Starlight (the Astro native documentation site generator)",
6
6
  "author": "grove-dev",
@@ -9,7 +9,7 @@
9
9
  "node": ">=22.12.0"
10
10
  },
11
11
  "readme": "README.md",
12
- "homepage": "https://grove.dev.mn/",
12
+ "homepage": "https://withgrove.dev/",
13
13
  "repository": {
14
14
  "type": "git",
15
15
  "url": "https://github.com/tortuvshin/grove.git",
@@ -21,18 +21,6 @@
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
- "files": [
25
- "components",
26
- "core",
27
- "styles",
28
- "global.d.ts",
29
- "index.ts",
30
- "schema.ts",
31
- "user-components.ts",
32
- "virtual.d.ts",
33
- "README.md",
34
- "THIRD_PARTY_LICENSES.md"
35
- ],
36
24
  "exports": {
37
25
  ".": "./index.ts",
38
26
  "./schema": "./schema.ts",
@@ -59,14 +47,10 @@
59
47
  "./package.json": "./package.json"
60
48
  },
61
49
  "peerDependencies": {
62
- "@astrojs/starlight": ">=0.38.3",
63
- "astro": ">=5.0.0"
50
+ "@astrojs/starlight": ">=0.41.4"
64
51
  },
65
52
  "dependencies": {
66
53
  "@pagefind/default-ui": "^1.5.2",
67
54
  "marked": "^18.0.2"
68
- },
69
- "scripts": {
70
- "test": "cd ../.. && pnpm exec vitest run --project starlight"
71
55
  }
72
56
  }
package/schema.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { z } from 'astro/zod';
2
+ import { markDocsSchemaLoaded } from './core/config/docs-schema';
3
+
4
+ // Lets `Hero.astro` tell "the user never wired up the schema" apart from "this page has no extra
5
+ // hero fields", so it only warns in the first case. See `core/config/docs-schema.ts`.
6
+ markDocsSchemaLoaded();
2
7
 
3
8
  export const heroLayoutSchema = z
4
9
  .enum(['centered', 'centered-top', 'split-left', 'split-right', 'banner'])
@@ -19,6 +24,32 @@ export const ExtendDocsSchema = z.object({
19
24
  link: z.string(),
20
25
  })
21
26
  .optional(),
27
+ actions: z
28
+ .array(
29
+ z.object({
30
+ /**
31
+ * Button style to use. Starlight's own `primary` and `minimal` are accepted
32
+ * as aliases of `default` and `ghost` so existing frontmatter keeps working.
33
+ *
34
+ * Requires `@astrojs/starlight >= 0.41.4`, which is the first version whose
35
+ * `docsSchema({ extend })` deep-merges instead of intersecting — an
36
+ * intersection cannot widen an enum Starlight already declares.
37
+ */
38
+ variant: z
39
+ .enum([
40
+ 'default',
41
+ 'link',
42
+ 'secondary',
43
+ 'outline',
44
+ 'ghost',
45
+ 'destructive',
46
+ 'primary',
47
+ 'minimal',
48
+ ])
49
+ .default('default'),
50
+ })
51
+ )
52
+ .default([]),
22
53
  })
23
54
  .optional(),
24
55
  });
package/styles/base.css CHANGED
@@ -1,4 +1,4 @@
1
- @layer lucode {
1
+ @layer grove {
2
2
  /* theme */
3
3
  :root {
4
4
  --tracking-tight: -0.025em;
@@ -10,6 +10,7 @@
10
10
  --radius: 0.625rem;
11
11
  --header-height: calc(var(--spacing) * 14);
12
12
  --sidebar-width: 18rem;
13
+ --content-max-width: 40rem;
13
14
  --container-max-width: 1600px;
14
15
 
15
16
  /* starlight color mappings */
@@ -54,6 +55,7 @@
54
55
  --muted-foreground: var(--gray-3);
55
56
  --accent: var(--gray-5);
56
57
  --accent-foreground: var(--foreground);
58
+ --destructive: var(--red);
57
59
  --border: var(--gray-6);
58
60
  --input: var(--gray-8);
59
61
  --ring: var(--gray-4);
@@ -330,11 +332,24 @@
330
332
  /* built-in Starlight Cards and LinkCards */
331
333
  .sl-markdown-content .card-grid {
332
334
  gap: 1rem;
333
- grid-auto-rows: 1fr;
334
335
  }
335
336
 
336
- .sl-markdown-content .card-grid > * {
337
- height: 100%;
337
+ /* Starlight sizes the two-up grid as `1fr 1fr`, which is really
338
+ `minmax(auto, 1fr)`: a track can never shrink below the min-content
339
+ width of what it holds. A card full of long inline code -- file paths
340
+ like `data/generated/records.full.json` -- therefore pushes its track
341
+ past half the content column, and the second card lands outside the
342
+ article, on top of the table of contents. `minmax(0, 1fr)` lets the
343
+ tracks shrink, and `anywhere` (not `break-word`, which does not affect
344
+ intrinsic sizing) lets the code inside them wrap to fit. */
345
+ @media (min-width: 50rem) {
346
+ .sl-markdown-content .card-grid {
347
+ grid-template-columns: repeat(2, minmax(0, 1fr));
348
+ }
349
+ }
350
+
351
+ .sl-markdown-content :is(.card, .sl-link-card) :is(code, a) {
352
+ overflow-wrap: anywhere;
338
353
  }
339
354
 
340
355
  .sl-markdown-content :is(.card, .sl-link-card) {
@@ -344,7 +359,6 @@
344
359
  background-color: var(--code-background);
345
360
  box-shadow: none;
346
361
  transition: background-color 0.15s;
347
- height: 100%;
348
362
  }
349
363
 
350
364
  .sl-markdown-content :is(.card, .sl-link-card):hover {
@@ -736,21 +750,35 @@
736
750
  display: none;
737
751
  }
738
752
 
753
+ /* A step's heading sits on the same line as its number bullet. The wrapper
754
+ * only sets a *minimum* height: a fixed `height` clips any heading that is
755
+ * taller than the bullet (an `h2` step title) or wraps to a second line on
756
+ * narrow viewports, and the following block — usually a code frame — then
757
+ * paints over the overflowing text. */
739
758
  .sl-markdown-content .sl-steps > li > .sl-heading-wrapper {
740
759
  display: flex;
741
760
  align-items: center;
742
761
  gap: 0.5rem;
743
- height: var(--bullet-size);
762
+ /* Matches the h3/h4 anchor icon: the level-h2 default (1.25rem) is
763
+ * sized for a full-size heading, not a step title. */
764
+ --sl-anchor-icon-size: 0.875rem;
744
765
  min-height: var(--bullet-size);
745
766
  transform: none;
746
767
  margin-bottom: 0;
747
768
  }
748
769
 
749
- .sl-markdown-content .sl-steps > li > .sl-heading-wrapper :is(h3, h4) {
770
+ /* Step titles render at one size whatever heading level the page picked,
771
+ * so `## Step` and `### Step` look the same inside <Steps>. */
772
+ .sl-markdown-content .sl-steps > li > .sl-heading-wrapper :is(h1, h2, h3, h4, h5, h6) {
750
773
  font-size: 1rem;
751
774
  line-height: 1.75rem;
752
775
  font-weight: 500;
753
776
  margin: 0;
777
+ /* `h2` carries a `padding-top` for section spacing further up this file;
778
+ * inside a step it would push the title off the number bullet's line.
779
+ * Only the block padding is reset — the inline padding is the room
780
+ * Starlight reserves for the anchor-link icon. */
781
+ padding-block: 0;
754
782
  }
755
783
 
756
784
  .sl-markdown-content .sl-steps > li > .sl-heading-wrapper .sl-anchor-link {
package/styles/layers.css CHANGED
@@ -1 +1 @@
1
- @layer starlight, lucode;
1
+ @layer starlight, grove;
package/styles/theme.css CHANGED
@@ -1,4 +1,4 @@
1
- @layer lucode {
1
+ @layer grove {
2
2
  :root[data-theme='dark'] {
3
3
  /* starlight gray scale */
4
4
  --foreground: oklch(98.5% 0 0); /* neutral-50 */
@@ -16,7 +16,7 @@
16
16
  --orange: oklch(83.7% 0.128 66.29);
17
17
  --orange-high: oklch(90.1% 0.076 70.697);
18
18
  --green-low: oklch(26.6% 0.065 152.934);
19
- --green: oklch(78% 0.19 152);
19
+ --green: oklch(79.2% 0.209 151.711);
20
20
  --green-high: oklch(87.1% 0.15 154.449);
21
21
  --blue-low: oklch(29.3% 0.066 243.157);
22
22
  --blue: oklch(70.7% 0.165 254.624);
@@ -1,4 +1,3 @@
1
- export { default as Card } from './components/custom/Card.astro';
2
1
  export { default as ContainerSection } from './components/custom/ContainerSection.astro';
3
2
  export { default as LinkButton } from './components/custom/LinkButton.astro';
4
3
  export { default as Dropdown } from './components/custom/dropdown';
package/virtual.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- declare module 'virtual:lucode-starlight-config' {
2
- const LucodeStarlightConfig: import('./core/config/schemas').LucodeStarlightConfig;
3
- export default LucodeStarlightConfig;
1
+ declare module 'virtual:grove-starlight-config' {
2
+ const GroveStarlightConfig: import('./core/config/schemas').GroveStarlightConfig;
3
+ export default GroveStarlightConfig;
4
4
  }
5
5
 
6
6
  declare module 'virtual:starlight/user-config' {
@@ -1,68 +0,0 @@
1
- # Third-party attributions — `@grove-dev/starlight`
2
-
3
- This package incorporates design and code from upstream projects. The
4
- contributions are credited below; reproduction of the upstream license
5
- texts follows the attribution block. The Grove project ships
6
- `@grove-dev/starlight` under the same MIT license that the rest of the
7
- monorepo uses; the third-party license texts below are reproduced here
8
- to satisfy the upstream attribution requirements.
9
-
10
- > **⚠️ TODO(decision): license compatibility check.**
11
- > The audit and the project's own README credit the upstream work as
12
- > "lucode / lucas-labs" and reference
13
- > `https://github.com/lucas-labs/@grove-dev/starlight-theme`. Before the
14
- > first publish of `@grove-dev/starlight` to npm, the project owner
15
- > must:
16
- >
17
- > 1. Confirm the exact upstream repo URL (the `lucas-labs/...` path
18
- > in the README is a placeholder; the real upstream is
19
- > `lucode-labs/lucode` or similar — verify on GitHub).
20
- > 2. Verify the upstream license (MIT? Apache-2.0? something else?)
21
- > and confirm it is compatible with the Grove monorepo's MIT
22
- > distribution.
23
- > 3. If compatible, replace the `<!-- TODO LICENSE TEXT -->` block
24
- > below with the verbatim upstream license text (most MIT projects
25
- > use the standard MIT text — the canonical version is at
26
- > <https://opensource.org/licenses/MIT>).
27
- > 4. If not compatible, either obtain a written license grant from
28
- > the upstream maintainer OR replace the derivative design with
29
- > original work before publishing.
30
- >
31
- > Tracking issue:
32
- > <https://github.com/tortuvshin/grove/issues/new?title=starlight%3A+verify+third-party+license+compatibility>
33
-
34
- ## Upstream credits
35
-
36
- The design and component overrides in this package derive from:
37
-
38
- - **[lucode](https://github.com/lucas-labs/@grove-dev/starlight-theme)** by the
39
- **lucas-labs** organization (also referenced as "Lucode" in the
40
- package's `user-components.ts` and component overrides). The theme
41
- recreates the design of [shadcn/ui](https://ui.shadcn.com/) (MIT)
42
- for use inside Astro Starlight, with custom overrides for the
43
- header, sidebar, page frame, hero, footer, search, table of
44
- contents, pagination, and Markdown content. See the package
45
- `README.md` (the "Attribution" and "Usage" sections) for the
46
- in-code pointers.
47
- - **[adrian-ub/starlight-theme-black](https://github.com/adrian-ub/starlight-theme-black)**
48
- — the earlier shadcn/ui-inspired Starlight theme that
49
- `lucas-labs/@grove-dev/starlight-theme` was based on (per the
50
- upstream README).
51
- - **[shadcn/ui](https://ui.shadcn.com/)** — the original design
52
- language that the upstream work and this package both target.
53
-
54
- ## Upstream license
55
-
56
- <!-- TODO LICENSE TEXT: paste the verbatim upstream license (typically
57
- the standard MIT text with copyright line) here once the license
58
- compatibility check above is complete. Until then this section is
59
- intentionally empty so the file can ship without misrepresenting the
60
- upstream license. -->
61
-
62
- ## License for this package
63
-
64
- `@grove-dev/starlight` is distributed under the **MIT License** — see
65
- [`../core/LICENSE`](../../LICENSE) at the monorepo root for the
66
- canonical text. The third-party attributions above are reproduced for
67
- compliance with the upstream license terms; they do not change the
68
- license of the rest of the package.
@@ -1,118 +0,0 @@
1
- ---
2
- /**
3
- * Starlight `<Card>` override that adds an optional `href` prop.
4
- *
5
- * The upstream `Card.astro` in `@astrojs/starlight@0.40` only accepts
6
- * `icon` and `title`, so a `<Card title="..." href="...">` call silently
7
- * dropped the link and rendered a non-clickable `<article>`. We wrap the
8
- * inner content in an `<a>` when `href` is present so the entire card
9
- * surface is interactive (the same affordance the `LinkCard` component
10
- * uses, but with our visual `Card` styling).
11
- *
12
- * Visual styles are identical to upstream — only the structure gains a
13
- * single `<a>` wrapper when `href` is set.
14
- */
15
- import { Icon } from '@astrojs/starlight/components';
16
- import type { StarlightIcon } from '@astrojs/starlight/components-internals/Icons';
17
-
18
- interface Props {
19
- icon?: StarlightIcon;
20
- title: string;
21
- href?: string;
22
- }
23
-
24
- const { icon, title, href } = Astro.props;
25
- ---
26
-
27
- <article class="card sl-flex">
28
- <p class="title sl-flex">
29
- {icon && <Icon name={icon} class="icon" size="1.333em" />}
30
- <span>{title}</span>
31
- </p>
32
- <div class="body">
33
- {href ? <a {href} class="sl-card-link"><slot /></a> : <slot />}
34
- </div>
35
- </article>
36
-
37
- <style>
38
- @layer starlight.components {
39
- .card {
40
- --sl-card-border: var(--sl-color-purple);
41
- --sl-card-bg: var(--sl-color-purple-low);
42
- border: 1px solid var(--sl-color-gray-5);
43
- background-color: var(--sl-color-black);
44
- padding: clamp(1rem, calc(0.125rem + 3vw), 2.5rem);
45
- flex-direction: column;
46
- gap: clamp(0.5rem, calc(0.125rem + 1vw), 1rem);
47
- transition: border-color 0.15s ease, transform 0.15s ease;
48
- }
49
- .card:nth-child(4n + 1) {
50
- --sl-card-border: var(--sl-color-orange);
51
- --sl-card-bg: var(--sl-color-orange-low);
52
- }
53
- .card:nth-child(4n + 3) {
54
- --sl-card-border: var(--sl-color-green);
55
- --sl-card-bg: var(--sl-color-green-low);
56
- }
57
- .card:nth-child(4n + 4) {
58
- --sl-card-border: var(--sl-color-red);
59
- --sl-card-bg: var(--sl-color-red-low);
60
- }
61
- .card:nth-child(4n + 5) {
62
- --sl-card-border: var(--sl-color-blue);
63
- --sl-card-bg: var(--sl-color-blue-low);
64
- }
65
- .title {
66
- font-weight: 600;
67
- font-size: var(--sl-text-h4);
68
- color: var(--sl-color-white);
69
- line-height: var(--sl-line-height-headings);
70
- gap: 1rem;
71
- align-items: center;
72
- }
73
- .card .icon {
74
- border: 1px solid var(--sl-card-border);
75
- background-color: var(--sl-card-bg);
76
- padding: 0.2em;
77
- border-radius: 0.25rem;
78
- flex-shrink: 0;
79
- }
80
- .card .body {
81
- margin: 0;
82
- font-size: clamp(var(--sl-text-sm), calc(0.5rem + 1vw), var(--sl-text-body));
83
- }
84
- .card .body .sl-card-link {
85
- /* The link is invisible text-decoration-wise; the card itself
86
- * is the click target thanks to the ::before pseudo-element
87
- * below. Display:contents lets the link wrap the body text
88
- * without breaking the flex/grid layout. */
89
- display: contents;
90
- color: inherit;
91
- }
92
- .card:has(.sl-card-link) {
93
- position: relative;
94
- cursor: pointer;
95
- }
96
- .card:has(.sl-card-link):hover {
97
- border-color: var(--sl-color-gray-2);
98
- }
99
- .card:has(.sl-card-link):hover .title {
100
- color: var(--sl-color-accent-high);
101
- }
102
- /* a11y: a transparent overlay makes the whole card clickable
103
- * without changing layout. Same trick Starlight's LinkCard uses. */
104
- .card:has(.sl-card-link) .sl-card-link::before {
105
- content: '';
106
- position: absolute;
107
- inset: 0;
108
- border-radius: inherit;
109
- }
110
- /* Focus-visible ring anchored to the card surface so keyboard
111
- * users see a clear focus indicator (otherwise the transparent
112
- * overlay hides the default link outline). */
113
- .card:has(.sl-card-link:focus-visible) {
114
- outline: 2px solid var(--sl-color-accent);
115
- outline-offset: 2px;
116
- }
117
- }
118
- </style>