docs: support detecting broken link cross-projects (#10483)

* docs: support detecting broken link cross-projects

* remove double separators
This commit is contained in:
Shahed Nasser
2024-12-06 19:54:46 +02:00
committed by GitHub
parent a76b533604
commit e7e36f39fb
28 changed files with 492 additions and 166 deletions
@@ -1,9 +1,253 @@
import { existsSync } from "fs"
import { existsSync, readdirSync, readFileSync } from "fs"
import path from "path"
import type { Transformer } from "unified"
import type { UnistNode, UnistTree } from "./types/index.js"
import type {
BrokenLinkCheckerOptions,
UnistNode,
UnistNodeWithData,
UnistTree,
} from "./types/index.js"
import type { VFile } from "vfile"
import { parseCrossProjectLink } from "./utils/cross-project-link-utils.js"
import { SlugChange } from "types"
import getAttribute from "./utils/get-attribute.js"
import { estreeToJs } from "./utils/estree-to-js.js"
import { performActionOnLiteral } from "./utils/perform-action-on-literal.js"
import { MD_LINK_REGEX } from "./constants.js"
export function brokenLinkCheckerPlugin(): Transformer {
function getErrorMessage({
link,
file,
}: {
link: string
file: VFile
}): string {
return `Broken link found! ${link} linked in ${file.history[0]}`
}
function checkLocalLinkExists({
link,
file,
currentPageFilePath,
}: {
link: string
file: VFile
currentPageFilePath: string
}) {
// get absolute path of the URL
const linkedFilePath = path
.resolve(currentPageFilePath, link)
.replace(/#.*$/, "")
// check if the file exists
if (!existsSync(linkedFilePath)) {
throw new Error(
getErrorMessage({
link,
file,
})
)
}
}
function mdxPageExists(pagePath: string): boolean {
if (!existsSync(pagePath)) {
// for projects that use a convention other than mdx
// check if an mdx file exists with the same name
if (existsSync(`${pagePath}.mdx`)) {
return true
}
return false
}
if (existsSync(path.join(pagePath, "page.mdx"))) {
return true
}
// for projects that use a convention other than mdx
// check if an mdx file exists with the same name
return readdirSync(pagePath).some((fileName) => fileName.endsWith(".mdx"))
}
function componentChecker({
node,
...rest
}: {
node: UnistNodeWithData
file: VFile
currentPageFilePath: string
options: BrokenLinkCheckerOptions
}) {
if (!node.name) {
return
}
let attributeName: string | undefined
const maybeCheckAttribute = () => {
if (!attributeName) {
return
}
const attribute = getAttribute(node, attributeName)
if (
!attribute ||
typeof attribute.value === "string" ||
!attribute.value.data?.estree
) {
return
}
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
return
}
performActionOnLiteral(itemJsVar, (item) => {
checkLink({
link: item.original.value as string,
...rest,
})
})
}
switch (node.name) {
case "Prerequisites":
case "CardList":
attributeName = "items"
break
case "Card":
attributeName = "href"
break
case "WorkflowDiagram":
attributeName = "workflow"
break
case "TypeList":
attributeName = "types"
break
}
maybeCheckAttribute()
}
function checkLink({
link,
file,
currentPageFilePath,
options,
}: {
link: unknown | undefined
file: VFile
currentPageFilePath: string
options: BrokenLinkCheckerOptions
}) {
if (!link || typeof link !== "string") {
return
}
// try to remove hash
const hashIndex = link.lastIndexOf("#")
const likeWithoutHash = hashIndex !== -1 ? link.substring(0, hashIndex) : link
if (likeWithoutHash.match(/page\.mdx?$/)) {
checkLocalLinkExists({
link: likeWithoutHash,
file,
currentPageFilePath,
})
return
}
const parsedLink = parseCrossProjectLink(likeWithoutHash)
if (!parsedLink || !Object.hasOwn(options.crossProjects, parsedLink.area)) {
if (MD_LINK_REGEX.test(link)) {
// try fixing MDX links
let linkMatches
let tempLink = link
MD_LINK_REGEX.lastIndex = 0
while ((linkMatches = MD_LINK_REGEX.exec(tempLink)) !== null) {
if (!linkMatches.groups?.link) {
return
}
checkLink({
link: linkMatches.groups.link,
file,
currentPageFilePath,
options,
})
tempLink = tempLink.replace(linkMatches.groups.link, "")
// reset regex
MD_LINK_REGEX.lastIndex = 0
}
}
return
}
const projectOptions = options.crossProjects[parsedLink.area]
const isReferenceLink = parsedLink.path.startsWith("/references")
const baseDir = isReferenceLink
? "references"
: projectOptions.contentPath || "app"
const pagePath = isReferenceLink
? parsedLink.path.replace(/^\/references/, "")
: parsedLink.path
// check if the file exists
if (mdxPageExists(path.join(projectOptions.projectPath, baseDir, pagePath))) {
return
}
// file doesn't exist, check if slugs are enabled and generated
const generatedSlugsPath = path.join(
projectOptions.projectPath,
"generated",
"slug-changes.mjs"
)
if (!projectOptions.hasGeneratedSlugs || !existsSync(generatedSlugsPath)) {
throw new Error(
getErrorMessage({
link,
file,
})
)
}
// get slugs from file
const generatedSlugContent = readFileSync(generatedSlugsPath, "utf-8")
const slugChanges: SlugChange[] = JSON.parse(
generatedSlugContent.substring(generatedSlugContent.indexOf("["))
)
const slugChange = slugChanges.find(
(change) => change.newSlug === parsedLink.path
)
if (
!slugChange ||
!mdxPageExists(path.join(projectOptions.projectPath, slugChange.origSlug))
) {
throw new Error(
getErrorMessage({
link,
file,
})
)
}
}
const allowedComponentNames = [
"Card",
"CardList",
"Prerequisites",
"WorkflowDiagram",
"TypeList",
]
export function brokenLinkCheckerPlugin(
options: BrokenLinkCheckerOptions
): Transformer {
return async (tree, file) => {
const { visit } = await import("unist-util-visit")
@@ -12,20 +256,26 @@ export function brokenLinkCheckerPlugin(): Transformer {
""
)
visit(tree as UnistTree, "element", (node: UnistNode) => {
if (node.tagName !== "a" || !node.properties?.href?.match(/page\.mdx?/)) {
return
visit(
tree as UnistTree,
["element", "mdxJsxFlowElement"],
(node: UnistNode) => {
if (node.tagName === "a" && node.properties?.href) {
checkLink({
link: node.properties.href,
file,
currentPageFilePath,
options,
})
} else if (node.name && allowedComponentNames.includes(node.name)) {
componentChecker({
node: node as UnistNodeWithData,
file,
currentPageFilePath,
options,
})
}
}
// get absolute path of the URL
const linkedFilePath = path
.resolve(currentPageFilePath, node.properties.href)
.replace(/#.*$/, "")
// check if the file exists
if (!existsSync(linkedFilePath)) {
throw new Error(
`Broken link found! ${node.properties.href} linked in ${file.history[0]}`
)
}
})
)
}
}
@@ -0,0 +1 @@
export const MD_LINK_REGEX = /\[(.*?)\]\((?<link>(![a-z]+!|\.).*?)\)/gm
@@ -1,18 +1,13 @@
/* eslint-disable no-case-declarations */
import type { Transformer } from "unified"
import type {
CrossProjectLinksOptions,
ExpressionJsVar,
UnistNode,
UnistNodeWithData,
UnistTree,
} from "./types/index.js"
import { estreeToJs } from "./utils/estree-to-js.js"
import getAttribute from "./utils/get-attribute.js"
import {
isExpressionJsVarLiteral,
isExpressionJsVarObj,
} from "./utils/expression-is-utils.js"
import { performActionOnLiteral } from "./utils/perform-action-on-literal.js"
const PROJECT_REGEX = /^!(?<area>[\w-]+)!/
@@ -61,89 +56,65 @@ function componentFixer(
return
}
const fixProperty = (item: ExpressionJsVar) => {
if (!isExpressionJsVarObj(item)) {
let attributeName: string | undefined
const maybeCheckAttribute = () => {
if (!attributeName) {
return
}
Object.entries(item).forEach(([key, value]) => {
if (
(key !== "href" && key !== "link") ||
!isExpressionJsVarLiteral(value)
) {
return
}
const attribute = getAttribute(node, attributeName)
value.original.value = matchAndFixLinks(
value.original.value as string,
if (
!attribute ||
typeof attribute.value === "string" ||
!attribute.value.data?.estree
) {
return
}
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
return
}
performActionOnLiteral(itemJsVar, (item) => {
item.original.value = matchAndFixLinks(
item.original.value as string,
options
)
value.original.raw = JSON.stringify(value.original.value)
item.original.raw = JSON.stringify(item.original.value)
})
}
switch (node.name) {
case "CardList":
const itemsAttribute = getAttribute(node, "items")
if (
!itemsAttribute?.value ||
typeof itemsAttribute.value === "string" ||
!itemsAttribute.value.data?.estree
) {
return
}
const jsVar = estreeToJs(itemsAttribute.value.data.estree)
if (!jsVar) {
return
}
if (Array.isArray(jsVar)) {
jsVar.forEach(fixProperty)
} else {
fixProperty(jsVar)
}
return
case "Card":
const hrefAttribute = getAttribute(node, "href")
if (!hrefAttribute?.value || typeof hrefAttribute.value !== "string") {
return
}
hrefAttribute.value = matchAndFixLinks(hrefAttribute.value, options)
return
case "Prerequisites":
const prerequisitesItemsAttribute = getAttribute(node, "items")
if (
!prerequisitesItemsAttribute?.value ||
typeof prerequisitesItemsAttribute.value === "string" ||
!prerequisitesItemsAttribute.value.data?.estree
) {
return
}
const prerequisitesJsVar = estreeToJs(
prerequisitesItemsAttribute.value.data.estree
)
if (!prerequisitesJsVar) {
return
}
if (Array.isArray(prerequisitesJsVar)) {
prerequisitesJsVar.forEach(fixProperty)
} else {
fixProperty(prerequisitesJsVar)
}
return
attributeName = "items"
break
case "Card":
attributeName = "href"
break
case "WorkflowDiagram":
attributeName = "workflow"
break
case "TypeList":
attributeName = "types"
break
}
maybeCheckAttribute()
}
const allowedComponentNames = [
"Card",
"CardList",
"Prerequisites",
"WorkflowDiagram",
"TypeList",
]
export function crossProjectLinksPlugin(
options: CrossProjectLinksOptions
): Transformer {
@@ -155,9 +126,7 @@ export function crossProjectLinksPlugin(
["element", "mdxJsxFlowElement"],
(node: UnistNode) => {
const isComponent =
node.name === "Card" ||
node.name === "CardList" ||
node.name === "Prerequisites"
node.name && allowedComponentNames.includes(node.name)
const isLink = node.tagName === "a" && node.properties?.href
if (!isComponent && !isLink) {
return
@@ -118,6 +118,16 @@ export declare type CrossProjectLinksOptions = {
useBaseUrl?: boolean
}
export declare type BrokenLinkCheckerOptions = {
crossProjects: {
[k: string]: {
projectPath: string
contentPath?: string
hasGeneratedSlugs?: boolean
}
}
}
export declare type ComponentLinkFixerLinkType = "md" | "value"
export declare type ComponentLinkFixerOptions = {
@@ -1,21 +1,13 @@
import path from "path"
import { Transformer } from "unified"
import {
ComponentLinkFixerLinkType,
ExpressionJsVar,
UnistNodeWithData,
UnistTree,
} from "../types/index.js"
import { UnistNodeWithData, UnistTree } from "../types/index.js"
import { FixLinkOptions, fixLinkUtil } from "../index.js"
import getAttribute from "../utils/get-attribute.js"
import { estreeToJs } from "../utils/estree-to-js.js"
import {
isExpressionJsVarLiteral,
isExpressionJsVarObj,
} from "../utils/expression-is-utils.js"
import { ComponentLinkFixerOptions } from "../types/index.js"
import { performActionOnLiteral } from "./perform-action-on-literal.js"
import { MD_LINK_REGEX } from "../constants.js"
const MD_LINK_REGEX = /\[(.*?)\]\((?<link>(![a-z]+!|\.).*?)\)/gm
const VALUE_LINK_REGEX = /^(![a-z]+!|\.)/gm
function matchMdLinks(
@@ -59,33 +51,6 @@ function matchValueLink(
})
}
function traverseJsVar(
item: ExpressionJsVar[] | ExpressionJsVar,
linkOptions: Omit<FixLinkOptions, "linkedPath">,
checkLinksType: ComponentLinkFixerLinkType
) {
const linkFn = checkLinksType === "md" ? matchMdLinks : matchValueLink
if (Array.isArray(item)) {
item.forEach((item) => traverseJsVar(item, linkOptions, checkLinksType))
} else if (isExpressionJsVarLiteral(item)) {
item.original.value = linkFn(item.original.value as string, linkOptions)
item.original.raw = JSON.stringify(item.original.value)
} else {
Object.values(item).forEach((value) => {
if (Array.isArray(value) || isExpressionJsVarObj(value)) {
return traverseJsVar(value, linkOptions, checkLinksType)
}
if (!isExpressionJsVarLiteral(value)) {
return
}
value.original.value = linkFn(value.original.value as string, linkOptions)
value.original.raw = JSON.stringify(value.original.value)
})
}
}
export function componentLinkFixer(
componentName: string,
attributeName: string,
@@ -117,12 +82,12 @@ export function componentLinkFixer(
return
}
const workflowAttribute = getAttribute(node, attributeName)
const attribute = getAttribute(node, attributeName)
if (
!workflowAttribute ||
typeof workflowAttribute.value === "string" ||
!workflowAttribute.value.data?.estree
!attribute ||
typeof attribute.value === "string" ||
!attribute.value.data?.estree
) {
return
}
@@ -132,13 +97,17 @@ export function componentLinkFixer(
appsPath,
}
const itemJsVar = estreeToJs(workflowAttribute.value.data.estree)
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
return
}
traverseJsVar(itemJsVar, linkOptions, checkLinksType)
const linkFn = checkLinksType === "md" ? matchMdLinks : matchValueLink
performActionOnLiteral(itemJsVar, (item) => {
item.original.value = linkFn(item.original.value as string, linkOptions)
item.original.raw = JSON.stringify(item.original.value)
})
})
}
}
@@ -0,0 +1,21 @@
const PROJECT_REGEX = /^!(?<area>[\w-]+)!/
export const parseCrossProjectLink = (
link: string
):
| {
area: string
path: string
}
| undefined => {
const projectArea = PROJECT_REGEX.exec(link)
if (!projectArea?.groups?.area) {
return undefined
}
return {
area: projectArea.groups.area,
path: link.replace(PROJECT_REGEX, ""),
}
}
@@ -0,0 +1,28 @@
import { ExpressionJsVar, ExpressionJsVarLiteral } from "../types/index.js"
import {
isExpressionJsVarLiteral,
isExpressionJsVarObj,
} from "./expression-is-utils.js"
export const performActionOnLiteral = (
item: ExpressionJsVar[] | ExpressionJsVar,
action: (item: ExpressionJsVarLiteral) => void
) => {
if (Array.isArray(item)) {
item.forEach((i) => performActionOnLiteral(i, action))
} else if (isExpressionJsVarLiteral(item)) {
action(item)
} else {
Object.values(item).forEach((value) => {
if (Array.isArray(value) || isExpressionJsVarObj(value)) {
return performActionOnLiteral(value, action)
}
if (!isExpressionJsVarLiteral(value)) {
return
}
action(value)
})
}
}