@levino/shipyard-base 0.9.3-rc-20260926183647 → 0.9.3

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.
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { useUiStrings } from '../../src/i18n'
2
3
  import type { AnnouncementBar } from '../../src/schemas/config'
3
4
 
4
5
  interface Props {
@@ -6,6 +7,7 @@ interface Props {
6
7
  }
7
8
 
8
9
  const { config } = Astro.props
10
+ const t = useUiStrings(Astro.currentLocale)
9
11
  const {
10
12
  id = 'announcement-bar',
11
13
  content,
@@ -67,7 +69,7 @@ const customStyle = [
67
69
  <button
68
70
  type="button"
69
71
  class="announcement-close absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:opacity-70 transition-opacity"
70
- aria-label="Close announcement"
72
+ aria-label={t.closeAnnouncement}
71
73
  data-announcement-id={id}
72
74
  >
73
75
  <svg
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { useUiStrings } from '../../src/i18n'
2
3
  import type { Config } from '../../src/schemas/config'
3
4
  import { cn } from '../../src/tools/cn'
4
5
  import LanguageSwitcher from './LanguageSwitcher.astro'
@@ -7,15 +8,16 @@ import ThemeToggle from './ThemeToggle.astro'
7
8
  type Props = Pick<Config, 'brand' | 'navigation'> & { showBrand: boolean }
8
9
 
9
10
  const { brand, navigation, showBrand = false } = Astro.props as Props
11
+ const t = useUiStrings(Astro.currentLocale)
10
12
  ---
11
13
 
12
14
  <div class="navbar z-10 bg-base-100">
13
15
  <span
14
16
  class="tooltip tooltip-bottom before:text-xs before:content-[attr(data-tip)]"
15
- data-tip="Menu"
17
+ data-tip={t.menu}
16
18
  >
17
19
  <label
18
- aria-label="Open menu"
20
+ aria-label={t.openMenu}
19
21
  for="drawer"
20
22
  class="btn btn-square btn-ghost drawer-button lg:hidden"
21
23
  >
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { useUiStrings } from '../../src/i18n'
2
3
  import { cn } from '../../src/tools/cn'
3
4
  import LanguageSwitcher from './LanguageSwitcher.astro'
4
5
  import SidebarElement from './SidebarElement.astro'
@@ -12,6 +13,7 @@ interface Props {
12
13
  }
13
14
 
14
15
  const { local, global, brand } = Astro.props
16
+ const t = useUiStrings(Astro.currentLocale)
15
17
 
16
18
  const withLocale = (path: string) =>
17
19
  Astro.currentLocale ? `/${Astro.currentLocale}${path}` : path
@@ -31,7 +33,7 @@ const withLocale = (path: string) =>
31
33
  {
32
34
  local ? (
33
35
  <details>
34
- <summary>Main menu</summary>
36
+ <summary>{t.mainMenu}</summary>
35
37
  <SidebarElement entry={global} />
36
38
  </details>
37
39
  ) : (
@@ -42,7 +44,7 @@ const withLocale = (path: string) =>
42
44
  <slot name="sidebarExtra" />
43
45
  <LanguageSwitcher variant="list" />
44
46
  <li class="flex flex-row items-center gap-2 px-4 py-2">
45
- <span class="text-sm opacity-70">Theme</span>
47
+ <span class="text-sm opacity-70">{t.theme}</span>
46
48
  <ThemeToggle />
47
49
  </li>
48
50
  <div class={cn("divider my-1 block lg:hidden", { hidden: !local })}></div>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { useUiStrings } from '../../src/i18n'
2
3
  import { cn } from '../../src/tools/cn'
3
4
 
4
5
  interface Link {
@@ -16,7 +17,7 @@ interface Props {
16
17
 
17
18
  const {
18
19
  links,
19
- label = 'On this page',
20
+ label = useUiStrings(Astro.currentLocale).onThisPage,
20
21
  class: className,
21
22
  desktopOnly = false,
22
23
  } = Astro.props
@@ -18,6 +18,7 @@
18
18
  * </Tabs>
19
19
  * ```
20
20
  */
21
+ import { useUiStrings } from '../../src/i18n'
21
22
 
22
23
  interface Props {
23
24
  /**
@@ -39,6 +40,7 @@ interface Props {
39
40
  }
40
41
 
41
42
  const { items, groupId, queryString, defaultValue } = Astro.props
43
+ const t = useUiStrings(Astro.currentLocale)
42
44
 
43
45
  const tabId = `tabs-${Math.random().toString(36).slice(2, 9)}`
44
46
  const defaultTab = defaultValue ?? items[0]
@@ -50,7 +52,7 @@ const defaultTab = defaultValue ?? items[0]
50
52
  data-group-id={groupId}
51
53
  data-query-string={queryString === true ? groupId : (queryString || undefined)}
52
54
  >
53
- <div class="shipyard-tabs-list" role="tablist" aria-label="Content tabs">
55
+ <div class="shipyard-tabs-list" role="tablist" aria-label={t.contentTabs}>
54
56
  {items.map((item, index) => (
55
57
  <button
56
58
  type="button"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import type { ImageMetadata } from 'astro'
3
+ import { useUiStrings } from '../../src/i18n'
3
4
  import type { NavigationTree } from '../../src/schemas/config'
4
5
  import Base from './Page.astro'
5
6
 
@@ -50,6 +51,7 @@ type Props = {
50
51
  }
51
52
 
52
53
  const props = Astro.props
54
+ const t = useUiStrings(Astro.currentLocale)
53
55
  const frontmatter = props.frontmatter
54
56
 
55
57
  // Check if this is a draft page in production
@@ -89,7 +91,7 @@ const allMetaTags = [
89
91
  <Base {...pageProps} customMetaTags={allMetaTags}>
90
92
  {isDraft && (
91
93
  <div class="alert alert-warning mb-4">
92
- <span>This page is a draft and will not be visible in production.</span>
94
+ <span>{t.draftNotice}</span>
93
95
  </div>
94
96
  )}
95
97
  <div class={wrapperClass}><slot /></div>
@@ -5,6 +5,7 @@ import { i18n } from 'astro:config/server'
5
5
  import config from 'virtual:shipyard/config'
6
6
  import type { ImageMetadata } from 'astro'
7
7
  import { mapObjIndexed } from 'ramda'
8
+ import { getPageLocale, getUiStrings } from '../../src/i18n'
8
9
  import type {
9
10
  NavigationEntry,
10
11
  NavigationTree,
@@ -114,9 +115,11 @@ const navigation = applyLocaleAndSetActive(config.navigation)
114
115
  const title = getTitle(config.title, props.title)
115
116
  const hasSidebar = !!props.sidebarNavigation
116
117
 
117
- // Language for the <html> element: current locale on i18n sites, otherwise the
118
- // configured default locale (omitted entirely when i18n is disabled).
119
- const htmlLang = Astro.currentLocale ?? (i18n ? i18n.defaultLocale : undefined)
118
+ // Language for the <html> element and shipyard's UI labels: current locale on
119
+ // i18n sites, otherwise the i18n default locale, otherwise `locale` from the
120
+ // shipyard config (omitted entirely when none is set).
121
+ const htmlLang = getPageLocale(Astro.currentLocale)
122
+ const t = getUiStrings(htmlLang)
120
123
 
121
124
  // Absolute URL of the current page (no query string), used for og:url and as
122
125
  // the self-referential canonical when no explicit canonicalUrl is provided.
@@ -202,12 +205,12 @@ const renderScriptAttributes = (script: Script) => {
202
205
  href="#main-content"
203
206
  class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 btn btn-primary btn-sm"
204
207
  >
205
- {config.skipToContentLabel ?? 'Skip to content'}
208
+ {config.skipToContentLabel ?? t.skipToContent}
206
209
  </a>
207
210
  {config.announcementBar && <AnnouncementBar config={config.announcementBar} />}
208
211
  <div class={cn("drawer grow", { "lg:drawer-open": hasSidebar })}>
209
212
  <input id="drawer" type="checkbox" class="drawer-toggle" />
210
- <div class="drawer-content flex flex-col overflow-x-hidden">
213
+ <div class="drawer-content flex flex-col overflow-x-clip">
211
214
  <GlobalDesktopNavigation
212
215
  showBrand={!hasSidebar}
213
216
  brand={config.brand}
@@ -223,7 +226,7 @@ const renderScriptAttributes = (script: Script) => {
223
226
  </div>
224
227
 
225
228
  <div class="drawer-side z-40">
226
- <label for="drawer" aria-label="close sidebar" class="drawer-overlay"
229
+ <label for="drawer" aria-label={t.closeSidebar} class="drawer-overlay"
227
230
  ></label>
228
231
  <SidebarNavigation
229
232
  brand={config.brand}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levino/shipyard-base",
3
- "version": "0.9.3-rc-20260926183647",
3
+ "version": "0.9.3",
4
4
  "description": "Core layouts, components, and configuration for shipyard - a composable page builder for Astro",
5
5
  "keywords": [
6
6
  "astro",
@@ -32,6 +32,7 @@
32
32
  "./layouts/*": "./astro/layouts/*",
33
33
  "./components": "./astro/components/index.ts",
34
34
  "./components/*": "./astro/components/*",
35
+ "./i18n": "./src/i18n/index.ts",
35
36
  "./remark": "./src/remark/index.ts",
36
37
  "./shiki": "./src/shiki/index.ts",
37
38
  "./globals.css": "./src/globals.css",
@@ -56,10 +57,13 @@
56
57
  "@tailwindcss/typography": "^0.5.20",
57
58
  "@types/ramda": "^0.32",
58
59
  "daisyui": "^5",
60
+ "hast-util-from-html": "^2.0.3",
61
+ "hast-util-to-html": "^9.0.5",
59
62
  "remark": "^15.0.1",
60
63
  "remark-html": "^16.0.1",
61
64
  "tailwindcss": "^4",
62
65
  "typescript": "^7.0.2",
66
+ "vfile": "^6.0.3",
63
67
  "vite": "^8.3.1",
64
68
  "vitest": "^5.0.2"
65
69
  },
@@ -0,0 +1,23 @@
1
+ import { i18n } from 'astro:config/server'
2
+ import config from 'virtual:shipyard/config'
3
+ import { getUiStrings, resolveLocale } from './ui'
4
+
5
+ export type { UiStringKey, UiStrings } from './ui'
6
+ export { formatUiString, getUiStrings, resolveLocale, uiStrings } from './ui'
7
+
8
+ /**
9
+ * Locale of the page being rendered. Pass `Astro.currentLocale`.
10
+ */
11
+ export const getPageLocale = (currentLocale: string | undefined) =>
12
+ resolveLocale({
13
+ currentLocale,
14
+ i18nDefaultLocale: i18n?.defaultLocale,
15
+ configLocale: config.locale,
16
+ })
17
+
18
+ /**
19
+ * shipyard's UI labels in the language of the page being rendered.
20
+ * Pass `Astro.currentLocale`.
21
+ */
22
+ export const useUiStrings = (currentLocale: string | undefined) =>
23
+ getUiStrings(getPageLocale(currentLocale))
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatUiString, getUiStrings, resolveLocale, uiStrings } from './ui'
3
+
4
+ describe('getUiStrings', () => {
5
+ it('returns English labels without a locale', () => {
6
+ expect(getUiStrings(undefined).onThisPage).toBe('On this page')
7
+ })
8
+
9
+ it('returns German labels for de', () => {
10
+ expect(getUiStrings('de').onThisPage).toBe('Auf dieser Seite')
11
+ })
12
+
13
+ it('falls back from a region subtag to the language', () => {
14
+ expect(getUiStrings('de-AT').onThisPage).toBe('Auf dieser Seite')
15
+ expect(getUiStrings('DE').next).toBe('Weiter')
16
+ })
17
+
18
+ it('falls back to English for unknown languages', () => {
19
+ expect(getUiStrings('xx').onThisPage).toBe('On this page')
20
+ })
21
+
22
+ it('has the same keys in every language', () => {
23
+ const keys = Object.keys(uiStrings.en).sort()
24
+ for (const strings of Object.values(uiStrings)) {
25
+ expect(Object.keys(strings).sort()).toEqual(keys)
26
+ }
27
+ })
28
+ })
29
+
30
+ describe('resolveLocale', () => {
31
+ it('prefers the current route locale', () => {
32
+ expect(
33
+ resolveLocale({
34
+ currentLocale: 'en',
35
+ i18nDefaultLocale: 'de',
36
+ configLocale: 'fr',
37
+ }),
38
+ ).toBe('en')
39
+ })
40
+
41
+ it('falls back to the i18n default locale, then the config locale', () => {
42
+ expect(resolveLocale({ i18nDefaultLocale: 'de', configLocale: 'fr' })).toBe(
43
+ 'de',
44
+ )
45
+ expect(resolveLocale({ configLocale: 'de' })).toBe('de')
46
+ expect(resolveLocale({})).toBeUndefined()
47
+ })
48
+ })
49
+
50
+ describe('formatUiString', () => {
51
+ it('fills placeholders and keeps unknown ones', () => {
52
+ expect(formatUiString('von {author}', { author: 'Levin' })).toBe(
53
+ 'von Levin',
54
+ )
55
+ expect(formatUiString('{missing}', {})).toBe('{missing}')
56
+ })
57
+ })
package/src/i18n/ui.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Built-in labels of shipyard's UI (table of contents, navigation, docs
3
+ * footer, …). Pure data and helpers so they can be unit-tested; the Astro
4
+ * glue that picks the page's locale lives in `./index.ts`.
5
+ */
6
+ export const uiStrings = {
7
+ en: {
8
+ onThisPage: 'On this page',
9
+ skipToContent: 'Skip to content',
10
+ menu: 'Menu',
11
+ openMenu: 'Open menu',
12
+ closeSidebar: 'Close sidebar',
13
+ mainMenu: 'Main menu',
14
+ theme: 'Theme',
15
+ previous: 'Previous',
16
+ next: 'Next',
17
+ docsNavigation: 'Docs navigation',
18
+ lastUpdated: 'Last updated:',
19
+ lastUpdatedBy: 'by {author}',
20
+ editThisPage: 'Edit this page',
21
+ draftNotice: 'This page is a draft and will not be visible in production.',
22
+ closeAnnouncement: 'Close announcement',
23
+ contentTabs: 'Content tabs',
24
+ },
25
+ de: {
26
+ onThisPage: 'Auf dieser Seite',
27
+ skipToContent: 'Zum Inhalt springen',
28
+ menu: 'Menü',
29
+ openMenu: 'Menü öffnen',
30
+ closeSidebar: 'Seitenleiste schließen',
31
+ mainMenu: 'Hauptmenü',
32
+ theme: 'Design',
33
+ previous: 'Zurück',
34
+ next: 'Weiter',
35
+ docsNavigation: 'Navigation in der Dokumentation',
36
+ lastUpdated: 'Zuletzt aktualisiert:',
37
+ lastUpdatedBy: 'von {author}',
38
+ editThisPage: 'Diese Seite bearbeiten',
39
+ draftNotice:
40
+ 'Diese Seite ist ein Entwurf und erscheint nicht in der Produktion.',
41
+ closeAnnouncement: 'Ankündigung schließen',
42
+ contentTabs: 'Inhalts-Tabs',
43
+ },
44
+ } as const satisfies Record<string, Record<string, string>>
45
+
46
+ export type UiStringKey = keyof (typeof uiStrings)['en']
47
+ export type UiStrings = Record<UiStringKey, string>
48
+
49
+ export const defaultUiLocale = 'en'
50
+
51
+ /**
52
+ * Returns the UI labels for a locale. Region subtags fall back to the
53
+ * language (`de-AT` → `de`); unknown languages fall back to English.
54
+ */
55
+ export const getUiStrings = (locale: string | undefined): UiStrings => {
56
+ if (!locale) return uiStrings.en
57
+ const normalized = locale.toLowerCase()
58
+ const language = normalized.split('-')[0] ?? normalized
59
+ const strings =
60
+ (uiStrings as Record<string, UiStrings>)[normalized] ??
61
+ (uiStrings as Record<string, UiStrings>)[language]
62
+ return strings ?? uiStrings.en
63
+ }
64
+
65
+ /**
66
+ * The locale a page is rendered in: the current i18n route locale, else
67
+ * Astro's i18n default locale, else the `locale` set in the shipyard config.
68
+ * `undefined` when none of them is set.
69
+ */
70
+ export const resolveLocale = ({
71
+ currentLocale,
72
+ i18nDefaultLocale,
73
+ configLocale,
74
+ }: {
75
+ currentLocale?: string | undefined
76
+ i18nDefaultLocale?: string | undefined
77
+ configLocale?: string | undefined
78
+ }): string | undefined => currentLocale ?? i18nDefaultLocale ?? configLocale
79
+
80
+ /** Replaces `{name}` placeholders in a label. */
81
+ export const formatUiString = (
82
+ template: string,
83
+ values: Record<string, string>,
84
+ ): string =>
85
+ template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
@@ -11,6 +11,7 @@ import shipyard from './index'
11
11
  import type { Config } from './schemas/config'
12
12
 
13
13
  const SHIPYARD_PLUGIN_COUNT = 3
14
+ const SHIPYARD_REHYPE_PLUGIN_COUNT = 1
14
15
 
15
16
  interface ConfigUpdate {
16
17
  markdown?: {
@@ -58,6 +59,9 @@ describe('markdown processor', () => {
58
59
  expect(update?.markdown?.processor?.options.remarkPlugins).toHaveLength(
59
60
  SHIPYARD_PLUGIN_COUNT,
60
61
  )
62
+ expect(update?.markdown?.processor?.options.rehypePlugins).toHaveLength(
63
+ SHIPYARD_REHYPE_PLUGIN_COUNT,
64
+ )
61
65
  expect(logger.warn).not.toHaveBeenCalled()
62
66
  })
63
67
 
@@ -69,13 +73,20 @@ describe('markdown processor', () => {
69
73
 
70
74
  test("keeps the user's own plugins on an existing unified processor", () => {
71
75
  const userPlugin = () => undefined
76
+ const userRehypePlugin = () => undefined
72
77
  const { update, logger } = runConfigSetup(
73
- unified({ remarkPlugins: [userPlugin] }),
78
+ unified({
79
+ remarkPlugins: [userPlugin],
80
+ rehypePlugins: [userRehypePlugin],
81
+ }),
74
82
  )
75
83
 
76
84
  const plugins = update?.markdown?.processor?.options.remarkPlugins
77
85
  expect(plugins).toHaveLength(SHIPYARD_PLUGIN_COUNT + 1)
78
86
  expect(plugins?.[0]).toBe(userPlugin)
87
+ const rehypePlugins = update?.markdown?.processor?.options.rehypePlugins
88
+ expect(rehypePlugins).toHaveLength(SHIPYARD_REHYPE_PLUGIN_COUNT + 1)
89
+ expect(rehypePlugins?.[0]).toBe(userRehypePlugin)
79
90
  expect(logger.warn).not.toHaveBeenCalled()
80
91
  })
81
92
 
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from 'node:util'
2
2
  import { isUnifiedProcessor, unified } from '@astrojs/markdown-remark'
3
3
  import { satteri } from '@astrojs/markdown-satteri'
4
4
  import type { AstroConfig, AstroIntegrationLogger } from 'astro'
5
+ import { rehypeHeadingLinks } from './rehype/rehypeHeadingLinks'
5
6
  import { remarkAdmonitions } from './remark/remarkAdmonitions'
6
7
  import { remarkBlockDirective } from './remark/remarkBlockDirective'
7
8
  import { remarkNpm2Yarn } from './remark/remarkNpm2Yarn'
@@ -14,6 +15,8 @@ const shipyardRemarkPlugins = [
14
15
  remarkNpm2Yarn,
15
16
  ]
16
17
 
18
+ const shipyardRehypePlugins = [rehypeHeadingLinks]
19
+
17
20
  // Astro fills in a fresh `satteri()` when `markdown.processor` is unset, so
18
21
  // the resolved config cannot tell that default apart from an explicit bare
19
22
  // `satteri()`. Both render identically, so both count as "not chosen".
@@ -27,7 +30,8 @@ const isAstroDefault = (processor: MarkdownProcessor) => {
27
30
 
28
31
  /**
29
32
  * shipyard's admonitions, npm2yarn tabs and block directives are remark
30
- * plugins, and only the `unified()` processor runs those.
33
+ * plugins, and the linked headings a rehype plugin; only the `unified()`
34
+ * processor runs those.
31
35
  *
32
36
  * - Astro's default processor is replaced with `unified()` carrying the
33
37
  * shipyard plugins.
@@ -49,11 +53,18 @@ export const shipyardMarkdownProcessor = (
49
53
  ...current.options.remarkPlugins,
50
54
  ...shipyardRemarkPlugins,
51
55
  ],
56
+ rehypePlugins: [
57
+ ...current.options.rehypePlugins,
58
+ ...shipyardRehypePlugins,
59
+ ],
52
60
  })
53
61
  }
54
62
 
55
63
  if (isAstroDefault(current)) {
56
- return unified({ remarkPlugins: shipyardRemarkPlugins })
64
+ return unified({
65
+ remarkPlugins: shipyardRemarkPlugins,
66
+ rehypePlugins: shipyardRehypePlugins,
67
+ })
57
68
  }
58
69
 
59
70
  logger.warn(
@@ -0,0 +1,56 @@
1
+ import { fromHtml } from 'hast-util-from-html'
2
+ import { toHtml } from 'hast-util-to-html'
3
+ import { unified } from 'unified'
4
+ import { VFile } from 'vfile'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { rehypeHeadingLinks } from './rehypeHeadingLinks'
7
+
8
+ const run = async (html: string) => {
9
+ const processor = unified().use(rehypeHeadingLinks)
10
+ const tree = await processor.run(
11
+ fromHtml(html, { fragment: true }),
12
+ new VFile(),
13
+ )
14
+ return toHtml(tree)
15
+ }
16
+
17
+ describe('rehypeHeadingLinks', () => {
18
+ it('links h2 to h4 to their own anchor', async () => {
19
+ expect(await run('<h2>Getting started</h2><h4>Fine print</h4>')).toBe(
20
+ '<h2 id="getting-started"><a href="#getting-started" class="heading-link">Getting started</a></h2>' +
21
+ '<h4 id="fine-print"><a href="#fine-print" class="heading-link">Fine print</a></h4>',
22
+ )
23
+ })
24
+
25
+ it('keeps existing ids and uses them as the link target', async () => {
26
+ expect(await run('<h3 id="custom">Title</h3>')).toBe(
27
+ '<h3 id="custom"><a href="#custom" class="heading-link">Title</a></h3>',
28
+ )
29
+ })
30
+
31
+ it('creates the same slugs as Astro, including umlauts and duplicates', async () => {
32
+ const html = await run(
33
+ '<h2>Vorgaben fürs Angebot</h2><h2>Vorgaben fürs Angebot</h2>',
34
+ )
35
+ expect(html).toContain('href="#vorgaben-fürs-angebot"')
36
+ expect(html).toContain('href="#vorgaben-fürs-angebot-1"')
37
+ })
38
+
39
+ it('keeps inline markup inside the link', async () => {
40
+ expect(await run('<h2>Use <code>npm</code></h2>')).toBe(
41
+ '<h2 id="use-npm"><a href="#use-npm" class="heading-link">Use <code>npm</code></a></h2>',
42
+ )
43
+ })
44
+
45
+ it('leaves h1, h5 and h6 alone', async () => {
46
+ expect(await run('<h1>Title</h1><h5>Small</h5>')).toBe(
47
+ '<h1 id="title">Title</h1><h5 id="small">Small</h5>',
48
+ )
49
+ })
50
+
51
+ it('does not nest links', async () => {
52
+ expect(await run('<h2><a href="/x">Linked</a></h2>')).toBe(
53
+ '<h2 id="linked"><a href="/x">Linked</a></h2>',
54
+ )
55
+ })
56
+ })
@@ -0,0 +1,50 @@
1
+ import { rehypeHeadingIds } from '@astrojs/markdown-remark'
2
+ import type { Element, ElementContent, Root } from 'hast'
3
+ import type { Plugin } from 'unified'
4
+ import { visit } from 'unist-util-visit'
5
+
6
+ /** Headings that get a link to their own anchor. */
7
+ const LINKED_HEADINGS = new Set(['h2', 'h3', 'h4'])
8
+
9
+ /** CSS class of the link that wraps a heading's content. */
10
+ export const HEADING_LINK_CLASS = 'heading-link'
11
+
12
+ const containsLink = (nodes: ElementContent[]): boolean =>
13
+ nodes.some(
14
+ (node) =>
15
+ node.type === 'element' &&
16
+ (node.tagName === 'a' || containsLink(node.children)),
17
+ )
18
+
19
+ /**
20
+ * Turns the content of every `h2`–`h4` into a link to the heading's own
21
+ * anchor (`<h2 id="setup"><a href="#setup" class="heading-link">Setup</a></h2>`),
22
+ * so readers can click a heading and copy its URL from the address bar.
23
+ *
24
+ * Heading IDs are normally added by Astro after all user rehype plugins have
25
+ * run. This plugin adds them first with Astro's own `rehypeHeadingIds`, so the
26
+ * IDs, the slugs in the table of contents and the link targets stay
27
+ * identical. Headings that already contain a link are left alone, because
28
+ * links cannot be nested.
29
+ */
30
+ export const rehypeHeadingLinks: Plugin<[], Root> = () => {
31
+ const addIds = rehypeHeadingIds()
32
+ return (tree, file) => {
33
+ // Astro's transformer is synchronous; the callback is never needed.
34
+ addIds?.(tree, file, () => {})
35
+ visit(tree, 'element', (node: Element) => {
36
+ if (!LINKED_HEADINGS.has(node.tagName)) return
37
+ const id = node.properties?.id
38
+ if (typeof id !== 'string' || id === '') return
39
+ if (node.children.length === 0 || containsLink(node.children)) return
40
+ node.children = [
41
+ {
42
+ type: 'element',
43
+ tagName: 'a',
44
+ properties: { href: `#${id}`, className: [HEADING_LINK_CLASS] },
45
+ children: node.children,
46
+ },
47
+ ]
48
+ })
49
+ }
50
+ }
@@ -226,9 +226,20 @@ export interface Config {
226
226
  * @example 'https://example.com/og.jpg'
227
227
  */
228
228
  defaultImage?: string
229
+ /**
230
+ * Language of the site when Astro's `i18n` routing is not configured, as a
231
+ * BCP 47 tag (e.g. `'de'`). Sets `<html lang>` and the language of
232
+ * shipyard's built-in labels such as "On this page", "Next" or
233
+ * "Edit this page" (English and German ship with shipyard; other languages
234
+ * fall back to English). With `i18n` configured, the route's locale wins.
235
+ *
236
+ * @example 'de'
237
+ */
238
+ locale?: string
229
239
  /**
230
240
  * Label for the accessibility "skip to content" link, rendered as the first
231
- * focusable element on every page. Set this to localize it.
241
+ * focusable element on every page. Overrides the built-in label for the
242
+ * page's locale.
232
243
  * @default 'Skip to content'
233
244
  */
234
245
  skipToContentLabel?: string
@@ -18,6 +18,35 @@
18
18
  @apply block overflow-x-auto;
19
19
  }
20
20
 
21
+ /*
22
+ * Linked headings (rehypeHeadingLinks): the whole heading links to its own
23
+ * anchor. It keeps the heading's look; a "#" fades in on hover and keyboard
24
+ * focus. The "#" always takes its space, so nothing shifts, and its empty
25
+ * alt text keeps it out of the link's accessible name.
26
+ */
27
+ .prose :where(h1, h2, h3, h4, h5, h6)[id] {
28
+ scroll-margin-top: 5rem;
29
+ }
30
+
31
+ .prose a.heading-link {
32
+ color: inherit;
33
+ font-weight: inherit;
34
+ text-decoration: none;
35
+ }
36
+
37
+ .prose a.heading-link::after {
38
+ content: "#" / "";
39
+ margin-inline-start: 0.35em;
40
+ color: var(--color-primary);
41
+ opacity: 0;
42
+ transition: opacity 0.15s;
43
+ }
44
+
45
+ .prose a.heading-link:hover::after,
46
+ .prose a.heading-link:focus-visible::after {
47
+ opacity: 1;
48
+ }
49
+
21
50
  /* Admonitions / Callouts */
22
51
  .admonition {
23
52
  @apply my-4 rounded-lg border-l-4 p-4;