Skip to content

Commit

Permalink
feat: add a new option to not error on fallback page links
Browse files Browse the repository at this point in the history
  • Loading branch information
HiDeoo committed Dec 12, 2023
1 parent 1d8ff99 commit cfc3b17
Show file tree
Hide file tree
Showing 51 changed files with 641 additions and 32 deletions.
5 changes: 4 additions & 1 deletion docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ export default defineConfig({
editLink: {
baseUrl: 'https://github.com/HiDeoo/starlight-links-validator/edit/main/docs/',
},
sidebar: [{ label: 'Getting Started', link: '/guides/getting-started/' }],
sidebar: [
{ label: 'Getting Started', link: '/getting-started/' },
{ label: 'Configuration', link: '/configuration/' },
],
social: {
github: 'https://github.com/HiDeoo/starlight-links-validator',
},
Expand Down
53 changes: 53 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
title: Configuration
---

The Starlight Links Validator plugin can be configured inside the `astro.config.mjs` configuration file of your project:

```js {11}
// astro.config.mjs
import starlight from '@astrojs/starlight'
import { defineConfig } from 'astro/config'
import starlightLinksValidator from 'starlight-links-validator'

export default defineConfig({
integrations: [
starlight({
plugins: [
starlightLinksValidator({
// Configuration options go here.
}),
],
title: 'My Docs',
}),
],
})
```

## Configuration options

You can pass the following options to the Starlight Links Validator plugin.

### `errorOnFallbackPages`

**Type:** `boolean`
**Default:** `true`

Starlight provides [fallback content](https://starlight.astro.build/guides/i18n/#fallback-content) in the default language for all pages that are not available in the current language.

By default, the Starlight Links Validator plugin will error if a link points to a fallback page.
If you do not expect to have all pages translated in all configured locales and want to use the fallback pages feature built-in into Starlight, you should set this option to `false`.

```js {6}
export default defineConfig({
integrations: [
starlight({
plugins: [
starlightLinksValidator({
errorOnFallbackPages: false,
}),
],
}),
],
})
```
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ export default defineConfig({
],
})
```

Running a production build will now validate all internal links in your Markdown and MDX files.
The Starlight Links Validator plugin behavior can be tweaked using various [configuration options](/configuration).
4 changes: 2 additions & 2 deletions docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ hero:
html: '🦺'
actions:
- text: Getting Started
link: /guides/getting-started/
link: /getting-started/
icon: rocket
variant: primary
---
Expand All @@ -27,6 +27,6 @@ import { Card, CardGrid } from '@astrojs/starlight/components'
Edit your config in `astro.config.mjs`.
</Card>
<Card title="Read the docs" icon="open-book">
Learn more in [the Starlight Links Validator Docs](/guides/getting-started/).
Learn more in [the Starlight Links Validator Docs](/getting-started/).
</Card>
</CardGrid>
1 change: 1 addition & 0 deletions packages/starlight-links-validator/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.astro
.github/blocks
.next
.tests
.vercel
.vscode-test
.vscode-test-web
Expand Down
45 changes: 38 additions & 7 deletions packages/starlight-links-validator/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
import type { StarlightPlugin } from '@astrojs/starlight/types'
import { AstroError } from 'astro/errors'
import { z } from 'astro/zod'

import { remarkStarlightLinksValidator } from './libs/remark'
import { logErrors, validateLinks } from './libs/validation'

export default function starlightLinksValidatorPlugin(): StarlightPlugin {
const starlightLinksValidatorOptionsSchema = z
.object({
/**
* Defines whether the plugin should error on fallback pages.
*
* If you do not expect to have all pages translated in all configured locales and want to use the fallback pages
* feature built-in into Starlight, you should set this option to `false`.
*
* @default true
* @see https://starlight.astro.build/guides/i18n/#fallback-content
*/
errorOnFallbackPages: z.boolean().default(true),
})
.default({})

export default function starlightLinksValidatorPlugin(
userOptions?: StarlightLinksValidatorUserOptions,
): StarlightPlugin {
const options = starlightLinksValidatorOptionsSchema.safeParse(userOptions)

if (!options.success) {
throwPluginError('Invalid options passed to the starlight-links-validator plugin.')
}

return {
name: 'starlight-links-validator-plugin',
hooks: {
setup({ addIntegration }) {
setup({ addIntegration, config: starlightConfig }) {
addIntegration({
name: 'starlight-links-validator-integration',
hooks: {
Expand All @@ -24,15 +48,12 @@ export default function starlightLinksValidatorPlugin(): StarlightPlugin {
})
},
'astro:build:done': ({ dir, pages }) => {
const errors = validateLinks(pages, dir)
const errors = validateLinks(pages, dir, starlightConfig, options.data)

logErrors(errors)

if (errors.size > 0) {
throw new AstroError(
'Links validation failed.',
`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.`,
)
throwPluginError('Links validation failed.')
}
},
},
Expand All @@ -41,3 +62,13 @@ export default function starlightLinksValidatorPlugin(): StarlightPlugin {
},
}
}

function throwPluginError(message: string): never {
throw new AstroError(
message,
`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.`,
)
}

type StarlightLinksValidatorUserOptions = z.input<typeof starlightLinksValidatorOptionsSchema>
export type StarlightLinksValidatorOptions = z.output<typeof starlightLinksValidatorOptionsSchema>
56 changes: 56 additions & 0 deletions packages/starlight-links-validator/libs/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Headings } from './remark'
import type { StarlightUserConfig } from './validation'

export function getLocaleConfig(config: StarlightUserConfig): LocaleConfig {
if (!config.locales || Object.keys(config.locales).length === 0) return

let defaultLocale = config.defaultLocale
const locales: string[] = []

for (const [dir, locale] of Object.entries(config.locales)) {
if (!locale) continue

if (dir === 'root') {
if (!locale.lang) continue

defaultLocale = ''
}

locales.push(dir)
}

if (defaultLocale === undefined) return

return {
defaultLocale,
locales,
}
}

export function getFallbackHeadings(
path: string,
headings: Headings,
localeConfig: LocaleConfig,
): string[] | undefined {
if (!localeConfig) return

for (const locale of localeConfig.locales) {
if (path.startsWith(`${locale}/`)) {
const fallbackPath = path.replace(
new RegExp(`^${locale}/`),
localeConfig.defaultLocale === '' ? localeConfig.defaultLocale : `${localeConfig.defaultLocale}/`,
)

return headings.get(fallbackPath === '' ? '/' : fallbackPath)
}
}

return
}

export type LocaleConfig =
| {
defaultLocale: string
locales: string[]
}
| undefined
69 changes: 54 additions & 15 deletions packages/starlight-links-validator/libs/validation.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
import { statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

import type { StarlightPlugin } from '@astrojs/starlight/types'
import { bgGreen, black, blue, bold, dim, red } from 'kleur/colors'

import type { StarlightLinksValidatorOptions } from '..'

import { getFallbackHeadings, getLocaleConfig, type LocaleConfig } from './i18n'
import { getValidationData, type Headings } from './remark'

export function validateLinks(pages: PageData[], outputDir: URL): ValidationErrors {
export function validateLinks(
pages: PageData[],
outputDir: URL,
starlightConfig: StarlightUserConfig,
options: StarlightLinksValidatorOptions,
): ValidationErrors {
process.stdout.write(`\n${bgGreen(black(` validating links `))}\n`)

const localeConfig = getLocaleConfig(starlightConfig)
const { headings, links } = getValidationData()
const allPages: Pages = new Set(pages.map((page) => page.pathname))

const errors: ValidationErrors = new Map()

for (const [filePath, fileLinks] of links) {
for (const link of fileLinks) {
const validationContext: ValidationContext = {
errors,
filePath,
headings,
link,
localeConfig,
options,
outputDir,
pages: allPages,
}

if (link.startsWith('#')) {
validateSelfAnchor(errors, link, filePath, headings)
validateSelfAnchor(validationContext)
} else {
validateLink(errors, link, filePath, headings, allPages, outputDir)
validateLink(validationContext)
}
}
}
Expand Down Expand Up @@ -61,14 +82,9 @@ export function logErrors(errors: ValidationErrors) {
/**
* Validate a link to another internal page that may or may not have a hash.
*/
function validateLink(
errors: ValidationErrors,
link: string,
filePath: string,
headings: Headings,
pages: Pages,
outputDir: URL,
) {
function validateLink(context: ValidationContext) {
const { errors, filePath, link, outputDir, pages } = context

const sanitizedLink = link.replace(/^\//, '')
const segments = sanitizedLink.split('#')

Expand All @@ -88,7 +104,7 @@ function validateLink(
}

const isValidPage = pages.has(path)
const fileHeadings = headings.get(path === '' ? '/' : path)
const fileHeadings = getFileHeadings(path, context)

if (!isValidPage || !fileHeadings) {
addError(errors, filePath, link)
Expand All @@ -100,19 +116,29 @@ function validateLink(
}
}

function getFileHeadings(path: string, { headings, localeConfig, options }: ValidationContext) {
let heading = headings.get(path === '' ? '/' : path)

if (!options.errorOnFallbackPages && !heading && localeConfig) {
heading = getFallbackHeadings(path, headings, localeConfig)
}

return heading
}

/**
* Validate a link to an anchor in the same page.
*/
function validateSelfAnchor(errors: ValidationErrors, hash: string, filePath: string, headings: Headings) {
const sanitizedHash = hash.replace(/^#/, '')
function validateSelfAnchor({ errors, link, filePath, headings }: ValidationContext) {
const sanitizedHash = link.replace(/^#/, '')
const fileHeadings = headings.get(filePath)

if (!fileHeadings) {
throw new Error(`Failed to find headings for the file at '${filePath}'.`)
}

if (!fileHeadings.includes(sanitizedHash)) {
addError(errors, filePath, hash)
addError(errors, filePath, link)
}
}

Expand Down Expand Up @@ -150,3 +176,16 @@ interface PageData {
}

type Pages = Set<PageData['pathname']>

interface ValidationContext {
errors: ValidationErrors
filePath: string
headings: Headings
link: string
localeConfig: LocaleConfig
options: StarlightLinksValidatorOptions
outputDir: URL
pages: Pages
}

export type StarlightUserConfig = Parameters<StarlightPlugin['hooks']['setup']>['0']['config']
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ import { expect, test } from 'vitest'
import { loadFixture } from './utils'

test('should build with no links', async () => {
await expect(loadFixture('no-links')).resolves.not.toThrow()
await expect(loadFixture('basics-no-links')).resolves.not.toThrow()
})

test('should build with valid links', async () => {
await expect(loadFixture('with-valid-links')).resolves.not.toThrow()
await expect(loadFixture('basics-valid-links')).resolves.not.toThrow()
})

test('should not build with invalid links', async () => {
expect.assertions(4)

try {
await loadFixture('with-invalid-links')
await loadFixture('basics-invalid-links')
} catch (error) {
expect(error).toMatch(/Found 20 invalid links in 3 files./)

Expand Down
Loading

0 comments on commit cfc3b17

Please sign in to comment.