UNPKG

starlight-links-validator

Version:
427 lines (357 loc) 12.1 kB
import { statSync } from 'node:fs' import { posix, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { StarlightUserConfig as StarlightUserConfigWithPlugins } from '@astrojs/starlight/types' import type { AstroConfig } from 'astro' import picomatch from 'picomatch' import type { StarlightLinksValidatorOptions } from '..' import type { ValidationReport, ValidationReportIssue } from '../reporters' import { getFallbackHeadings, getLocaleConfig, isInconsistentLocaleLink, type LocaleConfig } from './i18n' import type { Link } from './link' import { ensureTrailingSlash, normalizePathname, stripLeadingSlash, stripTrailingSlash } from './path' import { getErrorPosition, isSameLineSourcePosition, type Reference } from './position' import { getValidationData, type ValidationData } from './store' const documentationUrl = 'https://starlight-links-validator.vercel.app/' const validationErrorDefinitions = { InconsistentLocale: { message: 'inconsistent locale', slug: 'inconsistent-locale', }, InvalidHash: { message: 'invalid hash', slug: 'invalid-hash', }, InvalidLink: { message: 'invalid link', slug: 'invalid-link', }, InvalidLinkToCustomPage: { message: 'invalid link to custom page', slug: 'invalid-link-to-custom-page', }, LocalLink: { message: 'local link', slug: 'local-link', }, RelativeLink: { message: 'relative link', slug: 'relative-link', }, SameSite: { message: ({ site }) => `${site} can be omitted`, slug: 'same-site', }, TrailingSlashMissing: { message: 'missing trailing slash', slug: 'missing-trailing-slash', }, TrailingSlashForbidden: { message: 'forbidden trailing slash', slug: 'forbidden-trailing-slash', }, } as const satisfies Record< string, { slug: string; message: string | ((context: ValidationErrorMessageContext) => string) } > export const ValidationErrorType = Object.freeze( Object.fromEntries(Object.keys(validationErrorDefinitions).map((type) => [type, type])) as { [Key in ValidationErrorType]: Key }, ) export async function validateLinks( pages: PageData[], projectRoutes: ProjectRoutes, outputDir: URL, astroConfig: AstroConfig, starlightConfig: StarlightUserConfig, options: StarlightLinksValidatorOptions, ): Promise<ValidationReport> { const localeConfig = getLocaleConfig(starlightConfig) const validationData = getValidationData() const allPages: Pages = new Set(pages.map((page) => normalizePathname(page.pathname, astroConfig.base))) const issues: ValidationContext['issues'] = new Map() for (const [id, { links: fileLinks, file }] of validationData) { for (const link of fileLinks) { const validationContext: ValidationContext = { astroConfig, file, id, issues, link, localeConfig, options, outputDir, pages: allPages, projectRoutes, validationData, } if (link.raw.startsWith('#') || link.raw.startsWith('?')) { if (options.errorOnInvalidHashes) { validateSelfHash(validationContext) } } else { validateLink(validationContext) } } } const validationReportFiles = await Promise.all( [...issues.values()].map((file) => buildValidationReportFile(file, astroConfig)), ) const files: ValidationReport['files'] = [] let errorCount = 0 let hasInvalidLinkToCustomPage = false for (const validationReportFile of validationReportFiles) { files.push(validationReportFile.file) errorCount += validationReportFile.errorCount hasInvalidLinkToCustomPage ||= validationReportFile.hasInvalidLinkToCustomPage } return { errorCount, files, hasErrors: files.length > 0, hasInvalidLinkToCustomPage, } } export function getValidationErrorMessage(type: ValidationErrorType, context: ValidationErrorMessageContext) { const { message } = validationErrorDefinitions[type] return typeof message === 'function' ? message(context) : message } export function getValidationErrorDocumentationUrl(type: ValidationErrorType) { return new URL(`errors/${validationErrorDefinitions[type].slug}/`, documentationUrl).href } /** * Validate a link to another internal page that may or may not have a hash. */ function validateLink(context: ValidationContext) { const { astroConfig, id, link, localeConfig, options, pages, projectRoutes } = context if (isExcludedLink(link, context)) { return } if (link.error) { addIssue(context, link.error) return } const linkToValidate = link.transformed ?? link.raw const sanitizedLink = linkToValidate.replace(/^\//, '') const segments = sanitizedLink.split('#') let path = segments[0] const hash = segments[1] if (path === undefined) { throw new Error('Failed to validate a link with no path.') } path = stripQueryString(path) if (path.startsWith('.') || (!linkToValidate.startsWith('/') && !linkToValidate.startsWith('?'))) { if (options.errorOnRelativeLinks) { addIssue(context, ValidationErrorType.RelativeLink) } return } if (isValidAsset(path, context)) { return } const sanitizedPath = ensureTrailingSlash(stripQueryString(path)) const isValidPage = pages.has(sanitizedPath) let fileHeadings = getFileHeadings(sanitizedPath, context) if (!isValidPage || !fileHeadings) { const projectRoute = projectRoutes.get(stripTrailingSlash(sanitizedPath)) if (projectRoute?.type === 'redirect-external') { fileHeadings = undefined } else if (projectRoute?.type === 'redirect-internal') { fileHeadings = getFileHeadings(projectRoute.path, context) if (!fileHeadings) { const destination = projectRoutes.get(stripTrailingSlash(projectRoute.path)) addIssue( context, destination?.type === 'custom-page' ? ValidationErrorType.InvalidLinkToCustomPage : ValidationErrorType.InvalidLink, ) return } } else { addIssue( context, projectRoute?.type === 'custom-page' ? ValidationErrorType.InvalidLinkToCustomPage : ValidationErrorType.InvalidLink, ) return } } if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(id, link.raw, localeConfig)) { addIssue(context, ValidationErrorType.InconsistentLocale) return } if (hash && fileHeadings && !fileHeadings.includes(hash)) { if (options.errorOnInvalidHashes) { addIssue(context, ValidationErrorType.InvalidHash) } return } if (path.length > 0) { if (astroConfig.trailingSlash === 'always' && !path.endsWith('/')) { addIssue(context, ValidationErrorType.TrailingSlashMissing) return } else if (astroConfig.trailingSlash === 'never' && path.endsWith('/')) { addIssue(context, ValidationErrorType.TrailingSlashForbidden) return } } } function getFileHeadings(path: string, { astroConfig, localeConfig, options, validationData }: ValidationContext) { let headings = validationData.get(path === '' ? '/' : path)?.headings if (!options.errorOnFallbackPages && !headings && localeConfig) { headings = getFallbackHeadings(path, validationData, localeConfig, astroConfig.base) } return headings } /** * Validate a link to an hash in the same page. */ function validateSelfHash(context: ValidationContext) { const { link, id, validationData } = context if (isExcludedLink(link, context)) { return } const hash = link.raw.split('#')[1] ?? link.raw const sanitizedHash = hash.replace(/^#/, '') const fileHeadings = validationData.get(id)?.headings if (!fileHeadings) { throw new Error(`Failed to find headings for the file at '${id}'.`) } if (!fileHeadings.includes(sanitizedHash)) { addIssue(context, ValidationErrorType.InvalidHash) } } /** * Check if a link is a valid asset in the build output directory. */ function isValidAsset(path: string, context: ValidationContext) { if (context.astroConfig.base !== '/') { const base = stripLeadingSlash(context.astroConfig.base) if (path.startsWith(base)) { path = path.replace(new RegExp(`^${stripLeadingSlash(base)}/?`), '') } else { return false } } try { const filePath = fileURLToPath(new URL(path, context.outputDir)) const stats = statSync(filePath) return stats.isFile() } catch { return false } } /** * Check if a link is excluded from validation by the user. */ function isExcludedLink(link: Link, { id, options, validationData }: ValidationContext) { if (Array.isArray(options.exclude)) return picomatch(options.exclude)(stripQueryString(link.raw)) const file = validationData.get(id)?.file if (!file) throw new Error('Missing file path to check exclusion.') return options.exclude({ file, link: link.raw, slug: stripTrailingSlash(id), }) } function stripQueryString(path: string): string { return path.split('?')[0] ?? path } function getDocsPath(filePath: string, srcDir: AstroConfig['srcDir']) { return relative(fileURLToPath(srcDir), filePath).split(sep).join(posix.sep).replace('content/docs/', '') } async function buildValidationReportFile( fileValidationIssues: ValidationFileIssues, astroConfig: AstroConfig, ): Promise<{ errorCount: number; file: ValidationReport['files'][number]; hasInvalidLinkToCustomPage: boolean }> { const issuesWithPositions = await Promise.all( fileValidationIssues.issues.map(async (issue) => ({ issue, position: await getErrorPosition(issue.reference, fileValidationIssues.filePath), })), ) const groupedIssues: ValidationReportIssue[] = [] let errorCount = 0 let hasInvalidLinkToCustomPage = false for (const { issue, position } of issuesWithPositions) { errorCount += 1 hasInvalidLinkToCustomPage ||= issue.type === ValidationErrorType.InvalidLinkToCustomPage const previousIssue = groupedIssues.at(-1) if ( previousIssue && previousIssue.link === issue.link && previousIssue.type === issue.type && isSameLineSourcePosition(previousIssue.positions[0], position) ) { previousIssue.positions.push(position) } else { groupedIssues.push({ documentationUrl: getValidationErrorDocumentationUrl(issue.type), link: issue.link, message: getValidationErrorMessage(issue.type, { site: astroConfig.site }), positions: [position], type: issue.type, }) } } return { errorCount, file: { docsPath: getDocsPath(fileValidationIssues.filePath, astroConfig.srcDir), filePath: fileValidationIssues.filePath, issues: groupedIssues, }, hasInvalidLinkToCustomPage, } } function addIssue({ file, id, issues, link }: ValidationContext, type: ValidationErrorType) { const reportFile: ValidationFileIssues = issues.get(id) ?? { filePath: file, issues: [] } reportFile.issues.push({ link: link.raw, reference: link.reference, type }) issues.set(id, reportFile) } export type ValidationErrorType = keyof typeof validationErrorDefinitions interface ValidationIssue { link: string reference: Reference type: ValidationErrorType } interface ValidationFileIssues { filePath: string issues: ValidationIssue[] } interface PageData { pathname: string } type Pages = Set<PageData['pathname']> export type ProjectRoutes = Map< string, | { type: 'custom-page' } | { type: 'redirect-external' } | { type: 'redirect-internal' path: string } > interface ValidationContext { astroConfig: AstroConfig id: string file: string issues: Map<string, ValidationFileIssues> link: Link localeConfig: LocaleConfig | undefined options: StarlightLinksValidatorOptions outputDir: URL pages: Pages projectRoutes: ProjectRoutes validationData: ValidationData } export type StarlightUserConfig = Omit<StarlightUserConfigWithPlugins, 'plugins'> interface ValidationErrorMessageContext { site: AstroConfig['site'] }