Skip to content

Commit

Permalink
feat: improve link validation output readability
Browse files Browse the repository at this point in the history
  • Loading branch information
HiDeoo committed Dec 13, 2023
1 parent c397c27 commit f5900a7
Show file tree
Hide file tree
Showing 10 changed files with 257 additions and 226 deletions.
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"extends": ["@hideoo"],
"ignorePatterns": ["packages/starlight-links-validator/tests/fixtures"]
"ignorePatterns": ["packages/starlight-links-validator/tests/fixtures"],
"rules": {
"@typescript-eslint/no-redeclare": "off"
}
}
4 changes: 2 additions & 2 deletions packages/starlight-links-validator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<h1>starlight-links-validator 🦺</h1>
<p>Starlight plugin to validate internal links.</p>
<p>
<a href="https://i.imgur.com/EgiTGeR.png" title="Screenshot of starlight-links-validator">
<img alt="Screenshot of starlight-links-validator" src="https://i.imgur.com/EgiTGeR.png" width="520" />
<a href="https://github.com/HiDeoo/starlight-links-validator/assets/494699/fe5f797a-8089-4271-b090-7158bb053dfa" title="Screenshot of starlight-links-validator">
<img alt="Screenshot of starlight-links-validator" src="https://github.com/HiDeoo/starlight-links-validator/assets/494699/fe5f797a-8089-4271-b090-7158bb053dfa" width="520" />
</a>
</p>
</div>
Expand Down
4 changes: 2 additions & 2 deletions packages/starlight-links-validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function starlightLinksValidatorPlugin(
return {
name: 'starlight-links-validator-plugin',
hooks: {
setup({ addIntegration, config: starlightConfig }) {
setup({ addIntegration, config: starlightConfig, logger }) {
addIntegration({
name: 'starlight-links-validator-integration',
hooks: {
Expand All @@ -66,7 +66,7 @@ export default function starlightLinksValidatorPlugin(
'astro:build:done': ({ dir, pages }) => {
const errors = validateLinks(pages, dir, starlightConfig, options.data)

logErrors(errors)
logErrors(logger, errors)

if (errors.size > 0) {
throwPluginError('Links validation failed.')
Expand Down
73 changes: 45 additions & 28 deletions packages/starlight-links-validator/libs/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ 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 { AstroIntegrationLogger } from 'astro'
import { bgGreen, black, blue, dim, green, red } from 'kleur/colors'

import type { StarlightLinksValidatorOptions } from '..'

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

export const ValidationErrorType = {
InconsistentLocale: 'inconsistent locale',
InvalidAnchor: 'invalid anchor',
InvalidLink: 'invalid link',
RelativeLink: 'relative link',
} as const

export function validateLinks(
pages: PageData[],
outputDir: URL,
Expand Down Expand Up @@ -48,36 +56,38 @@ export function validateLinks(
return errors
}

export function logErrors(errors: ValidationErrors) {
export function logErrors(pluginLogger: AstroIntegrationLogger, errors: ValidationErrors) {
const logger = pluginLogger.fork('')

if (errors.size === 0) {
process.stdout.write(dim('All internal links are valid.\n\n'))
logger.info(green('✓ All internal links are valid.\n'))
return
}

const errorCount = [...errors.values()].reduce((acc, links) => acc + links.length, 0)

process.stderr.write(
`${bold(
red(
`Found ${errorCount} invalid ${pluralize(errorCount, 'link')} in ${errors.size} ${pluralize(
errors.size,
'file',
)}.`,
),
)}\n\n`,
logger.error(
red(
`✗ Found ${errorCount} invalid ${pluralize(errorCount, 'link')} in ${errors.size} ${pluralize(
errors.size,
'file',
)}.`,
),
)

for (const [file, links] of errors) {
process.stderr.write(`${red('▶')} ${file}\n`)
for (const [file, validationErrors] of errors) {
logger.info(`${red('▶')} ${blue(file)}`)

for (const [index, link] of links.entries()) {
process.stderr.write(` ${blue(`${index < links.length - 1 ? '├' : '└'}─`)} ${link}\n`)
for (const [index, validationError] of validationErrors.entries()) {
logger.info(
` ${blue(`${index < validationErrors.length - 1 ? '├' : '└'}─`)} ${validationError.link}${dim(
` - ${validationError.type}`,
)}`,
)
}

process.stdout.write(dim('\n'))
}

process.stdout.write(dim('\n'))
process.stdout.write('\n')
}

/**
Expand All @@ -98,7 +108,7 @@ function validateLink(context: ValidationContext) {

if (path.startsWith('.')) {
if (options.errorOnRelativeLinks) {
addError(errors, filePath, link)
addError(errors, filePath, link, ValidationErrorType.RelativeLink)
}

return
Expand All @@ -114,17 +124,17 @@ function validateLink(context: ValidationContext) {
const fileHeadings = getFileHeadings(path, context)

if (!isValidPage || !fileHeadings) {
addError(errors, filePath, link)
addError(errors, filePath, link, ValidationErrorType.InvalidLink)
return
}

if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link, localeConfig)) {
addError(errors, filePath, link)
addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
return
}

if (hash && !fileHeadings.includes(hash)) {
addError(errors, filePath, link)
addError(errors, filePath, link, ValidationErrorType.InvalidAnchor)
}
}

Expand All @@ -150,7 +160,7 @@ function validateSelfAnchor({ errors, link, filePath, headings }: ValidationCont
}

if (!fileHeadings.includes(sanitizedHash)) {
addError(errors, filePath, link)
addError(errors, filePath, link, 'invalid anchor')
}
}

Expand All @@ -169,9 +179,9 @@ function isValidAsset(path: string, outputDir: URL) {
}
}

function addError(errors: ValidationErrors, filePath: string, link: string) {
function addError(errors: ValidationErrors, filePath: string, link: string, type: ValidationErrorType) {
const fileErrors = errors.get(filePath) ?? []
fileErrors.push(link)
fileErrors.push({ link, type })

errors.set(filePath, fileErrors)
}
Expand All @@ -180,8 +190,15 @@ function pluralize(count: number, singular: string) {
return count === 1 ? singular : `${singular}s`
}

// The invalid links keyed by file path.
type ValidationErrors = Map<string, string[]>
// The validation errors keyed by file path.
type ValidationErrors = Map<string, ValidationError[]>

export type ValidationErrorType = (typeof ValidationErrorType)[keyof typeof ValidationErrorType]

interface ValidationError {
link: string
type: ValidationErrorType
}

interface PageData {
pathname: string
Expand Down
84 changes: 41 additions & 43 deletions packages/starlight-links-validator/tests/basics.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { expect, test } from 'vitest'

import { loadFixture } from './utils'
import { ValidationErrorType } from '../libs/validation'

import { expectValidationErrorCount, expectValidationErrors, loadFixture } from './utils'

test('should build with no links', async () => {
await expect(loadFixture('basics-no-links')).resolves.not.toThrow()
Expand All @@ -16,47 +18,43 @@ test('should not build with invalid links', async () => {
try {
await loadFixture('basics-invalid-links')
} catch (error) {
expect(error).toMatch(/Found 25 invalid links in 4 files./)

expect(error).toMatch(
new RegExp(`▶ test/
├─ /
├─ /unknown
├─ /unknown/
├─ /unknown#title
├─ /unknown/#title
├─ #links
├─ /guides/example/#links
├─ /icon.svg
├─ /guidelines/ui.pdf
├─ /unknown-ref
├─ #unknown-ref
└─ #anotherDiv`),
)

expect(error).toMatch(
new RegExp(`▶ guides/example/
├─ #links
├─ /unknown/#links
├─ /unknown
├─ #anotherBlock
├─ /icon.svg
└─ /guidelines/ui.pdf`),
)

expect(error).toMatch(
new RegExp(`▶ guides/namespacetest/
├─ #some-other-content
└─ /guides/namespacetest/#another-content`),
)

expect(error).toMatch(
new RegExp(`▶ relative/
├─ .
├─ ./relative
├─ ./test
├─ ./guides/example
└─ ../test`),
)
expectValidationErrorCount(error, 25, 4)

expectValidationErrors(error, 'test/', [
['/', ValidationErrorType.InvalidLink],
['/unknown', ValidationErrorType.InvalidLink],
['/unknown/', ValidationErrorType.InvalidLink],
['/unknown#title', ValidationErrorType.InvalidLink],
['/unknown/#title', ValidationErrorType.InvalidLink],
['#links', ValidationErrorType.InvalidAnchor],
['/guides/example/#links', ValidationErrorType.InvalidAnchor],
['/icon.svg', ValidationErrorType.InvalidLink],
['/guidelines/ui.pdf', ValidationErrorType.InvalidLink],
['/unknown-ref', ValidationErrorType.InvalidLink],
['#unknown-ref', ValidationErrorType.InvalidAnchor],
['#anotherDiv', ValidationErrorType.InvalidAnchor],
])

expectValidationErrors(error, 'guides/example/', [
['#links', ValidationErrorType.InvalidAnchor],
['/unknown/#links', ValidationErrorType.InvalidLink],
['/unknown', ValidationErrorType.InvalidLink],
['#anotherBlock', ValidationErrorType.InvalidAnchor],
['/icon.svg', ValidationErrorType.InvalidLink],
['/guidelines/ui.pdf', ValidationErrorType.InvalidLink],
])

expectValidationErrors(error, 'guides/namespacetest/', [
['#some-other-content', ValidationErrorType.InvalidAnchor],
['/guides/namespacetest/#another-content', ValidationErrorType.InvalidAnchor],
])

expectValidationErrors(error, 'relative/', [
['.', ValidationErrorType.RelativeLink],
['./relative', ValidationErrorType.RelativeLink],
['./test', ValidationErrorType.RelativeLink],
['./guides/example', ValidationErrorType.RelativeLink],
['../test', ValidationErrorType.RelativeLink],
])
}
})
Loading

0 comments on commit f5900a7

Please sign in to comment.