starlight-links-validator 0.4.2 → 0.5.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/README.md +8 -36
- package/index.ts +77 -23
- package/libs/i18n.ts +78 -0
- package/libs/path.ts +11 -0
- package/libs/remark.ts +14 -6
- package/libs/validation.ts +118 -44
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1>starlight-links-validator 🦺</h1>
|
|
3
|
-
<p>
|
|
3
|
+
<p>Starlight plugin to validate internal links.</p>
|
|
4
4
|
<p>
|
|
5
|
-
<a href="https://
|
|
6
|
-
<img alt="Screenshot of starlight-links-validator" src="https://
|
|
5
|
+
<a href="https://github.com/HiDeoo/starlight-links-validator/assets/494699/fe5f797a-8089-4271-b090-7158bb053dfa" title="Screenshot of starlight-links-validator">
|
|
6
|
+
<img alt="Screenshot of starlight-links-validator" src="https://github.com/HiDeoo/starlight-links-validator/assets/494699/fe5f797a-8089-4271-b090-7158bb053dfa" width="520" />
|
|
7
7
|
</a>
|
|
8
8
|
</p>
|
|
9
9
|
</div>
|
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
<br />
|
|
19
19
|
</div>
|
|
20
20
|
|
|
21
|
+
## Getting Started
|
|
22
|
+
|
|
23
|
+
Want to get started immediately? Check out the [getting started guide](https://starlight-links-validator.vercel.app/getting-started/).
|
|
24
|
+
|
|
21
25
|
## Features
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
A [Starlight](https://starlight.astro.build) plugin to validate **_internal_** links in Markdown and MDX files.
|
|
24
28
|
|
|
25
29
|
- Validate internal links to other pages
|
|
26
30
|
- Validate internal links to anchors in other pages
|
|
@@ -28,38 +32,6 @@ An [Astro](https://astro.build) integration for [Starlight](https://starlight.as
|
|
|
28
32
|
- Ignore external links
|
|
29
33
|
- Run only during a production build
|
|
30
34
|
|
|
31
|
-
## Installation
|
|
32
|
-
|
|
33
|
-
Install the Starlight Links Validator integration using your favorite package manager, e.g. with [pnpm](https://pnpm.io):
|
|
34
|
-
|
|
35
|
-
```shell
|
|
36
|
-
pnpm add starlight-links-validator
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Update your [Astro configuration](https://docs.astro.build/en/guides/configuring-astro/#supported-config-file-types) to include the Starlight Links Validator integration **_before_** the Starlight integration:
|
|
40
|
-
|
|
41
|
-
```diff
|
|
42
|
-
import starlight from '@astrojs/starlight'
|
|
43
|
-
import { defineConfig } from 'astro/config'
|
|
44
|
-
+ import starlightLinksValidator from 'starlight-links-validator'
|
|
45
|
-
|
|
46
|
-
export default defineConfig({
|
|
47
|
-
// …
|
|
48
|
-
integrations: [
|
|
49
|
-
+ starlightLinksValidator(),
|
|
50
|
-
starlight({
|
|
51
|
-
sidebar: [
|
|
52
|
-
{
|
|
53
|
-
label: 'Guides',
|
|
54
|
-
items: [{ label: 'Example Guide', link: '/guides/example/' }],
|
|
55
|
-
},
|
|
56
|
-
],
|
|
57
|
-
title: 'My Docs',
|
|
58
|
-
}),
|
|
59
|
-
],
|
|
60
|
-
})
|
|
61
|
-
```
|
|
62
|
-
|
|
63
35
|
## License
|
|
64
36
|
|
|
65
37
|
Licensed under the MIT License, Copyright © HiDeoo.
|
package/index.ts
CHANGED
|
@@ -1,36 +1,90 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StarlightPlugin } from '@astrojs/starlight/types'
|
|
2
2
|
import { AstroError } from 'astro/errors'
|
|
3
|
+
import { z } from 'astro/zod'
|
|
3
4
|
|
|
4
5
|
import { remarkStarlightLinksValidator } from './libs/remark'
|
|
5
6
|
import { logErrors, validateLinks } from './libs/validation'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
const starlightLinksValidatorOptionsSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
/**
|
|
11
|
+
* Defines whether the plugin should error on fallback pages.
|
|
12
|
+
*
|
|
13
|
+
* If you do not expect to have all pages translated in all configured locales and want to use the fallback pages
|
|
14
|
+
* feature built-in into Starlight, you should set this option to `false`.
|
|
15
|
+
*
|
|
16
|
+
* @default true
|
|
17
|
+
* @see https://starlight.astro.build/guides/i18n/#fallback-content
|
|
18
|
+
*/
|
|
19
|
+
errorOnFallbackPages: z.boolean().default(true),
|
|
20
|
+
/**
|
|
21
|
+
* Defines whether the plugin should error on inconsistent locale links.
|
|
22
|
+
*
|
|
23
|
+
* When set to `true`, the plugin will error on links that are pointing to a page in a different locale.
|
|
24
|
+
*
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
errorOnInconsistentLocale: z.boolean().default(false),
|
|
28
|
+
/**
|
|
29
|
+
* Defines whether the plugin should error on internal relative links.
|
|
30
|
+
*
|
|
31
|
+
* When set to `false`, the plugin will ignore relative links (e.g. `./foo` or `../bar`).
|
|
32
|
+
*
|
|
33
|
+
* @default true
|
|
34
|
+
*/
|
|
35
|
+
errorOnRelativeLinks: z.boolean().default(true),
|
|
36
|
+
})
|
|
37
|
+
.default({})
|
|
38
|
+
|
|
39
|
+
export default function starlightLinksValidatorPlugin(
|
|
40
|
+
userOptions?: StarlightLinksValidatorUserOptions,
|
|
41
|
+
): StarlightPlugin {
|
|
42
|
+
const options = starlightLinksValidatorOptionsSchema.safeParse(userOptions)
|
|
43
|
+
|
|
44
|
+
if (!options.success) {
|
|
45
|
+
throwPluginError('Invalid options passed to the starlight-links-validator plugin.')
|
|
46
|
+
}
|
|
47
|
+
|
|
8
48
|
return {
|
|
9
|
-
name: 'starlight-links-validator',
|
|
49
|
+
name: 'starlight-links-validator-plugin',
|
|
10
50
|
hooks: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
},
|
|
20
|
-
})
|
|
21
|
-
},
|
|
22
|
-
'astro:build:done': ({ dir, pages }) => {
|
|
23
|
-
const errors = validateLinks(pages, dir)
|
|
51
|
+
setup({ addIntegration, astroConfig, config: starlightConfig, logger }) {
|
|
52
|
+
addIntegration({
|
|
53
|
+
name: 'starlight-links-validator-integration',
|
|
54
|
+
hooks: {
|
|
55
|
+
'astro:config:setup': ({ command, updateConfig }) => {
|
|
56
|
+
if (command !== 'build') {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
24
59
|
|
|
25
|
-
|
|
60
|
+
updateConfig({
|
|
61
|
+
markdown: {
|
|
62
|
+
remarkPlugins: [[remarkStarlightLinksValidator, astroConfig.base]],
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
'astro:build:done': ({ dir, pages }) => {
|
|
67
|
+
const errors = validateLinks(pages, dir, astroConfig.base, starlightConfig, options.data)
|
|
26
68
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
69
|
+
logErrors(logger, errors)
|
|
70
|
+
|
|
71
|
+
if (errors.size > 0) {
|
|
72
|
+
throwPluginError('Links validation failed.')
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
})
|
|
33
77
|
},
|
|
34
78
|
},
|
|
35
79
|
}
|
|
36
80
|
}
|
|
81
|
+
|
|
82
|
+
function throwPluginError(message: string): never {
|
|
83
|
+
throw new AstroError(
|
|
84
|
+
message,
|
|
85
|
+
`See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-links-validator/issues/new/choose.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type StarlightLinksValidatorUserOptions = z.input<typeof starlightLinksValidatorOptionsSchema>
|
|
90
|
+
export type StarlightLinksValidatorOptions = z.output<typeof starlightLinksValidatorOptionsSchema>
|
package/libs/i18n.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ensureLeadingSlash, ensureTrailingSlash } from './path'
|
|
2
|
+
import type { Headings } from './remark'
|
|
3
|
+
import type { StarlightUserConfig } from './validation'
|
|
4
|
+
|
|
5
|
+
export function getLocaleConfig(config: StarlightUserConfig): LocaleConfig | undefined {
|
|
6
|
+
if (!config.locales || Object.keys(config.locales).length === 0) return
|
|
7
|
+
|
|
8
|
+
let defaultLocale = config.defaultLocale
|
|
9
|
+
const locales: string[] = []
|
|
10
|
+
|
|
11
|
+
for (const [dir, locale] of Object.entries(config.locales)) {
|
|
12
|
+
if (!locale) continue
|
|
13
|
+
|
|
14
|
+
if (dir === 'root') {
|
|
15
|
+
if (!locale.lang) continue
|
|
16
|
+
|
|
17
|
+
defaultLocale = ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
locales.push(dir)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (defaultLocale === undefined) return
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
defaultLocale,
|
|
27
|
+
locales,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getFallbackHeadings(
|
|
32
|
+
path: string,
|
|
33
|
+
headings: Headings,
|
|
34
|
+
localeConfig: LocaleConfig | undefined,
|
|
35
|
+
): string[] | undefined {
|
|
36
|
+
if (!localeConfig) return
|
|
37
|
+
|
|
38
|
+
for (const locale of localeConfig.locales) {
|
|
39
|
+
if (path.startsWith(`${locale}/`)) {
|
|
40
|
+
const fallbackPath = path.replace(
|
|
41
|
+
new RegExp(`^${locale}/`),
|
|
42
|
+
localeConfig.defaultLocale === '' ? localeConfig.defaultLocale : `${localeConfig.defaultLocale}/`,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return headings.get(fallbackPath === '' ? '/' : fallbackPath)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isInconsistentLocaleLink(path: string, link: string, localeConfig: LocaleConfig) {
|
|
53
|
+
const pathLocale = getLocale(path, localeConfig)
|
|
54
|
+
const linkLocale = getLocale(link, localeConfig)
|
|
55
|
+
|
|
56
|
+
if (pathLocale !== undefined || linkLocale !== undefined) {
|
|
57
|
+
return pathLocale !== linkLocale
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getLocale(path: string, localeConfig: LocaleConfig) {
|
|
64
|
+
const normalizedPath = ensureTrailingSlash(ensureLeadingSlash(path))
|
|
65
|
+
|
|
66
|
+
for (const locale of localeConfig.locales) {
|
|
67
|
+
if (normalizedPath.startsWith(`/${locale}/`)) {
|
|
68
|
+
return locale
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface LocaleConfig {
|
|
76
|
+
defaultLocale: string
|
|
77
|
+
locales: string[]
|
|
78
|
+
}
|
package/libs/path.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function ensureLeadingSlash(path: string): string {
|
|
2
|
+
return path.startsWith('/') ? path : `/${path}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function ensureTrailingSlash(path: string): string {
|
|
6
|
+
return path.endsWith('/') ? path : `${path}/`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stripLeadingSlash(path: string) {
|
|
10
|
+
return path.replace(/^\//, '')
|
|
11
|
+
}
|
package/libs/remark.ts
CHANGED
|
@@ -12,15 +12,17 @@ import { toString } from 'mdast-util-to-string'
|
|
|
12
12
|
import type { Plugin } from 'unified'
|
|
13
13
|
import { visit } from 'unist-util-visit'
|
|
14
14
|
|
|
15
|
+
import { stripLeadingSlash } from './path'
|
|
16
|
+
|
|
15
17
|
// All the headings keyed by file path.
|
|
16
18
|
const headings: Headings = new Map()
|
|
17
19
|
// All the internal links keyed by file path.
|
|
18
20
|
const links: Links = new Map()
|
|
19
21
|
|
|
20
|
-
export const remarkStarlightLinksValidator: Plugin<[], Root> = function () {
|
|
22
|
+
export const remarkStarlightLinksValidator: Plugin<[base: string], Root> = function (base) {
|
|
21
23
|
return (tree, file) => {
|
|
22
24
|
const slugger = new GitHubSlugger()
|
|
23
|
-
const filePath = normalizeFilePath(file.history[0])
|
|
25
|
+
const filePath = normalizeFilePath(base, file.history[0])
|
|
24
26
|
|
|
25
27
|
const fileHeadings: string[] = []
|
|
26
28
|
const fileLinks: string[] = []
|
|
@@ -131,22 +133,28 @@ export function getValidationData() {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
function isInternalLink(link: string) {
|
|
134
|
-
return nodePath.isAbsolute(link) || link.startsWith('#')
|
|
136
|
+
return nodePath.isAbsolute(link) || link.startsWith('#') || link.startsWith('.')
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
function normalizeFilePath(filePath?: string) {
|
|
139
|
+
function normalizeFilePath(base: string, filePath?: string) {
|
|
138
140
|
if (!filePath) {
|
|
139
141
|
throw new Error('Missing file path to validate links.')
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
const path = nodePath
|
|
143
145
|
.relative(nodePath.join(process.cwd(), 'src/content/docs'), filePath)
|
|
144
146
|
.replace(/\.\w+$/, '')
|
|
145
147
|
.replace(/index$/, '')
|
|
146
|
-
.replace(
|
|
148
|
+
.replace(/[/\\]?$/, '/')
|
|
147
149
|
.split(/[/\\]/)
|
|
148
150
|
.map((segment) => slug(segment))
|
|
149
151
|
.join('/')
|
|
152
|
+
|
|
153
|
+
if (base !== '/') {
|
|
154
|
+
return nodePath.posix.join(stripLeadingSlash(base), path)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return path
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
function isMdxIdAttribute(attribute: MdxJsxAttribute | MdxJsxExpressionAttribute): attribute is MdxIdAttribute {
|
package/libs/validation.ts
CHANGED
|
@@ -1,24 +1,60 @@
|
|
|
1
1
|
import { statSync } from 'node:fs'
|
|
2
|
+
import { posix } from 'node:path'
|
|
2
3
|
import { fileURLToPath } from 'node:url'
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import type { StarlightPlugin } from '@astrojs/starlight/types'
|
|
6
|
+
import type { AstroIntegrationLogger } from 'astro'
|
|
7
|
+
import { bgGreen, black, blue, dim, green, red } from 'kleur/colors'
|
|
5
8
|
|
|
9
|
+
import type { StarlightLinksValidatorOptions } from '..'
|
|
10
|
+
|
|
11
|
+
import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
|
|
12
|
+
import { ensureTrailingSlash, stripLeadingSlash } from './path'
|
|
6
13
|
import { getValidationData, type Headings } from './remark'
|
|
7
14
|
|
|
8
|
-
export
|
|
15
|
+
export const ValidationErrorType = {
|
|
16
|
+
InconsistentLocale: 'inconsistent locale',
|
|
17
|
+
InvalidAnchor: 'invalid anchor',
|
|
18
|
+
InvalidLink: 'invalid link',
|
|
19
|
+
RelativeLink: 'relative link',
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
export function validateLinks(
|
|
23
|
+
pages: PageData[],
|
|
24
|
+
outputDir: URL,
|
|
25
|
+
base: string,
|
|
26
|
+
starlightConfig: StarlightUserConfig,
|
|
27
|
+
options: StarlightLinksValidatorOptions,
|
|
28
|
+
): ValidationErrors {
|
|
9
29
|
process.stdout.write(`\n${bgGreen(black(` validating links `))}\n`)
|
|
10
30
|
|
|
31
|
+
const localeConfig = getLocaleConfig(starlightConfig)
|
|
11
32
|
const { headings, links } = getValidationData()
|
|
12
|
-
const allPages: Pages = new Set(
|
|
33
|
+
const allPages: Pages = new Set(
|
|
34
|
+
pages.map((page) =>
|
|
35
|
+
ensureTrailingSlash(base === '/' ? page.pathname : posix.join(stripLeadingSlash(base), page.pathname)),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
13
38
|
|
|
14
39
|
const errors: ValidationErrors = new Map()
|
|
15
40
|
|
|
16
41
|
for (const [filePath, fileLinks] of links) {
|
|
17
42
|
for (const link of fileLinks) {
|
|
43
|
+
const validationContext: ValidationContext = {
|
|
44
|
+
errors,
|
|
45
|
+
filePath,
|
|
46
|
+
headings,
|
|
47
|
+
link,
|
|
48
|
+
localeConfig,
|
|
49
|
+
options,
|
|
50
|
+
outputDir,
|
|
51
|
+
pages: allPages,
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
if (link.startsWith('#')) {
|
|
19
|
-
validateSelfAnchor(
|
|
55
|
+
validateSelfAnchor(validationContext)
|
|
20
56
|
} else {
|
|
21
|
-
validateLink(
|
|
57
|
+
validateLink(validationContext)
|
|
22
58
|
}
|
|
23
59
|
}
|
|
24
60
|
}
|
|
@@ -26,49 +62,46 @@ export function validateLinks(pages: PageData[], outputDir: URL): ValidationErro
|
|
|
26
62
|
return errors
|
|
27
63
|
}
|
|
28
64
|
|
|
29
|
-
export function logErrors(errors: ValidationErrors) {
|
|
65
|
+
export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors) {
|
|
66
|
+
const logger = pluginLogger.fork('')
|
|
67
|
+
|
|
30
68
|
if (errors.size === 0) {
|
|
31
|
-
|
|
69
|
+
logger.info(green('✓ All internal links are valid.\n'))
|
|
32
70
|
return
|
|
33
71
|
}
|
|
34
72
|
|
|
35
73
|
const errorCount = [...errors.values()].reduce((acc, links) => acc + links.length, 0)
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
),
|
|
45
|
-
)}\n\n`,
|
|
75
|
+
logger.error(
|
|
76
|
+
red(
|
|
77
|
+
`✗ Found ${errorCount} invalid ${pluralize(errorCount, 'link')} in ${errors.size} ${pluralize(
|
|
78
|
+
errors.size,
|
|
79
|
+
'file',
|
|
80
|
+
)}.`,
|
|
81
|
+
),
|
|
46
82
|
)
|
|
47
83
|
|
|
48
|
-
for (const [file,
|
|
49
|
-
|
|
84
|
+
for (const [file, validationErrors] of errors) {
|
|
85
|
+
logger.info(`${red('▶')} ${blue(file)}`)
|
|
50
86
|
|
|
51
|
-
for (const [index,
|
|
52
|
-
|
|
87
|
+
for (const [index, validationError] of validationErrors.entries()) {
|
|
88
|
+
logger.info(
|
|
89
|
+
` ${blue(`${index < validationErrors.length - 1 ? '├' : '└'}─`)} ${validationError.link}${dim(
|
|
90
|
+
` - ${validationError.type}`,
|
|
91
|
+
)}`,
|
|
92
|
+
)
|
|
53
93
|
}
|
|
54
|
-
|
|
55
|
-
process.stdout.write(dim('\n'))
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
process.stdout.write(
|
|
96
|
+
process.stdout.write('\n')
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
/**
|
|
62
100
|
* Validate a link to another internal page that may or may not have a hash.
|
|
63
101
|
*/
|
|
64
|
-
function validateLink(
|
|
65
|
-
errors
|
|
66
|
-
|
|
67
|
-
filePath: string,
|
|
68
|
-
headings: Headings,
|
|
69
|
-
pages: Pages,
|
|
70
|
-
outputDir: URL,
|
|
71
|
-
) {
|
|
102
|
+
function validateLink(context: ValidationContext) {
|
|
103
|
+
const { errors, filePath, link, localeConfig, options, outputDir, pages } = context
|
|
104
|
+
|
|
72
105
|
const sanitizedLink = link.replace(/^\//, '')
|
|
73
106
|
const segments = sanitizedLink.split('#')
|
|
74
107
|
|
|
@@ -79,32 +112,53 @@ function validateLink(
|
|
|
79
112
|
throw new Error('Failed to validate a link with no path.')
|
|
80
113
|
}
|
|
81
114
|
|
|
82
|
-
if (
|
|
115
|
+
if (path.startsWith('.')) {
|
|
116
|
+
if (options.errorOnRelativeLinks) {
|
|
117
|
+
addError(errors, filePath, link, ValidationErrorType.RelativeLink)
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
return
|
|
84
121
|
}
|
|
85
122
|
|
|
86
|
-
if (path
|
|
87
|
-
|
|
123
|
+
if (isValidAsset(path, outputDir)) {
|
|
124
|
+
return
|
|
88
125
|
}
|
|
89
126
|
|
|
127
|
+
path = ensureTrailingSlash(path)
|
|
128
|
+
|
|
90
129
|
const isValidPage = pages.has(path)
|
|
91
|
-
const fileHeadings =
|
|
130
|
+
const fileHeadings = getFileHeadings(path, context)
|
|
92
131
|
|
|
93
132
|
if (!isValidPage || !fileHeadings) {
|
|
94
|
-
addError(errors, filePath, link)
|
|
133
|
+
addError(errors, filePath, link, ValidationErrorType.InvalidLink)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link, localeConfig)) {
|
|
138
|
+
addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
|
|
95
139
|
return
|
|
96
140
|
}
|
|
97
141
|
|
|
98
142
|
if (hash && !fileHeadings.includes(hash)) {
|
|
99
|
-
addError(errors, filePath, link)
|
|
143
|
+
addError(errors, filePath, link, ValidationErrorType.InvalidAnchor)
|
|
100
144
|
}
|
|
101
145
|
}
|
|
102
146
|
|
|
147
|
+
function getFileHeadings(path: string, { headings, localeConfig, options }: ValidationContext) {
|
|
148
|
+
let heading = headings.get(path === '' ? '/' : path)
|
|
149
|
+
|
|
150
|
+
if (!options.errorOnFallbackPages && !heading && localeConfig) {
|
|
151
|
+
heading = getFallbackHeadings(path, headings, localeConfig)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return heading
|
|
155
|
+
}
|
|
156
|
+
|
|
103
157
|
/**
|
|
104
158
|
* Validate a link to an anchor in the same page.
|
|
105
159
|
*/
|
|
106
|
-
function validateSelfAnchor(errors
|
|
107
|
-
const sanitizedHash =
|
|
160
|
+
function validateSelfAnchor({ errors, link, filePath, headings }: ValidationContext) {
|
|
161
|
+
const sanitizedHash = link.replace(/^#/, '')
|
|
108
162
|
const fileHeadings = headings.get(filePath)
|
|
109
163
|
|
|
110
164
|
if (!fileHeadings) {
|
|
@@ -112,7 +166,7 @@ function validateSelfAnchor(errors: ValidationErrors, hash: string, filePath: st
|
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
if (!fileHeadings.includes(sanitizedHash)) {
|
|
115
|
-
addError(errors, filePath,
|
|
169
|
+
addError(errors, filePath, link, 'invalid anchor')
|
|
116
170
|
}
|
|
117
171
|
}
|
|
118
172
|
|
|
@@ -131,9 +185,9 @@ function isValidAsset(path: string, outputDir: URL) {
|
|
|
131
185
|
}
|
|
132
186
|
}
|
|
133
187
|
|
|
134
|
-
function addError(errors: ValidationErrors, filePath: string, link: string) {
|
|
188
|
+
function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
|
|
135
189
|
const fileErrors = errors.get(filePath) ?? []
|
|
136
|
-
fileErrors.push(link)
|
|
190
|
+
fileErrors.push({ link, type })
|
|
137
191
|
|
|
138
192
|
errors.set(filePath, fileErrors)
|
|
139
193
|
}
|
|
@@ -142,11 +196,31 @@ function pluralize(count: number, singular: string) {
|
|
|
142
196
|
return count === 1 ? singular : `${singular}s`
|
|
143
197
|
}
|
|
144
198
|
|
|
145
|
-
// The
|
|
146
|
-
type ValidationErrors = Map<string,
|
|
199
|
+
// The validation errors keyed by file path.
|
|
200
|
+
type ValidationErrors = Map<string, ValidationError[]>
|
|
201
|
+
|
|
202
|
+
export type ValidationErrorType = (typeof ValidationErrorType)[keyof typeof ValidationErrorType]
|
|
203
|
+
|
|
204
|
+
interface ValidationError {
|
|
205
|
+
link: string
|
|
206
|
+
type: ValidationErrorType
|
|
207
|
+
}
|
|
147
208
|
|
|
148
209
|
interface PageData {
|
|
149
210
|
pathname: string
|
|
150
211
|
}
|
|
151
212
|
|
|
152
213
|
type Pages = Set<PageData['pathname']>
|
|
214
|
+
|
|
215
|
+
interface ValidationContext {
|
|
216
|
+
errors: ValidationErrors
|
|
217
|
+
filePath: string
|
|
218
|
+
headings: Headings
|
|
219
|
+
link: string
|
|
220
|
+
localeConfig: LocaleConfig | undefined
|
|
221
|
+
options: StarlightLinksValidatorOptions
|
|
222
|
+
outputDir: URL
|
|
223
|
+
pages: Pages
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type StarlightUserConfig = Parameters<StarlightPlugin['hooks']['setup']>['0']['config']
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starlight-links-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Starlight plugin to validate internal links.",
|
|
6
6
|
"author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
@@ -18,19 +18,19 @@
|
|
|
18
18
|
"unist-util-visit": "5.0.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@astrojs/starlight": "0.
|
|
22
|
-
"@types/hast": "3.0.
|
|
23
|
-
"@types/mdast": "4.0.
|
|
21
|
+
"@astrojs/starlight": "0.15.0",
|
|
22
|
+
"@types/hast": "3.0.3",
|
|
23
|
+
"@types/mdast": "4.0.3",
|
|
24
24
|
"@types/node": "18.17.18",
|
|
25
|
-
"astro": "
|
|
25
|
+
"astro": "4.0.4",
|
|
26
26
|
"mdast-util-mdx-jsx": "3.0.0",
|
|
27
27
|
"typescript": "5.1.3",
|
|
28
|
-
"unified": "11.0.
|
|
29
|
-
"vitest": "0.
|
|
28
|
+
"unified": "11.0.4",
|
|
29
|
+
"vitest": "1.0.4"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@astrojs/starlight": ">=0.
|
|
33
|
-
"astro": ">=
|
|
32
|
+
"@astrojs/starlight": ">=0.15.0",
|
|
33
|
+
"astro": ">=4.0.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=18.14.1"
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"sideEffects": false,
|
|
43
43
|
"keywords": [
|
|
44
44
|
"starlight",
|
|
45
|
+
"plugin",
|
|
45
46
|
"links",
|
|
46
47
|
"validation",
|
|
47
|
-
"astro"
|
|
48
|
-
"astro-integration"
|
|
48
|
+
"astro"
|
|
49
49
|
],
|
|
50
50
|
"homepage": "https://github.com/HiDeoo/starlight-links-validator",
|
|
51
51
|
"repository": {
|
|
@@ -55,6 +55,6 @@
|
|
|
55
55
|
"bugs": "https://github.com/HiDeoo/starlight-links-validator/issues",
|
|
56
56
|
"scripts": {
|
|
57
57
|
"test": "vitest",
|
|
58
|
-
"lint": "prettier -c --cache . && eslint . --cache --max-warnings=0
|
|
58
|
+
"lint": "prettier -c --cache . && eslint . --cache --max-warnings=0"
|
|
59
59
|
}
|
|
60
60
|
}
|