@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.
- package/astro/layouts/Footer.astro +1 -1
- package/astro/layouts/Page.astro +0 -1
- package/package.json +3 -2
- package/src/index.ts +58 -28
- package/src/schemas/config.ts +32 -0
- package/src/tools/linkChecker.test.ts +275 -0
- package/src/tools/linkChecker.ts +189 -0
package/astro/layouts/Page.astro
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-base",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
package/src/schemas/config.ts
CHANGED
|
@@ -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
|
+
}
|