@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.
- package/astro/components/AnnouncementBar.astro +3 -1
- package/astro/components/GlobalDesktopNavigation.astro +4 -2
- package/astro/components/SidebarNavigation.astro +4 -2
- package/astro/components/TableOfContents.astro +2 -1
- package/astro/components/Tabs.astro +3 -1
- package/astro/layouts/Markdown.astro +3 -1
- package/astro/layouts/Page.astro +9 -6
- package/package.json +5 -1
- package/src/i18n/index.ts +23 -0
- package/src/i18n/ui.test.ts +57 -0
- package/src/i18n/ui.ts +85 -0
- package/src/markdownProcessor.test.ts +12 -1
- package/src/markdownProcessor.ts +13 -2
- package/src/rehype/rehypeHeadingLinks.test.ts +56 -0
- package/src/rehype/rehypeHeadingLinks.ts +50 -0
- package/src/schemas/config.ts +12 -1
- package/src/styles/components.css +29 -0
|
@@ -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=
|
|
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=
|
|
17
|
+
data-tip={t.menu}
|
|
16
18
|
>
|
|
17
19
|
<label
|
|
18
|
-
aria-label=
|
|
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>
|
|
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">
|
|
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 =
|
|
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=
|
|
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>
|
|
94
|
+
<span>{t.draftNotice}</span>
|
|
93
95
|
</div>
|
|
94
96
|
)}
|
|
95
97
|
<div class={wrapperClass}><slot /></div>
|
package/astro/layouts/Page.astro
CHANGED
|
@@ -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
|
|
118
|
-
//
|
|
119
|
-
|
|
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 ??
|
|
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-
|
|
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=
|
|
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
|
|
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({
|
|
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
|
|
package/src/markdownProcessor.ts
CHANGED
|
@@ -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()`
|
|
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({
|
|
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
|
+
}
|
package/src/schemas/config.ts
CHANGED
|
@@ -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.
|
|
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;
|