starlight-links-validator 0.14.3 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # starlight-links-validator
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#93](https://github.com/HiDeoo/starlight-links-validator/pull/93) [`6d7174b`](https://github.com/HiDeoo/starlight-links-validator/commit/6d7174bcc6a2bb39f287a50bbdda29a6af4c16c8) Thanks [@HiDeoo](https://github.com/HiDeoo)! - ⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now version `0.32.0`.
8
+
9
+ Please use the `@astrojs/upgrade` command to upgrade your project:
10
+
11
+ ```sh
12
+ npx @astrojs/upgrade
13
+ ```
14
+
15
+ - [#100](https://github.com/HiDeoo/starlight-links-validator/pull/100) [`b238cb7`](https://github.com/HiDeoo/starlight-links-validator/commit/b238cb7bd3db5f8fe848c317ba52d5ab44eb853e) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds a new [`sameSitePolicy` option](https://starlight-links-validator.vercel.app/configuration#samesitepolicy) to configure how external links pointing to the same origin as the one configured in the [Astro `site` option](https://docs.astro.build/en/reference/configuration-reference/#site) should be handled.
16
+
17
+ The current default behavior to ignore all external links remains unchanged. This new option allows to error on such links so they can be rewritten without the origin or to validate them as if they were internal links.
18
+
19
+ - [#100](https://github.com/HiDeoo/starlight-links-validator/pull/100) [`b238cb7`](https://github.com/HiDeoo/starlight-links-validator/commit/b238cb7bd3db5f8fe848c317ba52d5ab44eb853e) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds a new [`components`](https://starlight-links-validator.vercel.app/configuration#components) option to define additional components and their props to validate as links on top of the built-in `<LinkButton>` and `<LinkCard>` Starlight components.
20
+
21
+ ### Patch Changes
22
+
23
+ - [#99](https://github.com/HiDeoo/starlight-links-validator/pull/99) [`56ea78c`](https://github.com/HiDeoo/starlight-links-validator/commit/56ea78cefa40f554f88a32181daae1a82ec2fa9a) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Fixes validation issue with the [Astro `base` option](https://docs.astro.build/en/reference/configuration-reference/#base) and the [`errorOnFallbackPages` plugin option](https://starlight-links-validator.vercel.app/configuration#erroronfallbackpages) set to `false` in a multilingual project.
24
+
3
25
  ## 0.14.3
4
26
 
5
27
  ### Patch Changes
package/index.ts CHANGED
@@ -4,12 +4,22 @@ import { AstroError } from 'astro/errors'
4
4
  import { z } from 'astro/zod'
5
5
 
6
6
  import { clearContentLayerCache } from './libs/astro'
7
- import { pathnameToSlug } from './libs/path'
8
- import { remarkStarlightLinksValidator } from './libs/remark'
7
+ import { pathnameToSlug, stripTrailingSlash } from './libs/path'
8
+ import { remarkStarlightLinksValidator, type RemarkStarlightLinksValidatorConfig } from './libs/remark'
9
9
  import { logErrors, validateLinks } from './libs/validation'
10
10
 
11
11
  const starlightLinksValidatorOptionsSchema = z
12
12
  .object({
13
+ /**
14
+ * Defines a list of additional components and their props that should be validated as links.
15
+ *
16
+ * By default, the plugin will only validate links defined in the `href` prop of the `<LinkButton>` and `<LinkCard>`
17
+ * built-in Starlight components.
18
+ * Adding custom components to this list will allow the plugin to validate links in those components as well.
19
+ *
20
+ * @default []
21
+ */
22
+ components: z.tuple([z.string(), z.string()]).array().default([]),
13
23
  /**
14
24
  * Defines whether the plugin should error on fallback pages.
15
25
  *
@@ -58,6 +68,20 @@ const starlightLinksValidatorOptionsSchema = z
58
68
  * @default []
59
69
  */
60
70
  exclude: z.array(z.string()).default([]),
71
+ /**
72
+ * Defines the policy for external links with an origin matching the Astro `site` option.
73
+ *
74
+ * By default, all external links are ignored and not validated by the plugin.
75
+ * Setting this option to `error` will make the plugin error on external links with an origin matching the Astro
76
+ * `site` option and hint that the link can be rewritten without the origin.
77
+ * Setting this option to `validate` will make the plugin validate external links with an origin matching the Astro
78
+ * `site` option as if they were internal links.
79
+ *
80
+ * @default 'ignore'
81
+ * @see https://docs.astro.build/en/reference/configuration-reference/#site
82
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/URL/origin
83
+ */
84
+ sameSitePolicy: z.enum(['error', 'ignore', 'validate']).default('ignore'),
61
85
  })
62
86
  .default({})
63
87
 
@@ -73,8 +97,9 @@ export default function starlightLinksValidatorPlugin(
73
97
  return {
74
98
  name: 'starlight-links-validator-plugin',
75
99
  hooks: {
76
- setup({ addIntegration, astroConfig, config: starlightConfig, logger }) {
100
+ 'config:setup'({ addIntegration, astroConfig, config: starlightConfig, logger }) {
77
101
  let routes: IntegrationResolvedRoute[] = []
102
+ const site = astroConfig.site ? stripTrailingSlash(astroConfig.site) : undefined
78
103
 
79
104
  addIntegration({
80
105
  name: 'starlight-links-validator-integration',
@@ -89,7 +114,15 @@ export default function starlightLinksValidatorPlugin(
89
114
  updateConfig({
90
115
  markdown: {
91
116
  remarkPlugins: [
92
- [remarkStarlightLinksValidator, { base: astroConfig.base, srcDir: astroConfig.srcDir }],
117
+ [
118
+ remarkStarlightLinksValidator,
119
+ {
120
+ base: astroConfig.base,
121
+ options: options.data,
122
+ site,
123
+ srcDir: astroConfig.srcDir,
124
+ } satisfies RemarkStarlightLinksValidatorConfig,
125
+ ],
93
126
  ],
94
127
  },
95
128
  })
@@ -111,7 +144,7 @@ export default function starlightLinksValidatorPlugin(
111
144
 
112
145
  const errors = validateLinks(pages, customPages, dir, astroConfig, starlightConfig, options.data)
113
146
 
114
- const hasInvalidLinkToCustomPage = logErrors(logger, errors)
147
+ const hasInvalidLinkToCustomPage = logErrors(logger, errors, site)
115
148
 
116
149
  if (errors.size > 0) {
117
150
  throwPluginError(
package/libs/i18n.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ensureLeadingSlash, ensureTrailingSlash } from './path'
1
+ import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingSlash } from './path'
2
2
  import type { Headings } from './remark'
3
3
  import type { StarlightUserConfig } from './validation'
4
4
 
@@ -32,14 +32,20 @@ export function getFallbackHeadings(
32
32
  path: string,
33
33
  headings: Headings,
34
34
  localeConfig: LocaleConfig | undefined,
35
+ base: string,
35
36
  ): string[] | undefined {
36
37
  if (!localeConfig) return
37
38
 
39
+ const isPathWithBase = base !== '/'
40
+ const normalizedBase = isPathWithBase ? ensureTrailingSlash(stripLeadingSlash(base)) : ''
41
+ const normalizedPath = isPathWithBase ? path.replace(normalizedBase, '') : path
42
+
38
43
  for (const locale of localeConfig.locales) {
39
- if (path.startsWith(`${locale}/`)) {
44
+ if (normalizedPath.startsWith(`${locale}/`)) {
40
45
  const fallbackPath = path.replace(
41
- new RegExp(`^${locale}/`),
42
- localeConfig.defaultLocale === '' ? localeConfig.defaultLocale : `${localeConfig.defaultLocale}/`,
46
+ new RegExp(`^${normalizedBase}${locale}/`),
47
+ normalizedBase +
48
+ (localeConfig.defaultLocale === '' ? localeConfig.defaultLocale : `${localeConfig.defaultLocale}/`),
43
49
  )
44
50
 
45
51
  return headings.get(fallbackPath === '' ? '/' : fallbackPath)
package/libs/remark.ts CHANGED
@@ -3,6 +3,7 @@ import 'mdast-util-mdx-jsx'
3
3
  import nodePath from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
+ import type { AstroConfig } from 'astro'
6
7
  import GitHubSlugger, { slug } from 'github-slugger'
7
8
  import type { Nodes } from 'hast'
8
9
  import { fromHtml } from 'hast-util-from-html'
@@ -14,17 +15,28 @@ import { toString } from 'mdast-util-to-string'
14
15
  import type { Plugin } from 'unified'
15
16
  import { visit } from 'unist-util-visit'
16
17
 
18
+ import type { StarlightLinksValidatorOptions } from '..'
19
+
17
20
  import { ensureTrailingSlash, stripLeadingSlash } from './path'
21
+ import { ValidationErrorType } from './validation'
22
+
23
+ const builtInComponents: StarlightLinksValidatorOptions['components'] = [
24
+ ['LinkButton', 'href'],
25
+ ['LinkCard', 'href'],
26
+ ]
18
27
 
19
28
  // All the headings keyed by file path.
20
29
  const headings: Headings = new Map()
21
30
  // All the internal links keyed by file path.
22
31
  const links: Links = new Map()
23
32
 
24
- export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL }], Root> = function ({
25
- base,
26
- srcDir,
27
- }) {
33
+ export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidatorConfig], Root> = function (config) {
34
+ const { base, options, srcDir } = config
35
+
36
+ const linkComponents: Record<string, string> = Object.fromEntries(
37
+ [...builtInComponents, ...options.components].map(([name, attribute]) => [name, attribute]),
38
+ )
39
+
28
40
  return (tree, file) => {
29
41
  if (file.data.astro?.frontmatter?.['draft']) return
30
42
 
@@ -34,7 +46,7 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
34
46
  typeof file.data.astro?.frontmatter?.['slug'] === 'string' ? file.data.astro.frontmatter['slug'] : undefined
35
47
 
36
48
  const fileHeadings: string[] = []
37
- const fileLinks: string[] = []
49
+ const fileLinks: Link[] = []
38
50
  const fileDefinitions = new Map<string, string>()
39
51
 
40
52
  visit(tree, 'definition', (node) => {
@@ -64,18 +76,17 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
64
76
  break
65
77
  }
66
78
  case 'link': {
67
- if (shouldValidateLink(node.url)) {
68
- fileLinks.push(node.url)
69
- }
79
+ const link = getLinkToValidate(node.url, config)
80
+ if (link) fileLinks.push(link)
70
81
 
71
82
  break
72
83
  }
73
84
  case 'linkReference': {
74
85
  const definition = fileDefinitions.get(node.identifier)
86
+ if (!definition) break
75
87
 
76
- if (definition && shouldValidateLink(definition)) {
77
- fileLinks.push(definition)
78
- }
88
+ const link = getLinkToValidate(definition, config)
89
+ if (link) fileLinks.push(link)
79
90
 
80
91
  break
81
92
  }
@@ -86,22 +97,27 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
86
97
  }
87
98
  }
88
99
 
89
- if (node.name !== 'a' && node.name !== 'LinkCard' && node.name !== 'LinkButton') {
100
+ if (!node.name) {
101
+ break
102
+ }
103
+
104
+ const componentProp = linkComponents[node.name]
105
+
106
+ if (node.name !== 'a' && !componentProp) {
90
107
  break
91
108
  }
92
109
 
93
110
  for (const attribute of node.attributes) {
94
111
  if (
95
112
  attribute.type !== 'mdxJsxAttribute' ||
96
- attribute.name !== 'href' ||
113
+ attribute.name !== (componentProp ?? 'href') ||
97
114
  typeof attribute.value !== 'string'
98
115
  ) {
99
116
  continue
100
117
  }
101
118
 
102
- if (shouldValidateLink(attribute.value)) {
103
- fileLinks.push(attribute.value)
104
- }
119
+ const link = getLinkToValidate(attribute.value, config)
120
+ if (link) fileLinks.push(link)
105
121
  }
106
122
 
107
123
  break
@@ -127,10 +143,10 @@ export const remarkStarlightLinksValidator: Plugin<[{ base: string; srcDir: URL
127
143
  htmlNode.type === 'element' &&
128
144
  htmlNode.tagName === 'a' &&
129
145
  hasProperty(htmlNode, 'href') &&
130
- typeof htmlNode.properties.href === 'string' &&
131
- shouldValidateLink(htmlNode.properties.href)
146
+ typeof htmlNode.properties.href === 'string'
132
147
  ) {
133
- fileLinks.push(htmlNode.properties.href)
148
+ const link = getLinkToValidate(htmlNode.properties.href, config)
149
+ if (link) fileLinks.push(link)
134
150
  }
135
151
  })
136
152
 
@@ -148,17 +164,31 @@ export function getValidationData() {
148
164
  return { headings, links }
149
165
  }
150
166
 
151
- function shouldValidateLink(link: string) {
167
+ function getLinkToValidate(link: string, { options, site }: RemarkStarlightLinksValidatorConfig): Link | undefined {
168
+ const linkTovalidate = { raw: link }
169
+
152
170
  if (!isAbsoluteUrl(link)) {
153
- return true
171
+ return linkTovalidate
154
172
  }
155
173
 
156
174
  try {
157
175
  const url = new URL(link)
158
176
 
177
+ if (options.sameSitePolicy !== 'ignore' && url.origin === site) {
178
+ if (options.sameSitePolicy === 'error') {
179
+ return { ...linkTovalidate, error: ValidationErrorType.SameSite }
180
+ } else {
181
+ let transformed = link.replace(url.origin, '')
182
+ if (!transformed) transformed = '/'
183
+ return { ...linkTovalidate, transformed }
184
+ }
185
+ }
186
+
159
187
  return url.hostname === 'localhost' || url.hostname === '127.0.0.1'
188
+ ? { ...linkTovalidate, error: ValidationErrorType.LocalLink }
189
+ : undefined
160
190
  } catch {
161
- return false
191
+ return undefined
162
192
  }
163
193
  }
164
194
 
@@ -195,8 +225,21 @@ function isMdxIdAttribute(attribute: MdxJsxAttribute | MdxJsxExpressionAttribute
195
225
  return attribute.type === 'mdxJsxAttribute' && attribute.name === 'id' && typeof attribute.value === 'string'
196
226
  }
197
227
 
228
+ export interface RemarkStarlightLinksValidatorConfig {
229
+ base: string
230
+ options: StarlightLinksValidatorOptions
231
+ site: AstroConfig['site']
232
+ srcDir: URL
233
+ }
234
+
198
235
  export type Headings = Map<string, string[]>
199
- export type Links = Map<string, string[]>
236
+ export type Links = Map<string, Link[]>
237
+
238
+ export interface Link {
239
+ error?: ValidationErrorType
240
+ raw: string
241
+ transformed?: string
242
+ }
200
243
 
201
244
  interface MdxIdAttribute {
202
245
  name: 'id'
@@ -11,7 +11,7 @@ import type { StarlightLinksValidatorOptions } from '..'
11
11
 
12
12
  import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n'
13
13
  import { ensureTrailingSlash, stripLeadingSlash, stripTrailingSlash } from './path'
14
- import { getValidationData, type Headings } from './remark'
14
+ import { getValidationData, type Headings, type Link } from './remark'
15
15
 
16
16
  export const ValidationErrorType = {
17
17
  InconsistentLocale: 'inconsistent locale',
@@ -20,6 +20,7 @@ export const ValidationErrorType = {
20
20
  InvalidLinkToCustomPage: 'invalid link to custom page',
21
21
  LocalLink: 'local link',
22
22
  RelativeLink: 'relative link',
23
+ SameSite: '{{site}} can be omitted',
23
24
  TrailingSlashMissing: 'missing trailing slash',
24
25
  TrailingSlashForbidden: 'forbidden trailing slash',
25
26
  } as const
@@ -63,7 +64,7 @@ export function validateLinks(
63
64
  pages: allPages,
64
65
  }
65
66
 
66
- if (link.startsWith('#') || link.startsWith('?')) {
67
+ if (link.raw.startsWith('#') || link.raw.startsWith('?')) {
67
68
  if (options.errorOnInvalidHashes) {
68
69
  validateSelfHash(validationContext)
69
70
  }
@@ -76,7 +77,7 @@ export function validateLinks(
76
77
  return errors
77
78
  }
78
79
 
79
- export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors) {
80
+ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors, site: AstroConfig['site']) {
80
81
  const logger = pluginLogger.fork('')
81
82
 
82
83
  if (errors.size === 0) {
@@ -103,7 +104,7 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
103
104
  for (const [index, validationError] of validationErrors.entries()) {
104
105
  logger.info(
105
106
  ` ${blue(`${index < validationErrors.length - 1 ? '├' : '└'}─`)} ${validationError.link}${dim(
106
- ` - ${validationError.type}`,
107
+ ` - ${formatValidationError(validationError, site)}`,
107
108
  )}`,
108
109
  )
109
110
  hasInvalidLinkToCustomPage = validationError.type === ValidationErrorType.InvalidLinkToCustomPage
@@ -125,15 +126,13 @@ function validateLink(context: ValidationContext) {
125
126
  return
126
127
  }
127
128
 
128
- if (/^https?:\/\//.test(link)) {
129
- if (options.errorOnLocalLinks) {
130
- addError(errors, filePath, link, ValidationErrorType.LocalLink)
131
- }
132
-
129
+ if (link.error) {
130
+ addError(errors, filePath, link, link.error)
133
131
  return
134
132
  }
135
133
 
136
- const sanitizedLink = link.replace(/^\//, '')
134
+ const linkToValidate = link.transformed ?? link.raw
135
+ const sanitizedLink = linkToValidate.replace(/^\//, '')
137
136
  const segments = sanitizedLink.split('#')
138
137
 
139
138
  const path = segments[0]
@@ -143,7 +142,7 @@ function validateLink(context: ValidationContext) {
143
142
  throw new Error('Failed to validate a link with no path.')
144
143
  }
145
144
 
146
- if (path.startsWith('.') || (!link.startsWith('/') && !link.startsWith('?'))) {
145
+ if (path.startsWith('.') || (!linkToValidate.startsWith('/') && !linkToValidate.startsWith('?'))) {
147
146
  if (options.errorOnRelativeLinks) {
148
147
  addError(errors, filePath, link, ValidationErrorType.RelativeLink)
149
148
  }
@@ -172,7 +171,7 @@ function validateLink(context: ValidationContext) {
172
171
  return
173
172
  }
174
173
 
175
- if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link, localeConfig)) {
174
+ if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link.raw, localeConfig)) {
176
175
  addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
177
176
  return
178
177
  }
@@ -195,11 +194,11 @@ function validateLink(context: ValidationContext) {
195
194
  }
196
195
  }
197
196
 
198
- function getFileHeadings(path: string, { headings, localeConfig, options }: ValidationContext) {
197
+ function getFileHeadings(path: string, { astroConfig, headings, localeConfig, options }: ValidationContext) {
199
198
  let heading = headings.get(path === '' ? '/' : path)
200
199
 
201
200
  if (!options.errorOnFallbackPages && !heading && localeConfig) {
202
- heading = getFallbackHeadings(path, headings, localeConfig)
201
+ heading = getFallbackHeadings(path, headings, localeConfig, astroConfig.base)
203
202
  }
204
203
 
205
204
  return heading
@@ -209,7 +208,7 @@ function getFileHeadings(path: string, { headings, localeConfig, options }: Vali
209
208
  * Validate a link to an hash in the same page.
210
209
  */
211
210
  function validateSelfHash({ errors, link, filePath, headings }: ValidationContext) {
212
- const hash = link.split('#')[1] ?? link
211
+ const hash = link.raw.split('#')[1] ?? link.raw
213
212
  const sanitizedHash = hash.replace(/^#/, '')
214
213
  const fileHeadings = headings.get(filePath)
215
214
 
@@ -249,17 +248,17 @@ function isValidAsset(path: string, context: ValidationContext) {
249
248
  /**
250
249
  * Check if a link is excluded from validation by the user.
251
250
  */
252
- function isExcludedLink(link: string, context: ValidationContext) {
253
- return picomatch(context.options.exclude)(link)
251
+ function isExcludedLink(link: Link, context: ValidationContext) {
252
+ return picomatch(context.options.exclude)(link.raw)
254
253
  }
255
254
 
256
255
  function stripQueryString(path: string): string {
257
256
  return path.split('?')[0] ?? path
258
257
  }
259
258
 
260
- function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
259
+ function addError(errors: ValidationErrors, filePath: string, link: Link, type: ValidationErrorType) {
261
260
  const fileErrors = errors.get(filePath) ?? []
262
- fileErrors.push({ link, type })
261
+ fileErrors.push({ link: link.raw, type })
263
262
 
264
263
  errors.set(filePath, fileErrors)
265
264
  }
@@ -268,6 +267,12 @@ function pluralize(count: number, singular: string) {
268
267
  return count === 1 ? singular : `${singular}s`
269
268
  }
270
269
 
270
+ function formatValidationError(error: ValidationError, site: AstroConfig['site']) {
271
+ if (error.type !== ValidationErrorType.SameSite || !site) return error.type
272
+
273
+ return error.type.replace('{{site}}', site)
274
+ }
275
+
271
276
  // The validation errors keyed by file path.
272
277
  type ValidationErrors = Map<string, ValidationError[]>
273
278
 
@@ -290,7 +295,7 @@ interface ValidationContext {
290
295
  errors: ValidationErrors
291
296
  filePath: string
292
297
  headings: Headings
293
- link: string
298
+ link: Link
294
299
  localeConfig: LocaleConfig | undefined
295
300
  options: StarlightLinksValidatorOptions
296
301
  outputDir: URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starlight-links-validator",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "description": "Starlight plugin to validate internal links.",
6
6
  "author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
@@ -31,7 +31,7 @@
31
31
  "vitest": "2.1.6"
32
32
  },
33
33
  "peerDependencies": {
34
- "@astrojs/starlight": ">=0.15.0"
34
+ "@astrojs/starlight": ">=0.32.0"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=18.17.1"