@levino/shipyard-base 0.0.0-rc-20251122114952

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.
@@ -0,0 +1,63 @@
1
+ ---
2
+ import type { Entry } from './types'
3
+
4
+ interface BreadcrumbItem {
5
+ label: string
6
+ href?: string
7
+ }
8
+
9
+ interface Props {
10
+ navigation: Entry
11
+ currentPath?: string
12
+ }
13
+
14
+ const { navigation, currentPath = Astro.url.pathname } = Astro.props
15
+
16
+ // Normalize path by removing trailing slash for comparison
17
+ const normalizePath = (path: string) => path.replace(/\/$/, '') || '/'
18
+
19
+ // Find the path to the current page in the navigation tree
20
+ const findBreadcrumbs = (
21
+ entry: Entry,
22
+ targetPath: string,
23
+ path: BreadcrumbItem[] = [],
24
+ ): BreadcrumbItem[] | null => {
25
+ for (const [key, item] of Object.entries(entry)) {
26
+ const label = item.label ?? key
27
+ const currentItem: BreadcrumbItem = { label, href: item.href }
28
+
29
+ if (item.href && normalizePath(item.href) === normalizePath(targetPath)) {
30
+ return [...path, currentItem]
31
+ }
32
+
33
+ if (item.subEntry) {
34
+ const result = findBreadcrumbs(item.subEntry, targetPath, [
35
+ ...path,
36
+ currentItem,
37
+ ])
38
+ if (result) return result
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ const breadcrumbs = findBreadcrumbs(navigation, currentPath) ?? []
45
+ ---
46
+
47
+ {
48
+ breadcrumbs.length > 0 && (
49
+ <div class="breadcrumbs text-sm">
50
+ <ul>
51
+ {breadcrumbs.map((item, index) => (
52
+ <li>
53
+ {item.href && index < breadcrumbs.length - 1 ? (
54
+ <a href={item.href}>{item.label}</a>
55
+ ) : (
56
+ <span>{item.label}</span>
57
+ )}
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,26 @@
1
+ ---
2
+ interface FooterProps {
3
+ links: {
4
+ label: string
5
+ href: string
6
+ }[]
7
+ copyright: {
8
+ label: string
9
+ href: string
10
+ year: number
11
+ }
12
+ }
13
+
14
+ const { links, copyright } = Astro.props as FooterProps
15
+ ---
16
+
17
+ <footer class="flex w-full items-center justify-between px-6 py-4 text-sm font-medium">
18
+ <a href={copyright.href} target="_blank">
19
+ © {copyright.label}, {copyright.year}
20
+ </a>
21
+ {links.map(({ label, href }) => (
22
+ <a href={href}>
23
+ {label}
24
+ </a>
25
+ ))}
26
+ </footer>
@@ -0,0 +1,72 @@
1
+ ---
2
+ import type { Config } from '../../src/schemas/config'
3
+ import { cn } from '../../src/tools/cn'
4
+
5
+ type Props = Pick<Config, 'brand' | 'navigation'> & { showBrand: boolean }
6
+
7
+ const { brand, navigation, showBrand = false } = Astro.props as Props
8
+ ---
9
+
10
+ <div class="navbar z-10 bg-base-100">
11
+ <span
12
+ class="tooltip tooltip-bottom before:text-xs before:content-[attr(data-tip)]"
13
+ data-tip="Menu"
14
+ >
15
+ <label
16
+ aria-label="Open menu"
17
+ for="drawer"
18
+ class="btn btn-square btn-ghost drawer-button lg:hidden"
19
+ >
20
+ <svg
21
+ width="20"
22
+ height="20"
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ fill="none"
25
+ viewBox="0 0 24 24"
26
+ class="inline-block h-5 w-5 stroke-current md:h-6 md:w-6"
27
+ >
28
+ <path
29
+ stroke-linecap="round"
30
+ stroke-linejoin="round"
31
+ stroke-width="2"
32
+ d="M4 6h16M4 12h16M4 18h16"></path>
33
+ </svg>
34
+ </label>
35
+ </span>
36
+ <div class="flex-1">
37
+ <a
38
+ href="/"
39
+ class={cn("btn btn-ghost text-xl", {
40
+ "md:hidden": !showBrand,
41
+ })}
42
+ >
43
+ {brand}
44
+ </a>
45
+ </div>
46
+ <div class="hidden flex-none lg:flex">
47
+ <ul class="menu menu-horizontal px-1">
48
+ {
49
+ Object.entries(navigation).map(([key, entry]) =>
50
+ entry.subEntry ? (
51
+ <li>
52
+ <details>
53
+ <summary>{entry.label ?? key}</summary>
54
+ <ul class="rounded-t-none bg-base-100 p-2">
55
+ {Object.entries(entry.subEntry).map(([key, entry]) => (
56
+ <li>
57
+ <a href={entry.href}>{entry.label ?? key}</a>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ </details>
62
+ </li>
63
+ ) : (
64
+ <li>
65
+ <a href={entry.href}>{entry.label ?? key}</a>
66
+ </li>
67
+ ),
68
+ )
69
+ }
70
+ </ul>
71
+ </div>
72
+ </div>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import SidebarElement from './SidebarElement.astro'
3
+ import type { Entry } from './types'
4
+
5
+ interface SidebarProps {
6
+ entry: Entry
7
+ }
8
+
9
+ const { entry } = Astro.props as SidebarProps
10
+ ---
11
+
12
+ <SidebarElement entry={entry} />
@@ -0,0 +1,45 @@
1
+ ---
2
+ import { cn } from '../../src/tools/cn'
3
+ import type { Entry } from './types'
4
+
5
+ interface Props {
6
+ entry: Entry
7
+ currentPath?: string
8
+ }
9
+
10
+ const { entry, currentPath = Astro.url.pathname } = Astro.props
11
+
12
+ // Normalize path by removing trailing slash for comparison
13
+ const normalizePath = (path: string) => path.replace(/\/$/, '') || '/'
14
+
15
+ // Check if entry or any of its children are active
16
+ const isActiveOrHasActiveChild = (entryValue: Entry[string]): boolean => {
17
+ if (
18
+ entryValue.href &&
19
+ normalizePath(entryValue.href) === normalizePath(currentPath)
20
+ )
21
+ return true
22
+ if (entryValue.subEntry) {
23
+ return Object.values(entryValue.subEntry).some(isActiveOrHasActiveChild)
24
+ }
25
+ return false
26
+ }
27
+ ---
28
+
29
+ {Object.entries(entry).map(([key, entryValue]) => {
30
+ const label = entryValue.label ?? key;
31
+ const isActive = entryValue.href && normalizePath(entryValue.href) === normalizePath(currentPath);
32
+ const hasActiveChild = entryValue.subEntry && Object.values(entryValue.subEntry).some(isActiveOrHasActiveChild);
33
+ return (
34
+ <li class={entryValue.className}>
35
+ {entryValue.href
36
+ ? <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{label}</a>
37
+ : <span class='menu-title'>{label}</span>}
38
+ {entryValue.subEntry ? (
39
+ <ul>
40
+ <Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
41
+ </ul>
42
+ ) : null}
43
+ </li>
44
+ )
45
+ })}
@@ -0,0 +1,40 @@
1
+ ---
2
+ import { cn } from '../../src/tools/cn'
3
+ import SidebarElement from './SidebarElement.astro'
4
+ import type { Entry } from './types'
5
+
6
+ interface Props {
7
+ local: Entry | undefined
8
+ global: Entry
9
+ brand: string
10
+ }
11
+
12
+ const { local, global, brand } = Astro.props
13
+
14
+ const withLocale = (path: string) =>
15
+ Astro.currentLocale ? `/${Astro.currentLocale}${path}` : path
16
+ ---
17
+
18
+ <ul class={cn("menu min-h-screen w-56 bg-base-100", { "md:hidden": !local })}>
19
+ <div>
20
+ <a href={withLocale("/")} class="btn btn-ghost mb-2 text-xl">
21
+ {brand}
22
+ </a>
23
+ </div>
24
+ <div class="block md:hidden">
25
+ <li>
26
+ {
27
+ local ? (
28
+ <details>
29
+ <summary>Main menu</summary>
30
+ <SidebarElement entry={global} />
31
+ </details>
32
+ ) : (
33
+ <SidebarElement entry={global} />
34
+ )
35
+ }
36
+ </li>
37
+ <div class={cn("divider my-1 block md:hidden", { hidden: !local })}></div>
38
+ </div>
39
+ {local && <SidebarElement entry={local} />}
40
+ </ul>
@@ -0,0 +1,84 @@
1
+ ---
2
+ import { cn } from '../../src/tools/cn'
3
+
4
+ interface Link {
5
+ depth: number
6
+ text: string
7
+ slug: string
8
+ }
9
+
10
+ interface Props {
11
+ links: Link[]
12
+ label?: string
13
+ class?: string
14
+ desktopOnly?: boolean
15
+ }
16
+
17
+ const {
18
+ links,
19
+ label = 'On this page',
20
+ class: className,
21
+ desktopOnly = false,
22
+ } = Astro.props
23
+
24
+ // Filter to only include h2 and h3 headings (depth 2 and 3)
25
+ const filteredLinks = links.filter((link) => link.depth >= 2 && link.depth <= 3)
26
+ ---
27
+
28
+ {
29
+ filteredLinks.length > 0 && (
30
+ <div class={className}>
31
+ {/* Mobile: Collapsible dropdown */}
32
+ {!desktopOnly && (
33
+ <div class="xl:hidden mb-4">
34
+ <details class="collapse collapse-arrow bg-base-200">
35
+ <summary class="collapse-title min-h-0 py-2 font-medium">
36
+ {label}
37
+ </summary>
38
+ <div class="collapse-content">
39
+ <ul class="menu menu-sm">
40
+ {filteredLinks.map((link) => (
41
+ <li>
42
+ <a
43
+ href={`#${link.slug}`}
44
+ class={cn({
45
+ 'pl-4': link.depth === 2,
46
+ 'pl-8': link.depth === 3,
47
+ })}
48
+ >
49
+ {link.text}
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ </div>
55
+ </details>
56
+ </div>
57
+ )}
58
+
59
+ {/* Desktop: Sidebar within the grid column */}
60
+ {desktopOnly && (
61
+ <div class="border-l border-base-300 pl-4">
62
+ <h4 class="font-medium text-sm mb-2 text-base-content/60">
63
+ {label}
64
+ </h4>
65
+ <ul class="menu menu-xs p-0">
66
+ {filteredLinks.map((link) => (
67
+ <li>
68
+ <a
69
+ href={`#${link.slug}`}
70
+ class={cn('text-base-content/70 hover:text-primary', {
71
+ 'pl-0': link.depth === 2,
72
+ 'pl-4': link.depth === 3,
73
+ })}
74
+ >
75
+ {link.text}
76
+ </a>
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ </div>
81
+ )}
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,7 @@
1
+ export { default as Breadcrumbs } from './Breadcrumbs.astro'
2
+ export { default as Footer } from './Footer.astro'
3
+ export { default as GlobalDesktopNavigation } from './GlobalDesktopNavigation.astro'
4
+ export { default as LocalNavigation } from './LocalNavigation.astro'
5
+ export { default as SidebarElement } from './SidebarElement.astro'
6
+ export { default as SidebarNavigation } from './SidebarNavigation.astro'
7
+ export { default as TableOfContents } from './TableOfContents.astro'
@@ -0,0 +1,10 @@
1
+ export type Entry = Record<
2
+ string,
3
+ {
4
+ label?: string
5
+ href?: string
6
+ subEntry?: Entry
7
+ active?: boolean
8
+ className?: string
9
+ }
10
+ >
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { Footer as FooterComponent } from '../components'
3
+
4
+ const withLocale = (path: string) =>
5
+ Astro.currentLocale ? `/${Astro.currentLocale}${path}` : path
6
+ ---
7
+
8
+ <FooterComponent
9
+ links={[{ href: withLocale("/imprint"), label: "Impressum" }]}
10
+ copyright={{
11
+ href: "https://github.com/levino",
12
+ label: "Levin Keller",
13
+ year: 2025,
14
+ }}
15
+ />
@@ -0,0 +1,103 @@
1
+ ---
2
+ import Footer from './Footer.astro'
3
+ import '../../src/globals.css'
4
+ import { i18n } from 'astro:config/server'
5
+ import config from 'virtual:shipyard/config'
6
+ import { mapObjIndexed } from 'ramda'
7
+ import type {
8
+ NavigationEntry,
9
+ NavigationTree,
10
+ Script,
11
+ } from '../../src/schemas/config'
12
+ import { getTitle } from '../../src/tools/title'
13
+ import { GlobalDesktopNavigation, SidebarNavigation } from '../components'
14
+
15
+ type Props = {
16
+ frontmatter?: {
17
+ title?: string
18
+ description?: string
19
+ sidebarNavigation?: NavigationTree
20
+ }
21
+ } & {
22
+ title?: string
23
+ description?: string
24
+ sidebarNavigation?: NavigationTree
25
+ }
26
+
27
+ const currentPath = Astro.url.pathname
28
+ const props = Astro.props.frontmatter || Astro.props
29
+
30
+ const withLocale = (path: string) =>
31
+ i18n ? `/${Astro.currentLocale}${path}` : path
32
+ const applyLocaleAndSetActive: (navigation: NavigationTree) => NavigationTree =
33
+ mapObjIndexed((entry: NavigationEntry) => ({
34
+ ...entry,
35
+ ...(entry.href ? { href: withLocale(entry.href) } : {}),
36
+ active: entry.href === currentPath,
37
+ ...(entry.subEntry
38
+ ? { subEntry: applyLocaleAndSetActive(entry.subEntry) }
39
+ : {}),
40
+ }))
41
+
42
+ const navigation = applyLocaleAndSetActive(config.navigation)
43
+ const title = getTitle(config.title, props.title)
44
+
45
+ // Helper function to render script attributes
46
+ const renderScriptAttributes = (script: Script) => {
47
+ if (typeof script === 'string') {
48
+ return { src: script }
49
+ }
50
+ return script
51
+ }
52
+ ---
53
+
54
+ <html>
55
+ <head>
56
+ <meta charset="utf-8" />
57
+ <link rel="sitemap" href="/sitemap-index.xml" />
58
+ <title>
59
+ {title}
60
+ </title>
61
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
62
+ <meta name="description" content={props.description} />
63
+ <meta property="og:title" content={title} />
64
+ <meta property="og:description" content={props.description} />
65
+
66
+ {config.scripts?.map((script) => {
67
+ const attrs = renderScriptAttributes(script)
68
+ return <script is:inline {...attrs}></script>
69
+ })}
70
+ </head>
71
+ <body>
72
+ <div class="drawer lg:drawer-open">
73
+ <input id="drawer" type="checkbox" class="drawer-toggle" />
74
+ <div class="drawer-content">
75
+ <div class="flex min-h-screen flex-col">
76
+ <GlobalDesktopNavigation
77
+ showBrand={!props.sidebarNavigation}
78
+ brand={config.brand}
79
+ navigation={navigation}
80
+ />
81
+ <div class="grow">
82
+ <div class="mx-auto px-4">
83
+ <slot />
84
+ </div>
85
+ </div>
86
+ <Footer />
87
+ </div>
88
+ </div>
89
+
90
+ <div class="drawer-side z-40">
91
+ <label for="drawer" aria-label="close sidebar" class="drawer-overlay"
92
+ ></label>
93
+ <div>
94
+ <SidebarNavigation
95
+ brand={config.brand}
96
+ global={navigation}
97
+ local={props.sidebarNavigation}
98
+ />
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </body>
103
+ </html>
@@ -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>
@@ -0,0 +1,3 @@
1
+ export { default as Footer } from './Footer.astro'
2
+ export { default as Page } from './Page.astro'
3
+ export { default as Splash } from './Splash.astro'
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@levino/shipyard-base",
3
+ "version": "0.0.0-rc-20251122114952",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./astro": "./src/astro.ts",
9
+ "./layouts": "./astro/layouts/index.ts",
10
+ "./layouts/*": "./astro/layouts/*",
11
+ "./components": "./astro/components/index.ts",
12
+ "./components/*": "./astro/components/*"
13
+ },
14
+ "peerDependencies": {
15
+ "astro": "^5.7",
16
+ "daisyui": "^4",
17
+ "tailwindcss": "^3",
18
+ "@tailwindcss/typography": "^0.5.10"
19
+ },
20
+ "files": [
21
+ "astro",
22
+ "src"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/levino/shipyard"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/typography": "^0.5.10",
30
+ "@types/ramda": "^0.31",
31
+ "daisyui": "^4.6.0",
32
+ "tailwindcss": "^3.4.0",
33
+ "typescript": "^5.2.2",
34
+ "vite": "^4"
35
+ },
36
+ "dependencies": {
37
+ "clsx": "^2.1.0",
38
+ "ramda": "^0.31",
39
+ "tailwind-merge": "^2.2.0"
40
+ }
41
+ }
package/src/astro.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * as components from './astro/components'
2
+ export * as layouts from './astro/layouts'
@@ -0,0 +1,5 @@
1
+ /* biome-ignore-all lint/suspicious/noUnknownAtRules: bug https://github.com/biomejs/biome/issues/7223 */
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { AstroIntegration } from 'astro'
2
+ import type { Config } from './schemas/config'
3
+
4
+ export type { Entry } from '../astro/components/types'
5
+ export type * from './schemas/config'
6
+ export { getTitle } from './tools/title'
7
+ export * from './types'
8
+
9
+ const shipyardConfigId = 'virtual:shipyard/config'
10
+
11
+ const resolveId: Record<string, string | undefined> = {
12
+ [shipyardConfigId]: `${shipyardConfigId}`,
13
+ }
14
+
15
+ const load = (config: Config) =>
16
+ ({
17
+ [shipyardConfigId]: `export default ${JSON.stringify(config)}`,
18
+ }) as Record<string, string | undefined>
19
+
20
+ export default (config: Config): AstroIntegration => ({
21
+ name: 'shipyard',
22
+ hooks: {
23
+ 'astro:config:setup': ({ updateConfig }) => {
24
+ updateConfig({
25
+ vite: {
26
+ plugins: [
27
+ {
28
+ name: 'shipyard',
29
+ resolveId: (id: string) => resolveId[id],
30
+ load: (id: string) => load(config)[id],
31
+ },
32
+ ],
33
+ },
34
+ })
35
+ },
36
+ },
37
+ })
@@ -0,0 +1,18 @@
1
+ export interface NavigationEntry {
2
+ label?: string
3
+ href?: string
4
+ subEntry?: NavigationTree
5
+ active?: boolean
6
+ }
7
+
8
+ export type NavigationTree = Record<string, NavigationEntry>
9
+
10
+ export type Script = string | astroHTML.JSX.IntrinsicElements['script']
11
+
12
+ export interface Config {
13
+ brand: string
14
+ navigation: NavigationTree
15
+ title: string
16
+ tagline: string
17
+ scripts?: Script[]
18
+ }
@@ -0,0 +1,4 @@
1
+ import clsx, { type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes))
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { getTitle } from './title'
3
+
4
+ describe('getTitle', () => {
5
+ const siteTitle = 'Shipyard'
6
+
7
+ test('returns site title when page title is undefined', () => {
8
+ expect(getTitle(siteTitle, undefined)).toBe('Shipyard')
9
+ })
10
+
11
+ test('returns site title when page title is null', () => {
12
+ expect(getTitle(siteTitle, null)).toBe('Shipyard')
13
+ })
14
+
15
+ test('returns site title when page title is empty', () => {
16
+ expect(getTitle(siteTitle, '')).toBe('Shipyard')
17
+ })
18
+
19
+ test('returns site title when page title equals site title', () => {
20
+ expect(getTitle(siteTitle, 'Shipyard')).toBe('Shipyard')
21
+ })
22
+
23
+ test('returns combined title when page title differs', () => {
24
+ expect(getTitle(siteTitle, 'Blog')).toBe('Shipyard - Blog')
25
+ expect(getTitle(siteTitle, 'First Blog Post')).toBe(
26
+ 'Shipyard - First Blog Post',
27
+ )
28
+ })
29
+
30
+ test('handles whitespace-only page titles', () => {
31
+ expect(getTitle(siteTitle, ' ')).toBe('Shipyard')
32
+ })
33
+
34
+ test('trims page title before comparison', () => {
35
+ expect(getTitle(siteTitle, ' Shipyard ')).toBe('Shipyard')
36
+ expect(getTitle(siteTitle, ' Blog ')).toBe('Shipyard - Blog')
37
+ })
38
+
39
+ test('is case sensitive', () => {
40
+ expect(getTitle(siteTitle, 'shipyard')).toBe('Shipyard - shipyard')
41
+ })
42
+
43
+ test('handles special characters', () => {
44
+ expect(getTitle(siteTitle, 'FAQ & Help')).toBe('Shipyard - FAQ & Help')
45
+ })
46
+ })
@@ -0,0 +1,11 @@
1
+ export const getTitle = (
2
+ siteTitle: string,
3
+ pageTitle?: string | null,
4
+ ): string => {
5
+ if (!pageTitle) return siteTitle
6
+
7
+ const trimmedPageTitle = pageTitle.trim()
8
+ if (!trimmedPageTitle || trimmedPageTitle === siteTitle) return siteTitle
9
+
10
+ return `${siteTitle} - ${trimmedPageTitle}`
11
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ export interface LinkData {
2
+ href: string
3
+ label: string
4
+ active: boolean
5
+ }
6
+ export const MONTHS_EN = [
7
+ 'january',
8
+ 'february',
9
+ 'march',
10
+ 'april',
11
+ 'may',
12
+ 'june',
13
+ 'july',
14
+ 'august',
15
+ 'september',
16
+ 'october',
17
+ 'november',
18
+ 'december',
19
+ ] as const
20
+
21
+ export const MONTHS_DE = [
22
+ 'Januar',
23
+ 'Februar',
24
+ 'März',
25
+ 'April',
26
+ 'Mai',
27
+ 'Juni',
28
+ 'Juli',
29
+ 'August',
30
+ 'September',
31
+ 'Oktober',
32
+ 'November',
33
+ 'Dezember',
34
+ ] as const