@levino/shipyard-base 0.6.0 → 0.6.1

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.
@@ -6,7 +6,7 @@ const withLocale = (path: string) =>
6
6
  ---
7
7
 
8
8
  <FooterComponent
9
- links={[{ href: withLocale("/imprint"), label: "Impressum" }]}
9
+ links={[]}
10
10
  copyright={{
11
11
  href: "https://github.com/levino",
12
12
  label: "Levin Keller",
@@ -73,7 +73,6 @@ const renderScriptAttributes = (script: Script) => {
73
73
  <html>
74
74
  <head>
75
75
  <meta charset="utf-8" />
76
- <link rel="sitemap" href="/sitemap-index.xml" />
77
76
  <title>
78
77
  {title}
79
78
  </title>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levino/shipyard-base",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Core layouts, components, and configuration for shipyard - a composable page builder for Astro",
5
5
  "keywords": [
6
6
  "astro",
@@ -28,7 +28,8 @@
28
28
  "./components": "./astro/components/index.ts",
29
29
  "./components/*": "./astro/components/*",
30
30
  "./remark": "./src/remark/index.ts",
31
- "./shiki": "./src/shiki/index.ts"
31
+ "./shiki": "./src/shiki/index.ts",
32
+ "./globals.css": "./src/globals.css"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "@tailwindcss/typography": "^0.5.10",
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import { fileURLToPath } from 'node:url'
1
2
  import type { AstroIntegration } from 'astro'
2
3
  import type { Config } from './schemas/config'
4
+ import { checkLinks, reportBrokenLinks } from './tools/linkChecker'
3
5
 
4
6
  export type { Entry } from '../astro/components/types'
5
7
  export type * from './schemas/config'
8
+ export { checkLinks, reportBrokenLinks } from './tools/linkChecker'
6
9
  export { getTitle } from './tools/title'
7
10
  export * from './types'
8
11
 
@@ -14,32 +17,59 @@ const resolveId: Record<string, string | undefined> = {
14
17
  [shipyardLocalesId]: `${shipyardLocalesId}`,
15
18
  }
16
19
 
17
- export default (config: Config): AstroIntegration => ({
18
- name: 'shipyard',
19
- hooks: {
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
-
32
- updateConfig({
33
- vite: {
34
- plugins: [
35
- {
36
- name: 'shipyard',
37
- resolveId: (id: string) => resolveId[id],
38
- load: (id: string) => load[id],
39
- },
40
- ],
41
- },
42
- })
20
+ export default (config: Config): AstroIntegration => {
21
+ let isServerMode = false
22
+
23
+ return {
24
+ name: 'shipyard',
25
+ hooks: {
26
+ 'astro:config:setup': ({ updateConfig, config: astroConfig }) => {
27
+ // Detect server mode (output: 'server' or 'hybrid')
28
+ isServerMode = astroConfig.output === 'server'
29
+
30
+ // Extract locales from Astro's i18n config
31
+ const locales = astroConfig.i18n?.locales ?? []
32
+ const localeList = locales.map((locale) =>
33
+ typeof locale === 'string' ? locale : locale.path,
34
+ )
35
+
36
+ const load = {
37
+ [shipyardConfigId]: `export default ${JSON.stringify(config)}`,
38
+ [shipyardLocalesId]: `export const locales = ${JSON.stringify(localeList)}; export default ${JSON.stringify(localeList)};`,
39
+ } as Record<string, string | undefined>
40
+
41
+ updateConfig({
42
+ vite: {
43
+ plugins: [
44
+ {
45
+ name: 'shipyard',
46
+ resolveId: (id: string) => resolveId[id],
47
+ load: (id: string) => load[id],
48
+ },
49
+ ],
50
+ },
51
+ })
52
+ },
53
+ 'astro:build:done': ({ dir, logger }) => {
54
+ const onBrokenLinks = config.onBrokenLinks ?? 'warn'
55
+
56
+ if (onBrokenLinks === 'ignore') {
57
+ return
58
+ }
59
+
60
+ // Skip link checking in server mode - pages are rendered on-demand
61
+ if (isServerMode) {
62
+ logger.info(
63
+ 'Link checking skipped in server mode (pages are rendered on-demand)',
64
+ )
65
+ return
66
+ }
67
+
68
+ const buildDir = fileURLToPath(dir)
69
+ const result = checkLinks(buildDir)
70
+
71
+ reportBrokenLinks(result, onBrokenLinks, logger)
72
+ },
43
73
  },
44
- },
45
- })
74
+ }
75
+ }
@@ -60,6 +60,24 @@ export interface AnnouncementBar {
60
60
  isCloseable?: boolean
61
61
  }
62
62
 
63
+ /**
64
+ * Behavior for handling broken internal links during build.
65
+ * - 'ignore': Don't check for broken links
66
+ * - 'log': Log broken links but don't affect build
67
+ * - 'warn': Log warnings for broken links
68
+ * - 'throw': Throw an error and fail the build on broken links
69
+ */
70
+ export type OnBrokenLinksAction = 'ignore' | 'log' | 'warn' | 'throw'
71
+
72
+ /**
73
+ * Behavior for handling broken markdown links during build.
74
+ * - 'ignore': Don't check for broken markdown links
75
+ * - 'log': Log broken links but don't affect build
76
+ * - 'warn': Log warnings for broken markdown links
77
+ * - 'throw': Throw an error and fail the build on broken markdown links
78
+ */
79
+ export type OnBrokenMarkdownLinksAction = 'ignore' | 'log' | 'warn' | 'throw'
80
+
63
81
  export interface Config {
64
82
  brand: string
65
83
  navigation: NavigationTree
@@ -71,4 +89,18 @@ export interface Config {
71
89
  * Shows a dismissible banner at the top of the page.
72
90
  */
73
91
  announcementBar?: AnnouncementBar
92
+ /**
93
+ * The behavior of Shipyard when it detects any broken internal link.
94
+ * By default, it logs a warning. Set to 'throw' to fail the build on broken links.
95
+ * Only runs during production builds.
96
+ * @default 'warn'
97
+ */
98
+ onBrokenLinks?: OnBrokenLinksAction
99
+ /**
100
+ * The behavior of Shipyard when it detects any broken markdown link.
101
+ * By default, it logs a warning. Set to 'throw' to fail the build on broken links.
102
+ * Only runs during production builds.
103
+ * @default 'warn'
104
+ */
105
+ onBrokenMarkdownLinks?: OnBrokenMarkdownLinksAction
74
106
  }
@@ -0,0 +1,275 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
5
+ import type { LinkCheckResult } from './linkChecker'
6
+ import { checkLinks, reportBrokenLinks } from './linkChecker'
7
+
8
+ describe('checkLinks', () => {
9
+ let testDir: string
10
+
11
+ beforeEach(() => {
12
+ testDir = join(tmpdir(), `linkchecker-test-${Date.now()}`)
13
+ mkdirSync(testDir, { recursive: true })
14
+ })
15
+
16
+ afterEach(() => {
17
+ rmSync(testDir, { recursive: true, force: true })
18
+ })
19
+
20
+ test('finds no broken links when all links are valid', () => {
21
+ // Create test HTML files
22
+ writeFileSync(
23
+ join(testDir, 'index.html'),
24
+ '<a href="/about">About</a><a href="/docs">Docs</a>',
25
+ )
26
+ mkdirSync(join(testDir, 'about'), { recursive: true })
27
+ writeFileSync(join(testDir, 'about', 'index.html'), '<a href="/">Home</a>')
28
+ mkdirSync(join(testDir, 'docs'), { recursive: true })
29
+ writeFileSync(join(testDir, 'docs', 'index.html'), '<a href="/">Home</a>')
30
+
31
+ const result = checkLinks(testDir)
32
+
33
+ expect(result.brokenCount).toBe(0)
34
+ expect(result.brokenLinks).toEqual([])
35
+ expect(result.totalLinks).toBe(4)
36
+ })
37
+
38
+ test('detects broken internal links', () => {
39
+ writeFileSync(
40
+ join(testDir, 'index.html'),
41
+ '<a href="/non-existent">Broken</a>',
42
+ )
43
+
44
+ const result = checkLinks(testDir)
45
+
46
+ expect(result.brokenCount).toBe(1)
47
+ expect(result.brokenLinks[0].href).toBe('/non-existent')
48
+ expect(result.brokenLinks[0].sourceFile).toBe('index.html')
49
+ })
50
+
51
+ test('ignores external links', () => {
52
+ writeFileSync(
53
+ join(testDir, 'index.html'),
54
+ '<a href="https://example.com">External</a><a href="http://test.com">HTTP</a>',
55
+ )
56
+
57
+ const result = checkLinks(testDir)
58
+
59
+ expect(result.brokenCount).toBe(0)
60
+ expect(result.totalLinks).toBe(0) // External links are not counted
61
+ })
62
+
63
+ test('ignores anchor links', () => {
64
+ writeFileSync(join(testDir, 'index.html'), '<a href="#section">Anchor</a>')
65
+
66
+ const result = checkLinks(testDir)
67
+
68
+ expect(result.brokenCount).toBe(0)
69
+ expect(result.totalLinks).toBe(0)
70
+ })
71
+
72
+ test('ignores special protocol links', () => {
73
+ writeFileSync(
74
+ join(testDir, 'index.html'),
75
+ '<a href="mailto:test@example.com">Email</a><a href="tel:+1234567890">Phone</a><a href="javascript:void(0)">JS</a>',
76
+ )
77
+
78
+ const result = checkLinks(testDir)
79
+
80
+ expect(result.brokenCount).toBe(0)
81
+ expect(result.totalLinks).toBe(0)
82
+ })
83
+
84
+ test('handles links with trailing slashes', () => {
85
+ writeFileSync(join(testDir, 'index.html'), '<a href="/about/">About</a>')
86
+ mkdirSync(join(testDir, 'about'), { recursive: true })
87
+ writeFileSync(join(testDir, 'about', 'index.html'), 'About page')
88
+
89
+ const result = checkLinks(testDir)
90
+
91
+ expect(result.brokenCount).toBe(0)
92
+ })
93
+
94
+ test('handles links without trailing slashes', () => {
95
+ writeFileSync(join(testDir, 'index.html'), '<a href="/about">About</a>')
96
+ mkdirSync(join(testDir, 'about'), { recursive: true })
97
+ writeFileSync(join(testDir, 'about', 'index.html'), 'About page')
98
+
99
+ const result = checkLinks(testDir)
100
+
101
+ expect(result.brokenCount).toBe(0)
102
+ })
103
+
104
+ test('handles links to .html files', () => {
105
+ writeFileSync(join(testDir, 'index.html'), '<a href="/page.html">Page</a>')
106
+ writeFileSync(join(testDir, 'page.html'), 'Page content')
107
+
108
+ const result = checkLinks(testDir)
109
+
110
+ expect(result.brokenCount).toBe(0)
111
+ })
112
+
113
+ test('handles query strings and hash fragments', () => {
114
+ writeFileSync(
115
+ join(testDir, 'index.html'),
116
+ '<a href="/about?foo=bar#section">About</a>',
117
+ )
118
+ mkdirSync(join(testDir, 'about'), { recursive: true })
119
+ writeFileSync(join(testDir, 'about', 'index.html'), 'About page')
120
+
121
+ const result = checkLinks(testDir)
122
+
123
+ expect(result.brokenCount).toBe(0)
124
+ })
125
+
126
+ test('handles nested directories', () => {
127
+ writeFileSync(
128
+ join(testDir, 'index.html'),
129
+ '<a href="/docs/getting-started">Docs</a>',
130
+ )
131
+ mkdirSync(join(testDir, 'docs', 'getting-started'), { recursive: true })
132
+ writeFileSync(
133
+ join(testDir, 'docs', 'getting-started', 'index.html'),
134
+ 'Getting Started',
135
+ )
136
+
137
+ const result = checkLinks(testDir)
138
+
139
+ expect(result.brokenCount).toBe(0)
140
+ })
141
+
142
+ test('detects multiple broken links', () => {
143
+ writeFileSync(
144
+ join(testDir, 'index.html'),
145
+ '<a href="/foo">Foo</a><a href="/bar">Bar</a><a href="/baz">Baz</a>',
146
+ )
147
+
148
+ const result = checkLinks(testDir)
149
+
150
+ expect(result.brokenCount).toBe(3)
151
+ expect(result.brokenLinks.map((l) => l.href).sort()).toEqual([
152
+ '/bar',
153
+ '/baz',
154
+ '/foo',
155
+ ])
156
+ })
157
+
158
+ test('provides correct line numbers', () => {
159
+ writeFileSync(
160
+ join(testDir, 'index.html'),
161
+ 'line 1\n<a href="/broken">Broken</a>\nline 3',
162
+ )
163
+
164
+ const result = checkLinks(testDir)
165
+
166
+ expect(result.brokenLinks[0].line).toBe(2)
167
+ })
168
+ })
169
+
170
+ describe('reportBrokenLinks', () => {
171
+ const createMockLogger = () => ({
172
+ info: vi.fn(),
173
+ warn: vi.fn(),
174
+ error: vi.fn(),
175
+ debug: vi.fn(),
176
+ label: 'test',
177
+ options: {},
178
+ fork: vi.fn(),
179
+ })
180
+
181
+ test('logs success message when no broken links and action is not ignore', () => {
182
+ const logger = createMockLogger()
183
+ const result: LinkCheckResult = {
184
+ totalLinks: 10,
185
+ brokenCount: 0,
186
+ brokenLinks: [],
187
+ }
188
+
189
+ reportBrokenLinks(result, 'warn', logger as any)
190
+
191
+ expect(logger.info).toHaveBeenCalledWith(
192
+ 'Link check passed: 10 links verified',
193
+ )
194
+ })
195
+
196
+ test('does nothing when action is ignore', () => {
197
+ const logger = createMockLogger()
198
+ const result: LinkCheckResult = {
199
+ totalLinks: 10,
200
+ brokenCount: 2,
201
+ brokenLinks: [
202
+ { sourceFile: 'index.html', href: '/broken', line: 1 },
203
+ { sourceFile: 'about.html', href: '/missing', line: 5 },
204
+ ],
205
+ }
206
+
207
+ reportBrokenLinks(result, 'ignore', logger as any)
208
+
209
+ expect(logger.info).not.toHaveBeenCalled()
210
+ expect(logger.warn).not.toHaveBeenCalled()
211
+ })
212
+
213
+ test('logs info when action is log', () => {
214
+ const logger = createMockLogger()
215
+ const result: LinkCheckResult = {
216
+ totalLinks: 5,
217
+ brokenCount: 1,
218
+ brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
219
+ }
220
+
221
+ reportBrokenLinks(result, 'log', logger as any)
222
+
223
+ expect(logger.info).toHaveBeenCalled()
224
+ const message = logger.info.mock.calls[0][0]
225
+ expect(message).toContain('Found 1 broken link')
226
+ expect(message).toContain('/broken')
227
+ expect(message).toContain('index.html:1')
228
+ })
229
+
230
+ test('logs warning when action is warn', () => {
231
+ const logger = createMockLogger()
232
+ const result: LinkCheckResult = {
233
+ totalLinks: 5,
234
+ brokenCount: 1,
235
+ brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
236
+ }
237
+
238
+ reportBrokenLinks(result, 'warn', logger as any)
239
+
240
+ expect(logger.warn).toHaveBeenCalled()
241
+ const message = logger.warn.mock.calls[0][0]
242
+ expect(message).toContain('Found 1 broken link')
243
+ })
244
+
245
+ test('throws error when action is throw', () => {
246
+ const logger = createMockLogger()
247
+ const result: LinkCheckResult = {
248
+ totalLinks: 5,
249
+ brokenCount: 1,
250
+ brokenLinks: [{ sourceFile: 'index.html', href: '/broken', line: 1 }],
251
+ }
252
+
253
+ expect(() => reportBrokenLinks(result, 'throw', logger as any)).toThrow(
254
+ 'Broken links found',
255
+ )
256
+ })
257
+
258
+ test('pluralizes correctly for multiple broken links', () => {
259
+ const logger = createMockLogger()
260
+ const result: LinkCheckResult = {
261
+ totalLinks: 10,
262
+ brokenCount: 3,
263
+ brokenLinks: [
264
+ { sourceFile: 'a.html', href: '/a' },
265
+ { sourceFile: 'b.html', href: '/b' },
266
+ { sourceFile: 'c.html', href: '/c' },
267
+ ],
268
+ }
269
+
270
+ reportBrokenLinks(result, 'warn', logger as any)
271
+
272
+ const message = logger.warn.mock.calls[0][0]
273
+ expect(message).toContain('Found 3 broken links')
274
+ })
275
+ })
@@ -0,0 +1,189 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import type { AstroIntegrationLogger } from 'astro'
4
+ import type { OnBrokenLinksAction } from '../schemas/config'
5
+
6
+ export interface BrokenLink {
7
+ /** The source file where the broken link was found */
8
+ sourceFile: string
9
+ /** The broken link href */
10
+ href: string
11
+ /** The line number where the link was found (if available) */
12
+ line?: number
13
+ }
14
+
15
+ export interface LinkCheckResult {
16
+ /** Total number of links checked */
17
+ totalLinks: number
18
+ /** Number of broken links found */
19
+ brokenCount: number
20
+ /** Details of each broken link */
21
+ brokenLinks: BrokenLink[]
22
+ }
23
+
24
+ /**
25
+ * Recursively finds all HTML files in a directory
26
+ */
27
+ function findHtmlFiles(dir: string): string[] {
28
+ const files: string[] = []
29
+
30
+ const entries = readdirSync(dir)
31
+ for (const entry of entries) {
32
+ const fullPath = join(dir, entry)
33
+ const stat = statSync(fullPath)
34
+
35
+ if (stat.isDirectory()) {
36
+ files.push(...findHtmlFiles(fullPath))
37
+ } else if (entry.endsWith('.html')) {
38
+ files.push(fullPath)
39
+ }
40
+ }
41
+
42
+ return files
43
+ }
44
+
45
+ /**
46
+ * Extracts all internal links from an HTML file
47
+ * Returns links that start with / (internal links)
48
+ */
49
+ function extractInternalLinks(html: string): { href: string; line: number }[] {
50
+ const links: { href: string; line: number }[] = []
51
+ const lines = html.split('\n')
52
+
53
+ const hrefRegex = /href=["']([^"']+)["']/g
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i]
57
+
58
+ for (const match of line.matchAll(hrefRegex)) {
59
+ const href = match[1]
60
+
61
+ // Only include internal links (start with /)
62
+ // This automatically excludes external URLs, anchors, and special protocols
63
+ if (href.startsWith('/')) {
64
+ links.push({ href, line: i + 1 })
65
+ }
66
+ }
67
+ }
68
+
69
+ return links
70
+ }
71
+
72
+ /**
73
+ * Normalizes a link path for checking
74
+ * Handles trailing slashes and index.html
75
+ */
76
+ function normalizePath(href: string): string[] {
77
+ // Remove query string and hash, and decode URL-encoded characters
78
+ const path = decodeURIComponent(href.split('?')[0].split('#')[0])
79
+
80
+ // Generate possible file paths to check
81
+ const paths: string[] = []
82
+
83
+ if (path.endsWith('/')) {
84
+ // /foo/ -> check /foo/index.html
85
+ paths.push(`${path}index.html`)
86
+ // Also check /foo.html (some sites use this pattern)
87
+ paths.push(`${path.slice(0, -1)}.html`)
88
+ } else if (path.endsWith('.html')) {
89
+ // /foo.html -> check as-is
90
+ paths.push(path)
91
+ } else if (path.includes('.')) {
92
+ // /foo.css, /foo.js, etc. -> check as-is (asset files)
93
+ paths.push(path)
94
+ } else {
95
+ // /foo -> check /foo/index.html, /foo.html, and /foo (directory)
96
+ paths.push(`${path}/index.html`)
97
+ paths.push(`${path}.html`)
98
+ paths.push(path)
99
+ }
100
+
101
+ return paths
102
+ }
103
+
104
+ /**
105
+ * Checks if a link target exists in the build output
106
+ */
107
+ function linkExists(href: string, buildDir: string): boolean {
108
+ const possiblePaths = normalizePath(href)
109
+
110
+ for (const path of possiblePaths) {
111
+ // Remove leading slash and join with build dir
112
+ const fullPath = join(buildDir, path.startsWith('/') ? path.slice(1) : path)
113
+
114
+ if (existsSync(fullPath)) {
115
+ return true
116
+ }
117
+ }
118
+
119
+ return false
120
+ }
121
+
122
+ /**
123
+ * Checks all internal links in the build output directory
124
+ */
125
+ export function checkLinks(buildDir: string): LinkCheckResult {
126
+ const htmlFiles = findHtmlFiles(buildDir)
127
+ const brokenLinks: BrokenLink[] = []
128
+ let totalLinks = 0
129
+
130
+ for (const file of htmlFiles) {
131
+ const html = readFileSync(file, 'utf-8')
132
+ const links = extractInternalLinks(html)
133
+
134
+ for (const link of links) {
135
+ totalLinks++
136
+
137
+ if (!linkExists(link.href, buildDir)) {
138
+ brokenLinks.push({
139
+ sourceFile: relative(buildDir, file),
140
+ href: link.href,
141
+ line: link.line,
142
+ })
143
+ }
144
+ }
145
+ }
146
+
147
+ return {
148
+ totalLinks,
149
+ brokenCount: brokenLinks.length,
150
+ brokenLinks,
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Reports broken links according to the configured action
156
+ */
157
+ export function reportBrokenLinks(
158
+ result: LinkCheckResult,
159
+ action: OnBrokenLinksAction,
160
+ logger: AstroIntegrationLogger,
161
+ ): void {
162
+ if (action === 'ignore' || result.brokenCount === 0) {
163
+ if (result.brokenCount === 0 && action !== 'ignore') {
164
+ logger.info(`Link check passed: ${result.totalLinks} links verified`)
165
+ }
166
+ return
167
+ }
168
+
169
+ const message = `Found ${result.brokenCount} broken link${result.brokenCount === 1 ? '' : 's'}:`
170
+ const details = result.brokenLinks
171
+ .map(
172
+ (link) =>
173
+ ` - ${link.href} (in ${link.sourceFile}${link.line ? `:${link.line}` : ''})`,
174
+ )
175
+ .join('\n')
176
+
177
+ const fullMessage = `${message}\n${details}`
178
+
179
+ switch (action) {
180
+ case 'log':
181
+ logger.info(fullMessage)
182
+ break
183
+ case 'warn':
184
+ logger.warn(fullMessage)
185
+ break
186
+ case 'throw':
187
+ throw new Error(`Broken links found:\n${details}`)
188
+ }
189
+ }