Skip to content

Commit efef54a

Browse files
authored
feat: hyperlinks (#118)
1 parent 99831fd commit efef54a

File tree

5 files changed

+92
-36
lines changed

5 files changed

+92
-36
lines changed

.changeset/short-cows-own.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'starlight-links-validator': minor
3+
---
4+
5+
Adds [hyperlinks (OSC 8)](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) support to validation terminal output.
6+
7+
In [supported terminals](https://github.com/Alhadis/OSC8-Adoption/), error slugs can be conveniently used (e.g. with `Ctrl+Click`, `Opt+Click`, `Cmd+Click`, or a context menu) to open the corresponding file using the default associated application.

packages/starlight-links-validator/libs/remark.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidato
4141

4242
if (file.data.astro?.frontmatter?.['draft']) return
4343

44-
const originalPath = file.history[0]
45-
if (!originalPath) throw new Error('Missing file path to validate links.')
44+
const path = file.history[0]
45+
if (!path) throw new Error('Missing file path to validate links.')
4646

4747
const slugger = new GitHubSlugger()
48-
const filePath = normalizeFilePath(base, srcDir, originalPath)
48+
const id = normalizeId(base, srcDir, path)
4949
const slug: string | undefined =
5050
typeof file.data.astro?.frontmatter?.['slug'] === 'string' ? file.data.astro.frontmatter['slug'] : undefined
5151

@@ -159,8 +159,8 @@ export const remarkStarlightLinksValidator: Plugin<[RemarkStarlightLinksValidato
159159
}
160160
})
161161

162-
data.set(getFilePath(base, filePath, slug), {
163-
file: originalPath,
162+
data.set(getValidationDataId(base, id, slug), {
163+
file: path,
164164
headings: fileHeadings,
165165
links: fileLinks,
166166
})
@@ -201,15 +201,15 @@ function getLinkToValidate(link: string, { options, site }: RemarkStarlightLinks
201201
}
202202
}
203203

204-
function getFilePath(base: string, filePath: string, slug: string | undefined) {
204+
function getValidationDataId(base: string, id: string, slug: string | undefined) {
205205
if (slug) {
206206
return nodePath.posix.join(stripLeadingSlash(base), stripLeadingSlash(ensureTrailingSlash(slug)))
207207
}
208208

209-
return filePath
209+
return id
210210
}
211211

212-
function normalizeFilePath(base: string, srcDir: URL, filePath: string) {
212+
function normalizeId(base: string, srcDir: URL, filePath: string) {
213213
const path = nodePath
214214
.relative(nodePath.join(fileURLToPath(srcDir), 'content/docs'), filePath)
215215
.replace(/\.\w+$/, '')

packages/starlight-links-validator/libs/validation.ts

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { statSync } from 'node:fs'
22
import { posix } from 'node:path'
3-
import { fileURLToPath } from 'node:url'
3+
import { fileURLToPath, pathToFileURL } from 'node:url'
44

55
import type { StarlightUserConfig as StarlightUserConfigWithPlugins } from '@astrojs/starlight/types'
66
import type { AstroConfig, AstroIntegrationLogger } from 'astro'
77
import { bgGreen, black, blue, dim, green, red } from 'kleur/colors'
88
import picomatch from 'picomatch'
9+
import terminalLink from 'terminal-link'
910

1011
import type { StarlightLinksValidatorOptions } from '..'
1112

@@ -49,13 +50,14 @@ export function validateLinks(
4950

5051
const errors: ValidationErrors = new Map()
5152

52-
for (const [filePath, { links: fileLinks }] of validationData) {
53+
for (const [id, { links: fileLinks, file }] of validationData) {
5354
for (const link of fileLinks) {
5455
const validationContext: ValidationContext = {
5556
astroConfig,
5657
customPages,
5758
errors,
58-
filePath,
59+
file,
60+
id,
5961
link,
6062
localeConfig,
6163
options,
@@ -85,7 +87,10 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
8587
return
8688
}
8789

88-
const errorCount = [...errors.values()].reduce((acc, links) => acc + links.length, 0)
90+
const errorCount = [...errors.values()].reduce(
91+
(acc, { errors: validationErrors }) => acc + validationErrors.length,
92+
0,
93+
)
8994

9095
logger.error(
9196
red(
@@ -98,8 +103,8 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
98103

99104
let hasInvalidLinkToCustomPage = false
100105

101-
for (const [file, validationErrors] of errors) {
102-
logger.info(`${red('▶')} ${blue(file)}`)
106+
for (const [id, { errors: validationErrors, file }] of errors) {
107+
logger.info(`${red('▶')} ${blue(terminalLink(id, pathToFileURL(file).toString(), { fallback: false }))}`)
103108

104109
for (const [index, validationError] of validationErrors.entries()) {
105110
logger.info(
@@ -120,14 +125,14 @@ export function logErrors(pluginLogger: AstroIntegrationLogger, errors: Validati
120125
* Validate a link to another internal page that may or may not have a hash.
121126
*/
122127
function validateLink(context: ValidationContext) {
123-
const { astroConfig, customPages, errors, filePath, link, localeConfig, options, pages } = context
128+
const { astroConfig, customPages, errors, id, file, link, localeConfig, options, pages } = context
124129

125130
if (isExcludedLink(link, context)) {
126131
return
127132
}
128133

129134
if (link.error) {
130-
addError(errors, filePath, link, link.error)
135+
addError(errors, id, file, link, link.error)
131136
return
132137
}
133138

@@ -144,7 +149,7 @@ function validateLink(context: ValidationContext) {
144149

145150
if (path.startsWith('.') || (!linkToValidate.startsWith('/') && !linkToValidate.startsWith('?'))) {
146151
if (options.errorOnRelativeLinks) {
147-
addError(errors, filePath, link, ValidationErrorType.RelativeLink)
152+
addError(errors, id, file, link, ValidationErrorType.RelativeLink)
148153
}
149154

150155
return
@@ -162,7 +167,8 @@ function validateLink(context: ValidationContext) {
162167
if (!isValidPage || !fileHeadings) {
163168
addError(
164169
errors,
165-
filePath,
170+
id,
171+
file,
166172
link,
167173
customPages.has(stripTrailingSlash(sanitizedPath))
168174
? ValidationErrorType.InvalidLinkToCustomPage
@@ -171,24 +177,24 @@ function validateLink(context: ValidationContext) {
171177
return
172178
}
173179

174-
if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(filePath, link.raw, localeConfig)) {
175-
addError(errors, filePath, link, ValidationErrorType.InconsistentLocale)
180+
if (options.errorOnInconsistentLocale && localeConfig && isInconsistentLocaleLink(id, link.raw, localeConfig)) {
181+
addError(errors, id, file, link, ValidationErrorType.InconsistentLocale)
176182
return
177183
}
178184

179185
if (hash && !fileHeadings.includes(hash)) {
180186
if (options.errorOnInvalidHashes) {
181-
addError(errors, filePath, link, ValidationErrorType.InvalidHash)
187+
addError(errors, id, file, link, ValidationErrorType.InvalidHash)
182188
}
183189
return
184190
}
185191

186192
if (path.length > 0) {
187193
if (astroConfig.trailingSlash === 'always' && !path.endsWith('/')) {
188-
addError(errors, filePath, link, ValidationErrorType.TrailingSlashMissing)
194+
addError(errors, id, file, link, ValidationErrorType.TrailingSlashMissing)
189195
return
190196
} else if (astroConfig.trailingSlash === 'never' && path.endsWith('/')) {
191-
addError(errors, filePath, link, ValidationErrorType.TrailingSlashForbidden)
197+
addError(errors, id, file, link, ValidationErrorType.TrailingSlashForbidden)
192198
return
193199
}
194200
}
@@ -208,22 +214,22 @@ function getFileHeadings(path: string, { astroConfig, localeConfig, options, val
208214
* Validate a link to an hash in the same page.
209215
*/
210216
function validateSelfHash(context: ValidationContext) {
211-
const { errors, link, filePath, validationData } = context
217+
const { errors, link, id, file, validationData } = context
212218

213219
if (isExcludedLink(link, context)) {
214220
return
215221
}
216222

217223
const hash = link.raw.split('#')[1] ?? link.raw
218224
const sanitizedHash = hash.replace(/^#/, '')
219-
const fileHeadings = validationData.get(filePath)?.headings
225+
const fileHeadings = validationData.get(id)?.headings
220226

221227
if (!fileHeadings) {
222-
throw new Error(`Failed to find headings for the file at '${filePath}'.`)
228+
throw new Error(`Failed to find headings for the file at '${id}'.`)
223229
}
224230

225231
if (!fileHeadings.includes(sanitizedHash)) {
226-
addError(errors, filePath, link, ValidationErrorType.InvalidHash)
232+
addError(errors, id, file, link, ValidationErrorType.InvalidHash)
227233
}
228234
}
229235

@@ -254,28 +260,28 @@ function isValidAsset(path: string, context: ValidationContext) {
254260
/**
255261
* Check if a link is excluded from validation by the user.
256262
*/
257-
function isExcludedLink(link: Link, { filePath, options, validationData }: ValidationContext) {
263+
function isExcludedLink(link: Link, { id, options, validationData }: ValidationContext) {
258264
if (Array.isArray(options.exclude)) return picomatch(options.exclude)(stripQueryString(link.raw))
259265

260-
const file = validationData.get(filePath)?.file
266+
const file = validationData.get(id)?.file
261267
if (!file) throw new Error('Missing file path to check exclusion.')
262268

263269
return options.exclude({
264270
file,
265271
link: link.raw,
266-
slug: stripTrailingSlash(filePath),
272+
slug: stripTrailingSlash(id),
267273
})
268274
}
269275

270276
function stripQueryString(path: string): string {
271277
return path.split('?')[0] ?? path
272278
}
273279

274-
function addError(errors: ValidationErrors, filePath: string, link: Link, type: ValidationErrorType) {
275-
const fileErrors = errors.get(filePath) ?? []
276-
fileErrors.push({ link: link.raw, type })
280+
function addError(errors: ValidationErrors, id: string, file: string, link: Link, type: ValidationErrorType) {
281+
const fileErrors = errors.get(id) ?? { errors: [], file }
282+
fileErrors.errors.push({ link: link.raw, type })
277283

278-
errors.set(filePath, fileErrors)
284+
errors.set(id, fileErrors)
279285
}
280286

281287
function pluralize(count: number, singular: string) {
@@ -289,7 +295,7 @@ function formatValidationError(error: ValidationError, site: AstroConfig['site']
289295
}
290296

291297
// The validation errors keyed by file path.
292-
type ValidationErrors = Map<string, ValidationError[]>
298+
type ValidationErrors = Map<string, { errors: ValidationError[]; file: string }>
293299

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

@@ -308,7 +314,8 @@ interface ValidationContext {
308314
astroConfig: AstroConfig
309315
customPages: Set<string>
310316
errors: ValidationErrors
311-
filePath: string
317+
id: string
318+
file: string
312319
link: Link
313320
localeConfig: LocaleConfig | undefined
314321
options: StarlightLinksValidatorOptions

packages/starlight-links-validator/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"mdast-util-mdx-jsx": "^3.1.3",
2424
"mdast-util-to-string": "^4.0.0",
2525
"picomatch": "^4.0.2",
26+
"terminal-link": "^5.0.0",
2627
"unist-util-visit": "^5.0.0"
2728
},
2829
"devDependencies": {

pnpm-lock.yaml

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)