docs: support detecting broken link cross-projects (#10483)
* docs: support detecting broken link cross-projects * remove double separators
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user