starlight-links-validator 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # starlight-links-validator
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#113](https://github.com/HiDeoo/starlight-links-validator/pull/113) [`3e0a88c`](https://github.com/HiDeoo/starlight-links-validator/commit/3e0a88cd2f7f6f84c57248ae72a8e8df32c22dbe) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Prevents plugin remark plugin from running on Markdown and MDX content when using the Astro [`renderMarkdown()`](https://docs.astro.build/en/reference/content-loader-reference/#rendermarkdown) content loader API.
8
+
9
+ ## 0.17.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#108](https://github.com/HiDeoo/starlight-links-validator/pull/108) [`82f8ec5`](https://github.com/HiDeoo/starlight-links-validator/commit/82f8ec5cff97d5b9e343440666a3bb67de216b00) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds support for [excluding](https://starlight-links-validator.vercel.app/configuration#exclude) links from validation using a function.
14
+
15
+ When using the function syntax, the function should return `true` for any link that should be excluded from validation or `false` otherwise. The function will be called for each link to validate and will receive an object containing various properties to help determine whether to exclude the link or not.
16
+
17
+ Check out the [`exclude` configuration option](https://starlight-links-validator.vercel.app/configuration#exclude) documentation for more details and examples.
18
+
3
19
  ## 0.16.0
4
20
 
5
21
  ### Minor Changes
package/index.ts CHANGED
@@ -1,94 +1,19 @@
1
1
  import type { StarlightPlugin } from '@astrojs/starlight/types'
2
2
  import type { IntegrationResolvedRoute } from 'astro'
3
3
  import { AstroError } from 'astro/errors'
4
- import { z } from 'astro/zod'
5
4
 
6
5
  import { clearContentLayerCache } from './libs/astro'
6
+ import { StarlightLinksValidatorOptionsSchema, type StarlightLinksValidatorUserOptions } from './libs/config'
7
7
  import { pathnameToSlug, stripTrailingSlash } from './libs/path'
8
8
  import { remarkStarlightLinksValidator, type RemarkStarlightLinksValidatorConfig } from './libs/remark'
9
9
  import { logErrors, validateLinks } from './libs/validation'
10
10
 
11
- const starlightLinksValidatorOptionsSchema = z
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([]),
23
- /**
24
- * Defines whether the plugin should error on fallback pages.
25
- *
26
- * If you do not expect to have all pages translated in all configured locales and want to use the fallback pages
27
- * feature built-in into Starlight, you should set this option to `false`.
28
- *
29
- * @default true
30
- * @see https://starlight.astro.build/guides/i18n/#fallback-content
31
- */
32
- errorOnFallbackPages: z.boolean().default(true),
33
- /**
34
- * Defines whether the plugin should error on inconsistent locale links.
35
- *
36
- * When set to `true`, the plugin will error on links that are pointing to a page in a different locale.
37
- *
38
- * @default false
39
- */
40
- errorOnInconsistentLocale: z.boolean().default(false),
41
- /**
42
- * Defines whether the plugin should error on internal relative links.
43
- *
44
- * When set to `false`, the plugin will ignore relative links (e.g. `./foo` or `../bar`).
45
- *
46
- * @default true
47
- */
48
- errorOnRelativeLinks: z.boolean().default(true),
49
- /**
50
- * Defines whether the plugin should error on invalid hashes.
51
- *
52
- * When set to `false`, the plugin will only validate link pages and ignore hashes.
53
- *
54
- * @default true
55
- */
56
- errorOnInvalidHashes: z.boolean().default(true),
57
- /**
58
- * Defines whether the plugin should error on local links, e.g. URLs with a hostname of `localhost` or `127.0.0.1`.
59
- *
60
- * @default true
61
- */
62
- errorOnLocalLinks: z.boolean().default(true),
63
- /**
64
- * Defines a list of links or glob patterns that should be excluded from validation.
65
- *
66
- * The links in this list will be ignored by the plugin and will not be validated.
67
- *
68
- * @default []
69
- */
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'),
85
- })
86
- .default({})
11
+ export type { StarlightLinksValidatorOptions } from './libs/config'
87
12
 
88
13
  export default function starlightLinksValidatorPlugin(
89
14
  userOptions?: StarlightLinksValidatorUserOptions,
90
15
  ): StarlightPlugin {
91
- const options = starlightLinksValidatorOptionsSchema.safeParse(userOptions)
16
+ const options = StarlightLinksValidatorOptionsSchema.safeParse(userOptions)
92
17
 
93
18
  if (!options.success) {
94
19
  throwPluginError('Invalid options passed to the starlight-links-validator plugin.')
@@ -170,6 +95,3 @@ function throwPluginError(message: string, additionalHint?: string): never {
170
95
 
171
96
  throw new AstroError(message, hint)
172
97
  }
173
-
174
- type StarlightLinksValidatorUserOptions = z.input<typeof starlightLinksValidatorOptionsSchema>
175
- export type StarlightLinksValidatorOptions = z.output<typeof starlightLinksValidatorOptionsSchema>
package/libs/config.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { z } from 'astro/zod'
2
+
3
+ export const StarlightLinksValidatorOptionsSchema = z
4
+ .object({
5
+ /**
6
+ * Defines a list of additional components and their props that should be validated as links.
7
+ *
8
+ * By default, the plugin will only validate links defined in the `href` prop of the `<LinkButton>` and `<LinkCard>`
9
+ * built-in Starlight components.
10
+ * Adding custom components to this list will allow the plugin to validate links in those components as well.
11
+ *
12
+ * @default []
13
+ */
14
+ components: z.tuple([z.string(), z.string()]).array().default([]),
15
+ /**
16
+ * Defines whether the plugin should error on fallback pages.
17
+ *
18
+ * If you do not expect to have all pages translated in all configured locales and want to use the fallback pages
19
+ * feature built-in into Starlight, you should set this option to `false`.
20
+ *
21
+ * @default true
22
+ * @see https://starlight.astro.build/guides/i18n/#fallback-content
23
+ */
24
+ errorOnFallbackPages: z.boolean().default(true),
25
+ /**
26
+ * Defines whether the plugin should error on inconsistent locale links.
27
+ *
28
+ * When set to `true`, the plugin will error on links that are pointing to a page in a different locale.
29
+ *
30
+ * @default false
31
+ */
32
+ errorOnInconsistentLocale: z.boolean().default(false),
33
+ /**
34
+ * Defines whether the plugin should error on internal relative links.
35
+ *
36
+ * When set to `false`, the plugin will ignore relative links (e.g. `./foo` or `../bar`).
37
+ *
38
+ * @default true
39
+ */
40
+ errorOnRelativeLinks: z.boolean().default(true),
41
+ /**
42
+ * Defines whether the plugin should error on invalid hashes.
43
+ *
44
+ * When set to `false`, the plugin will only validate link pages and ignore hashes.
45
+ *
46
+ * @default true
47
+ */
48
+ errorOnInvalidHashes: z.boolean().default(true),
49
+ /**
50
+ * Defines whether the plugin should error on local links, e.g. URLs with a hostname of `localhost` or `127.0.0.1`.
51
+ *
52
+ * @default true
53
+ */
54
+ errorOnLocalLinks: z.boolean().default(true),
55
+ /**
56
+ * Defines a list of links or glob patterns that should be excluded from validation or a function that will be
57
+ * called for each link to determine if it should be excluded from validation or not.
58
+ *
59
+ * The links in this list or links where the function returns `true` will be ignored by the plugin and will not be
60
+ * validated.
61
+ *
62
+ * @default []
63
+ */
64
+ exclude: z
65
+ .union([
66
+ z.array(z.string()),
67
+ z
68
+ .function()
69
+ .args(
70
+ z.object({
71
+ /**
72
+ * The absolute path to the file where the link is defined.
73
+ */
74
+ file: z.string(),
75
+ /**
76
+ * The link to validate as authored in the content.
77
+ */
78
+ link: z.string(),
79
+ /**
80
+ * The slug of the page where the link is defined.
81
+ */
82
+ slug: z.string(),
83
+ }),
84
+ )
85
+ .returns(z.boolean()),
86
+ ])
87
+ .default([]),
88
+ /**
89
+ * Defines the policy for external links with an origin matching the Astro `site` option.
90
+ *
91
+ * By default, all external links are ignored and not validated by the plugin.
92
+ * Setting this option to `error` will make the plugin error on external links with an origin matching the Astro
93
+ * `site` option and hint that the link can be rewritten without the origin.
94
+ * Setting this option to `validate` will make the plugin validate external links with an origin matching the Astro
95
+ * `site` option as if they were internal links.
96
+ *
97
+ * @default 'ignore'
98
+ * @see https://docs.astro.build/en/reference/configuration-reference/#site
99
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/URL/origin
100
+ */
101
+ sameSitePolicy: z.enum(['error', 'ignore', 'validate']).default('ignore'),
102
+ })
103
+ .default({})
104
+
105
+ export type StarlightLinksValidatorUserOptions = z.input<typeof StarlightLinksValidatorOptionsSchema>
106
+ export type StarlightLinksValidatorOptions = z.output<typeof StarlightLinksValidatorOptionsSchema>
package/libs/i18n.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingSlash } from './path'
2
- import type { Headings } from './remark'
2
+ import type { ValidationData } from './remark'
3
3
  import type { StarlightUserConfig } from './validation'
4
4
 
5
5
  export function getLocaleConfig(config: StarlightUserConfig): LocaleConfig | undefined {
@@ -30,7 +30,7 @@ export function getLocaleConfig(config: StarlightUserConfig): LocaleConfig | und
30
30
 
31
31
  export function getFallbackHeadings(
32
32
  path: string,
33
- headings: Headings,
33
+ validationData: ValidationData,
34
34
  localeConfig: LocaleConfig | undefined,
35
35
  base: string,
36
36
  ): string[] | undefined {
@@ -48,7 +48,7 @@ export function getFallbackHeadings(
48
48
  (localeConfig.defaultLocale === '' ? localeConfig.defaultLocale : `${localeConfig.defaultLocale}/`),
49
49
  )
50
50
 
51
- return headings.get(fallbackPath === '' ? '/' : fallbackPath)
51
+ return validationData.get(fallbackPath === '' ? '/' : fallbackPath)?.headings
52
52
  }
53
53
  }
54
54
 
package/libs/remark.ts CHANGED
@@ -25,10 +25,8 @@ const builtInComponents: StarlightLinksValidatorOptions['components'] = [
25
25
  ['LinkCard', 'href'],
26
26
  ]
27
27
 
28
- // All the headings keyed by file path.
29
- const headings: Headings = new Map()
30
- // All the internal links keyed by file path.
31
- const links: Links = new Map()
28
+ // All the validation data keyed by file path.
29
+ const data: ValidationData = new Map()
32
30
 
33
31
  export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidatorConfig], Root> = function (config) {
34
32
  const { base, options, srcDir } = config
@@ -38,10 +36,16 @@ export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidato
38
36
  )
39
37
 
40
38
  return (tree, file) => {
39
+ // If the content does not have a path, e.g. when rendered using the content loader `renderMarkdown()` API, skip it.
40
+ if (!file.path) return
41
+
41
42
  if (file.data.astro?.frontmatter?.['draft']) return
42
43
 
44
+ const originalPath = file.history[0]
45
+ if (!originalPath) throw new Error('Missing file path to validate links.')
46
+
43
47
  const slugger = new GitHubSlugger()
44
- const filePath = normalizeFilePath(base, srcDir, file.history[0])
48
+ const filePath = normalizeFilePath(base, srcDir, originalPath)
45
49
  const slug: string | undefined =
46
50
  typeof file.data.astro?.frontmatter?.['slug'] === 'string' ? file.data.astro.frontmatter['slug'] : undefined
47
51
 
@@ -155,13 +159,16 @@ export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidato
155
159
  }
156
160
  })
157
161
 
158
- headings.set(getFilePath(base, filePath, slug), fileHeadings)
159
- links.set(getFilePath(base, filePath, slug), fileLinks)
162
+ data.set(getFilePath(base, filePath, slug), {
163
+ file: originalPath,
164
+ headings: fileHeadings,
165
+ links: fileLinks,
166
+ })
160
167
  }
161
168
  }
162
169
 
163
- export function getValidationData() {
164
- return { headings, links }
170
+ export function getValidationData(): ValidationData {
171
+ return data
165
172
  }
166
173
 
167
174
  function getLinkToValidate(link: string, { options, site }: RemarkStarlightLinksValidatorConfig): Link | undefined {
@@ -202,11 +209,7 @@ function getFilePath(base: string, filePath: string, slug: string | undefined) {
202
209
  return filePath
203
210
  }
204
211
 
205
- function normalizeFilePath(base: string, srcDir: URL, filePath?: string) {
206
- if (!filePath) {
207
- throw new Error('Missing file path to validate links.')
208
- }
209
-
212
+ function normalizeFilePath(base: string, srcDir: URL, filePath: string) {
210
213
  const path = nodePath
211
214
  .relative(nodePath.join(fileURLToPath(srcDir), 'content/docs'), filePath)
212
215
  .replace(/\.\w+$/, '')
@@ -234,8 +237,17 @@ export interface RemarkStarlightLinksValidatorConfig {
234
237
  srcDir: URL
235
238
  }
236
239
 
237
- export type Headings = Map<string, string[]>
238
- export type Links = Map<string, Link[]>
240
+ export type ValidationData = Map<
241
+ string,
242
+ {
243
+ // The absolute path to the file.
244
+ file: string
245
+ // All the headings.
246
+ headings: string[]
247
+ // All the internal links.
248
+ links: Link[]
249
+ }
250
+ >
239
251
 
240
252
  export interface Link {
241
253
  error?: ValidationErrorType
@@ -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, type Link } from './remark'
14
+ import { getValidationData, type Link, type ValidationData } from './remark'
15
15
 
16
16
  export const ValidationErrorType = {
17
17
  InconsistentLocale: 'inconsistent locale',
@@ -36,7 +36,7 @@ export function validateLinks(
36
36
  process.stdout.write(`\n${bgGreen(black(` validating links `))}\n`)
37
37
 
38
38
  const localeConfig = getLocaleConfig(starlightConfig)
39
- const { headings, links } = getValidationData()
39
+ const validationData = getValidationData()
40
40
  const allPages: Pages = new Set(
41
41
  pages.map((page) =>
42
42
  ensureTrailingSlash(
@@ -49,19 +49,19 @@ export function validateLinks(
49
49
 
50
50
  const errors: ValidationErrors = new Map()
51
51
 
52
- for (const [filePath, fileLinks] of links) {
52
+ for (const [filePath, { links: fileLinks }] of validationData) {
53
53
  for (const link of fileLinks) {
54
54
  const validationContext: ValidationContext = {
55
55
  astroConfig,
56
56
  customPages,
57
57
  errors,
58
58
  filePath,
59
- headings,
60
59
  link,
61
60
  localeConfig,
62
61
  options,
63
62
  outputDir,
64
63
  pages: allPages,
64
+ validationData,
65
65
  }
66
66
 
67
67
  if (link.raw.startsWith('#') || link.raw.startsWith('?')) {
@@ -194,23 +194,29 @@ function validateLink(context: ValidationContext) {
194
194
  }
195
195
  }
196
196
 
197
- function getFileHeadings(path: string, { astroConfig, headings, localeConfig, options }: ValidationContext) {
198
- let heading = headings.get(path === '' ? '/' : path)
197
+ function getFileHeadings(path: string, { astroConfig, localeConfig, options, validationData }: ValidationContext) {
198
+ let headings = validationData.get(path === '' ? '/' : path)?.headings
199
199
 
200
- if (!options.errorOnFallbackPages && !heading && localeConfig) {
201
- heading = getFallbackHeadings(path, headings, localeConfig, astroConfig.base)
200
+ if (!options.errorOnFallbackPages && !headings && localeConfig) {
201
+ headings = getFallbackHeadings(path, validationData, localeConfig, astroConfig.base)
202
202
  }
203
203
 
204
- return heading
204
+ return headings
205
205
  }
206
206
 
207
207
  /**
208
208
  * Validate a link to an hash in the same page.
209
209
  */
210
- function validateSelfHash({ errors, link, filePath, headings }: ValidationContext) {
210
+ function validateSelfHash(context: ValidationContext) {
211
+ const { errors, link, filePath, validationData } = context
212
+
213
+ if (isExcludedLink(link, context)) {
214
+ return
215
+ }
216
+
211
217
  const hash = link.raw.split('#')[1] ?? link.raw
212
218
  const sanitizedHash = hash.replace(/^#/, '')
213
- const fileHeadings = headings.get(filePath)
219
+ const fileHeadings = validationData.get(filePath)?.headings
214
220
 
215
221
  if (!fileHeadings) {
216
222
  throw new Error(`Failed to find headings for the file at '${filePath}'.`)
@@ -248,8 +254,17 @@ function isValidAsset(path: string, context: ValidationContext) {
248
254
  /**
249
255
  * Check if a link is excluded from validation by the user.
250
256
  */
251
- function isExcludedLink(link: Link, context: ValidationContext) {
252
- return picomatch(context.options.exclude)(stripQueryString(link.raw))
257
+ function isExcludedLink(link: Link, { filePath, options, validationData }: ValidationContext) {
258
+ if (Array.isArray(options.exclude)) return picomatch(options.exclude)(stripQueryString(link.raw))
259
+
260
+ const file = validationData.get(filePath)?.file
261
+ if (!file) throw new Error('Missing file path to check exclusion.')
262
+
263
+ return options.exclude({
264
+ file,
265
+ link: link.raw,
266
+ slug: stripTrailingSlash(filePath),
267
+ })
253
268
  }
254
269
 
255
270
  function stripQueryString(path: string): string {
@@ -294,12 +309,12 @@ interface ValidationContext {
294
309
  customPages: Set<string>
295
310
  errors: ValidationErrors
296
311
  filePath: string
297
- headings: Headings
298
312
  link: Link
299
313
  localeConfig: LocaleConfig | undefined
300
314
  options: StarlightLinksValidatorOptions
301
315
  outputDir: URL
302
316
  pages: Pages
317
+ validationData: ValidationData
303
318
  }
304
319
 
305
320
  export type StarlightUserConfig = Omit<StarlightUserConfigWithPlugins, 'plugins'>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starlight-links-validator",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "description": "Starlight plugin to validate internal links.",
6
6
  "author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
@@ -26,6 +26,8 @@
26
26
  "@types/mdast": "^4.0.4",
27
27
  "@types/node": "^18.19.68",
28
28
  "remark-custom-heading-id": "^2.0.0",
29
+ "remark-parse": "^11.0.0",
30
+ "remark-stringify": "^11.0.0",
29
31
  "unified": "^11.0.5",
30
32
  "vfile": "^6.0.3",
31
33
  "vitest": "2.1.6"