starlight-links-validator 0.4.2 → 0.5.0

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,9 +1,9 @@
1
1
  <div align="center">
2
2
  <h1>starlight-links-validator 🦺</h1>
3
- <p>Astro integration for Starlight to validate internal links.</p>
3
+ <p>Starlight plugin to validate internal links.</p>
4
4
  <p>
5
- <a href="https://i.imgur.com/EgiTGeR.png" title="Screenshot of starlight-links-validator">
6
- <img alt="Screenshot of starlight-links-validator" src="https://i.imgur.com/EgiTGeR.png" width="520" />
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
- An [Astro](https://astro.build) integration for [Starlight](https://starlight.astro.build) Starlight to validate **_internal_** links in Markdown and MDX files.
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 { AstroIntegration } from 'astro'
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
- export default function starlightLinksValidatorIntegration(): AstroIntegration {
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
- 'astro:config:setup': ({ command, updateConfig }) => {
12
- if (command !== 'build') {
13
- return
14
- }
15
-
16
- updateConfig({
17
- markdown: {
18
- remarkPlugins: [remarkStarlightLinksValidator],
19
- },
20
- })
21
- },
22
- 'astro:build:done': ({ dir, pages }) => {
23
- const errors = validateLinks(pages, dir)
51
+ setup({ addIntegration, 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
- logErrors(errors)
60
+ updateConfig({
61
+ markdown: {
62
+ remarkPlugins: [remarkStarlightLinksValidator],
63
+ },
64
+ })
65
+ },
66
+ 'astro:build:done': ({ dir, pages }) => {
67
+ const errors = validateLinks(pages, dir, starlightConfig, options.data)
26
68
 
27
- if (errors.size > 0) {
28
- throw new AstroError(
29
- 'Links validation failed.',
30
- `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.`,
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,7 @@
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
+ }
package/libs/remark.ts CHANGED
@@ -131,7 +131,7 @@ export function getValidationData() {
131
131
  }
132
132
 
133
133
  function isInternalLink(link: string) {
134
- return nodePath.isAbsolute(link) || link.startsWith('#')
134
+ return nodePath.isAbsolute(link) || link.startsWith('#') || link.startsWith('.')
135
135
  }
136
136
 
137
137
  function normalizeFilePath(filePath?: string) {
@@ -143,7 +143,7 @@ function normalizeFilePath(filePath?: string) {
143
143
  .relative(nodePath.join(process.cwd(), 'src/content/docs'), filePath)
144
144
  .replace(/\.\w+$/, '')
145
145
  .replace(/index$/, '')
146
- .replace(/\/?$/, '/')
146
+ .replace(/[/\\]?$/, '/')
147
147
  .split(/[/\\]/)
148
148
  .map((segment) => slug(segment))
149
149
  .join('/')
@@ -1,24 +1,54 @@
1
1
  import { statSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
3
 
4
- import { bgGreen, black, bold, cyan, dim, red } from 'kleur/colors'
4
+ import type { StarlightPlugin } from '@astrojs/starlight/types'
5
+ import type { AstroIntegrationLogger } from 'astro'
6
+ import { bgGreen, black, blue, dim, green, red } from 'kleur/colors'
5
7
 
8
+ import type { StarlightLinksValidatorOptions } from '..'
9
+
10
+ import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
11
+ import { ensureTrailingSlash } from './path'
6
12
  import { getValidationData, type Headings } from './remark'
7
13
 
8
- export function validateLinks(pages: PageData[], outputDir: URL): ValidationErrors {
14
+ export const ValidationErrorType = {
15
+ InconsistentLocale: 'inconsistent locale',
16
+ InvalidAnchor: 'invalid anchor',
17
+ InvalidLink: 'invalid link',
18
+ RelativeLink: 'relative link',
19
+ } as const
20
+
21
+ export function validateLinks(
22
+ pages: PageData[],
23
+ outputDir: URL,
24
+ starlightConfig: StarlightUserConfig,
25
+ options: StarlightLinksValidatorOptions,
26
+ ): ValidationErrors {
9
27
  process.stdout.write(`\n${bgGreen(black(` validating links `))}\n`)
10
28
 
29
+ const localeConfig = getLocaleConfig(starlightConfig)
11
30
  const { headings, links } = getValidationData()
12
- const allPages: Pages = new Set(pages.map((page) => page.pathname))
31
+ const allPages: Pages = new Set(pages.map((page) => ensureTrailingSlash(page.pathname)))
13
32
 
14
33
  const errors: ValidationErrors = new Map()
15
34
 
16
35
  for (const [filePath, fileLinks] of links) {
17
36
  for (const link of fileLinks) {
37
+ const validationContext: ValidationContext = {
38
+ errors,
39
+ filePath,
40
+ headings,
41
+ link,
42
+ localeConfig,
43
+ options,
44
+ outputDir,
45
+ pages: allPages,
46
+ }
47
+
18
48
  if (link.startsWith('#')) {
19
- validateSelfAnchor(errors, link, filePath, headings)
49
+ validateSelfAnchor(validationContext)
20
50
  } else {
21
- validateLink(errors, link, filePath, headings, allPages, outputDir)
51
+ validateLink(validationContext)
22
52
  }
23
53
  }
24
54
  }
@@ -26,49 +56,46 @@ export function validateLinks(pages: PageData[], outputDir: URL): ValidationErro
26
56
  return errors
27
57
  }
28
58
 
29
- export function logErrors(errors: ValidationErrors) {
59
+ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors) {
60
+ const logger = pluginLogger.fork('')
61
+
30
62
  if (errors.size === 0) {
31
- process.stdout.write(dim('All internal links are valid.\n\n'))
63
+ logger.info(green('All internal links are valid.\n'))
32
64
  return
33
65
  }
34
66
 
35
67
  const errorCount = [...errors.values()].reduce((acc, links) => acc + links.length, 0)
36
68
 
37
- process.stderr.write(
38
- `${bold(
39
- red(
40
- `Found ${errorCount} invalid ${pluralize(errorCount, 'link')} in ${errors.size} ${pluralize(
41
- errors.size,
42
- 'file',
43
- )}.`,
44
- ),
45
- )}\n\n`,
69
+ logger.error(
70
+ red(
71
+ `✗ Found ${errorCount} invalid ${pluralize(errorCount, 'link')} in ${errors.size} ${pluralize(
72
+ errors.size,
73
+ 'file',
74
+ )}.`,
75
+ ),
46
76
  )
47
77
 
48
- for (const [file, links] of errors) {
49
- process.stderr.write(`${red('▶')} ${file}\n`)
78
+ for (const [file, validationErrors] of errors) {
79
+ logger.info(`${red('▶')} ${blue(file)}`)
50
80
 
51
- for (const [index, link] of links.entries()) {
52
- process.stderr.write(` ${cyan(`${index < links.length - 1 ? '├' : '└'}─`)} ${link}\n`)
81
+ for (const [index, validationError] of validationErrors.entries()) {
82
+ logger.info(
83
+ ` ${blue(`${index < validationErrors.length - 1 ? '├' : '└'}─`)} ${validationError.link}${dim(
84
+ ` - ${validationError.type}`,
85
+ )}`,
86
+ )
53
87
  }
54
-
55
- process.stdout.write(dim('\n'))
56
88
  }
57
89
 
58
- process.stdout.write(dim('\n'))
90
+ process.stdout.write('\n')
59
91
  }
60
92
 
61
93
  /**
62
94
  * Validate a link to another internal page that may or may not have a hash.
63
95
  */
64
- function validateLink(
65
- errors: ValidationErrors,
66
- link: string,
67
- filePath: string,
68
- headings: Headings,
69
- pages: Pages,
70
- outputDir: URL,
71
- ) {
96
+ function validateLink(context: ValidationContext) {
97
+ const { errors, filePath, link, localeConfig, options, outputDir, pages } = context
98
+
72
99
  const sanitizedLink = link.replace(/^\//, '')
73
100
  const segments = sanitizedLink.split('#')
74
101
 
@@ -79,32 +106,53 @@ function validateLink(
79
106
  throw new Error('Failed to validate a link with no path.')
80
107
  }
81
108
 
82
- if (isValidAsset(path, outputDir)) {
109
+ if (path.startsWith('.')) {
110
+ if (options.errorOnRelativeLinks) {
111
+ addError(errors, filePath, link, ValidationErrorType.RelativeLink)
112
+ }
113
+
83
114
  return
84
115
  }
85
116
 
86
- if (path.length > 0 && !path.endsWith('/')) {
87
- path += '/'
117
+ if (isValidAsset(path, outputDir)) {
118
+ return
88
119
  }
89
120
 
121
+ path = ensureTrailingSlash(path)
122
+
90
123
  const isValidPage = pages.has(path)
91
- const fileHeadings = headings.get(path === '' ? '/' : path)
124
+ const fileHeadings = getFileHeadings(path, context)
92
125
 
93
126
  if (!isValidPage || !fileHeadings) {
94
- addError(errors, filePath, link)
127
+ addError(errors, filePath, link, ValidationErrorType.InvalidLink)
128
+ return
129
+ }
130
+
131
+ if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link, localeConfig)) {
132
+ addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
95
133
  return
96
134
  }
97
135
 
98
136
  if (hash && !fileHeadings.includes(hash)) {
99
- addError(errors, filePath, link)
137
+ addError(errors, filePath, link, ValidationErrorType.InvalidAnchor)
138
+ }
139
+ }
140
+
141
+ function getFileHeadings(path: string, { headings, localeConfig, options }: ValidationContext) {
142
+ let heading = headings.get(path === '' ? '/' : path)
143
+
144
+ if (!options.errorOnFallbackPages && !heading && localeConfig) {
145
+ heading = getFallbackHeadings(path, headings, localeConfig)
100
146
  }
147
+
148
+ return heading
101
149
  }
102
150
 
103
151
  /**
104
152
  * Validate a link to an anchor in the same page.
105
153
  */
106
- function validateSelfAnchor(errors: ValidationErrors, hash: string, filePath: string, headings: Headings) {
107
- const sanitizedHash = hash.replace(/^#/, '')
154
+ function validateSelfAnchor({ errors, link, filePath, headings }: ValidationContext) {
155
+ const sanitizedHash = link.replace(/^#/, '')
108
156
  const fileHeadings = headings.get(filePath)
109
157
 
110
158
  if (!fileHeadings) {
@@ -112,7 +160,7 @@ function validateSelfAnchor(errors: ValidationErrors, hash: string, filePath: st
112
160
  }
113
161
 
114
162
  if (!fileHeadings.includes(sanitizedHash)) {
115
- addError(errors, filePath, hash)
163
+ addError(errors, filePath, link, 'invalid anchor')
116
164
  }
117
165
  }
118
166
 
@@ -131,9 +179,9 @@ function isValidAsset(path: string, outputDir: URL) {
131
179
  }
132
180
  }
133
181
 
134
- function addError(errors: ValidationErrors, filePath: string, link: string) {
182
+ function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
135
183
  const fileErrors = errors.get(filePath) ?? []
136
- fileErrors.push(link)
184
+ fileErrors.push({ link, type })
137
185
 
138
186
  errors.set(filePath, fileErrors)
139
187
  }
@@ -142,11 +190,31 @@ function pluralize(count: number, singular: string) {
142
190
  return count === 1 ? singular : `${singular}s`
143
191
  }
144
192
 
145
- // The invalid links keyed by file path.
146
- type ValidationErrors = Map<string, string[]>
193
+ // The validation errors keyed by file path.
194
+ type ValidationErrors = Map<string, ValidationError[]>
195
+
196
+ export type ValidationErrorType = (typeof ValidationErrorType)[keyof typeof ValidationErrorType]
197
+
198
+ interface ValidationError {
199
+ link: string
200
+ type: ValidationErrorType
201
+ }
147
202
 
148
203
  interface PageData {
149
204
  pathname: string
150
205
  }
151
206
 
152
207
  type Pages = Set<PageData['pathname']>
208
+
209
+ interface ValidationContext {
210
+ errors: ValidationErrors
211
+ filePath: string
212
+ headings: Headings
213
+ link: string
214
+ localeConfig: LocaleConfig | undefined
215
+ options: StarlightLinksValidatorOptions
216
+ outputDir: URL
217
+ pages: Pages
218
+ }
219
+
220
+ 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.4.2",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
- "description": "Astro integration for Starlight to validate internal links.",
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.10.1",
22
- "@types/hast": "3.0.1",
23
- "@types/mdast": "4.0.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": "3.1.1",
25
+ "astro": "4.0.4",
26
26
  "mdast-util-mdx-jsx": "3.0.0",
27
27
  "typescript": "5.1.3",
28
- "unified": "11.0.3",
29
- "vitest": "0.32.2"
28
+ "unified": "11.0.4",
29
+ "vitest": "1.0.4"
30
30
  },
31
31
  "peerDependencies": {
32
- "@astrojs/starlight": ">=0.9.0",
33
- "astro": ">=3.0.0"
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 && tsc --noEmit"
58
+ "lint": "prettier -c --cache . && eslint . --cache --max-warnings=0"
59
59
  }
60
60
  }