@levino/shipyard-base 0.5.9 → 0.5.10

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @levino/shipyard-base
2
2
 
3
- Core package for [Shipyard](https://shipyard.levinkeller.de), a general-purpose page builder for Astro.
3
+ Core package for [shipyard](https://shipyard.levinkeller.de), a general-purpose page builder for Astro.
4
4
 
5
5
  ## Installation
6
6
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import type { Config } from '../../src/schemas/config'
3
3
  import { cn } from '../../src/tools/cn'
4
+ import LanguageSwitcher from './LanguageSwitcher.astro'
4
5
 
5
6
  type Props = Pick<Config, 'brand' | 'navigation'> & { showBrand: boolean }
6
7
 
@@ -68,5 +69,6 @@ const { brand, navigation, showBrand = false } = Astro.props as Props
68
69
  )
69
70
  }
70
71
  </ul>
72
+ <LanguageSwitcher variant="dropdown" />
71
73
  </div>
72
74
  </div>
@@ -0,0 +1,96 @@
1
+ ---
2
+ /**
3
+ * Language switcher component that displays available locales.
4
+ * Reads locales from Astro's i18n config via virtual module.
5
+ */
6
+ import { locales } from 'virtual:shipyard/locales'
7
+
8
+ interface Props {
9
+ /** Additional CSS classes */
10
+ class?: string
11
+ /** Variant: 'dropdown' for navbar, 'list' for sidebar */
12
+ variant?: 'dropdown' | 'list'
13
+ }
14
+
15
+ const { class: className, variant = 'dropdown' } = Astro.props
16
+
17
+ const currentLocale = Astro.currentLocale ?? locales[0]
18
+ const currentPath = Astro.url.pathname
19
+
20
+ // Build the path for other locales by replacing the current locale
21
+ const getLocalePath = (locale: string) => {
22
+ if (!Astro.currentLocale) {
23
+ return `/${locale}${currentPath}`
24
+ }
25
+ return currentPath.replace(`/${currentLocale}`, `/${locale}`)
26
+ }
27
+
28
+ // Locale display names
29
+ const localeNames: Record<string, string> = {
30
+ en: 'English',
31
+ de: 'Deutsch',
32
+ fr: 'Français',
33
+ es: 'Español',
34
+ it: 'Italiano',
35
+ pt: 'Português',
36
+ nl: 'Nederlands',
37
+ pl: 'Polski',
38
+ ru: 'Русский',
39
+ ja: '日本語',
40
+ zh: '中文',
41
+ ko: '한국어',
42
+ }
43
+
44
+ const hasMultipleLocales = locales.length > 1
45
+ ---
46
+
47
+ {hasMultipleLocales && variant === 'dropdown' && (
48
+ <div class:list={["dropdown dropdown-end", className]} data-testid="language-switcher">
49
+ <div tabindex="0" role="button" class="btn btn-ghost gap-1">
50
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
51
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
52
+ </svg>
53
+ <span class="hidden sm:inline">{localeNames[currentLocale] ?? currentLocale}</span>
54
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
56
+ </svg>
57
+ </div>
58
+ <ul tabindex="0" class="dropdown-content menu rounded-box z-50 mt-3 w-40 bg-base-100 p-2 shadow">
59
+ {locales.map((locale) => (
60
+ <li>
61
+ <a
62
+ href={getLocalePath(locale)}
63
+ class:list={[{ "active": locale === currentLocale }]}
64
+ >
65
+ {localeNames[locale] ?? locale}
66
+ </a>
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ </div>
71
+ )}
72
+
73
+ {hasMultipleLocales && variant === 'list' && (
74
+ <li class={className} data-testid="language-switcher">
75
+ <details>
76
+ <summary class="gap-2">
77
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
78
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
79
+ </svg>
80
+ {localeNames[currentLocale] ?? currentLocale}
81
+ </summary>
82
+ <ul>
83
+ {locales.map((locale) => (
84
+ <li>
85
+ <a
86
+ href={getLocalePath(locale)}
87
+ class:list={[{ "active": locale === currentLocale }]}
88
+ >
89
+ {localeNames[locale] ?? locale}
90
+ </a>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ </details>
95
+ </li>
96
+ )}
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { cn } from '../../src/tools/cn'
3
+ import LanguageSwitcher from './LanguageSwitcher.astro'
3
4
  import SidebarElement from './SidebarElement.astro'
4
5
  import type { Entry } from './types'
5
6
 
@@ -15,13 +16,13 @@ const withLocale = (path: string) =>
15
16
  Astro.currentLocale ? `/${Astro.currentLocale}${path}` : path
16
17
  ---
17
18
 
18
- <ul class={cn("menu min-h-screen w-56 bg-base-100", { "md:hidden": !local })}>
19
+ <ul class={cn("menu min-h-screen w-56 bg-base-100", { "md:hidden": !local })} data-testid="sidebar-navigation">
19
20
  <div>
20
21
  <a href={withLocale("/")} class="btn btn-ghost mb-2 text-xl">
21
22
  {brand}
22
23
  </a>
23
24
  </div>
24
- <div class="block md:hidden">
25
+ <div class="block md:hidden" data-testid="sidebar-global-nav">
25
26
  <li>
26
27
  {
27
28
  local ? (
@@ -34,7 +35,8 @@ const withLocale = (path: string) =>
34
35
  )
35
36
  }
36
37
  </li>
38
+ <LanguageSwitcher variant="list" />
37
39
  <div class={cn("divider my-1 block md:hidden", { hidden: !local })}></div>
38
40
  </div>
39
- {local && <SidebarElement entry={local} />}
41
+ {local && <div data-testid="sidebar-local-nav"><SidebarElement entry={local} /></div>}
40
42
  </ul>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { NavigationTree } from '../../src/schemas/config'
3
+ import Base from './Page.astro'
4
+
5
+ type Props = {
6
+ frontmatter?: {
7
+ title?: string
8
+ description?: string
9
+ sidebarNavigation?: NavigationTree
10
+ }
11
+ }
12
+
13
+ const props = Astro.props
14
+ ---
15
+
16
+ <Base {...props}>
17
+ <div class="prose mx-auto"><slot /></div>
18
+ </Base>
@@ -14,5 +14,5 @@ const props = Astro.props
14
14
  ---
15
15
 
16
16
  <Base {...props}>
17
- <div class="prose mx-auto"><slot /></div>
17
+ <div class="mx-auto max-w-4xl px-4"><slot /></div>
18
18
  </Base>
@@ -1,3 +1,4 @@
1
1
  export { default as Footer } from './Footer.astro'
2
+ export { default as Markdown } from './Markdown.astro'
2
3
  export { default as Page } from './Page.astro'
3
4
  export { default as Splash } from './Splash.astro'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levino/shipyard-base",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -7,27 +7,35 @@ export { getTitle } from './tools/title'
7
7
  export * from './types'
8
8
 
9
9
  const shipyardConfigId = 'virtual:shipyard/config'
10
+ const shipyardLocalesId = 'virtual:shipyard/locales'
10
11
 
11
12
  const resolveId: Record<string, string | undefined> = {
12
13
  [shipyardConfigId]: `${shipyardConfigId}`,
14
+ [shipyardLocalesId]: `${shipyardLocalesId}`,
13
15
  }
14
16
 
15
- const load = (config: Config) =>
16
- ({
17
- [shipyardConfigId]: `export default ${JSON.stringify(config)}`,
18
- }) as Record<string, string | undefined>
19
-
20
17
  export default (config: Config): AstroIntegration => ({
21
18
  name: 'shipyard',
22
19
  hooks: {
23
- 'astro:config:setup': ({ updateConfig }) => {
20
+ 'astro:config:setup': ({ updateConfig, config: astroConfig }) => {
21
+ // Extract locales from Astro's i18n config
22
+ const locales = astroConfig.i18n?.locales ?? []
23
+ const localeList = locales.map((locale) =>
24
+ typeof locale === 'string' ? locale : locale.path,
25
+ )
26
+
27
+ const load = {
28
+ [shipyardConfigId]: `export default ${JSON.stringify(config)}`,
29
+ [shipyardLocalesId]: `export const locales = ${JSON.stringify(localeList)}; export default ${JSON.stringify(localeList)};`,
30
+ } as Record<string, string | undefined>
31
+
24
32
  updateConfig({
25
33
  vite: {
26
34
  plugins: [
27
35
  {
28
36
  name: 'shipyard',
29
37
  resolveId: (id: string) => resolveId[id],
30
- load: (id: string) => load(config)[id],
38
+ load: (id: string) => load[id],
31
39
  },
32
40
  ],
33
41
  },
@@ -2,45 +2,45 @@ import { describe, expect, test } from 'vitest'
2
2
  import { getTitle } from './title'
3
3
 
4
4
  describe('getTitle', () => {
5
- const siteTitle = 'Shipyard'
5
+ const siteTitle = 'shipyard'
6
6
 
7
7
  test('returns site title when page title is undefined', () => {
8
- expect(getTitle(siteTitle, undefined)).toBe('Shipyard')
8
+ expect(getTitle(siteTitle, undefined)).toBe('shipyard')
9
9
  })
10
10
 
11
11
  test('returns site title when page title is null', () => {
12
- expect(getTitle(siteTitle, null)).toBe('Shipyard')
12
+ expect(getTitle(siteTitle, null)).toBe('shipyard')
13
13
  })
14
14
 
15
15
  test('returns site title when page title is empty', () => {
16
- expect(getTitle(siteTitle, '')).toBe('Shipyard')
16
+ expect(getTitle(siteTitle, '')).toBe('shipyard')
17
17
  })
18
18
 
19
19
  test('returns site title when page title equals site title', () => {
20
- expect(getTitle(siteTitle, 'Shipyard')).toBe('Shipyard')
20
+ expect(getTitle(siteTitle, 'shipyard')).toBe('shipyard')
21
21
  })
22
22
 
23
23
  test('returns combined title when page title differs', () => {
24
- expect(getTitle(siteTitle, 'Blog')).toBe('Shipyard - Blog')
24
+ expect(getTitle(siteTitle, 'Blog')).toBe('shipyard - Blog')
25
25
  expect(getTitle(siteTitle, 'First Blog Post')).toBe(
26
- 'Shipyard - First Blog Post',
26
+ 'shipyard - First Blog Post',
27
27
  )
28
28
  })
29
29
 
30
30
  test('handles whitespace-only page titles', () => {
31
- expect(getTitle(siteTitle, ' ')).toBe('Shipyard')
31
+ expect(getTitle(siteTitle, ' ')).toBe('shipyard')
32
32
  })
33
33
 
34
34
  test('trims page title before comparison', () => {
35
- expect(getTitle(siteTitle, ' Shipyard ')).toBe('Shipyard')
36
- expect(getTitle(siteTitle, ' Blog ')).toBe('Shipyard - Blog')
35
+ expect(getTitle(siteTitle, ' shipyard ')).toBe('shipyard')
36
+ expect(getTitle(siteTitle, ' Blog ')).toBe('shipyard - Blog')
37
37
  })
38
38
 
39
39
  test('is case sensitive', () => {
40
- expect(getTitle(siteTitle, 'shipyard')).toBe('Shipyard - shipyard')
40
+ expect(getTitle(siteTitle, 'SHIPYARD')).toBe('shipyard - SHIPYARD')
41
41
  })
42
42
 
43
43
  test('handles special characters', () => {
44
- expect(getTitle(siteTitle, 'FAQ & Help')).toBe('Shipyard - FAQ & Help')
44
+ expect(getTitle(siteTitle, 'FAQ & Help')).toBe('shipyard - FAQ & Help')
45
45
  })
46
46
  })
@@ -0,0 +1,10 @@
1
+ declare module 'virtual:shipyard/config' {
2
+ import type { Config } from './schemas/config'
3
+ const config: Config
4
+ export default config
5
+ }
6
+
7
+ declare module 'virtual:shipyard/locales' {
8
+ export const locales: string[]
9
+ export default locales
10
+ }