docs: add line highlight validation (#14380)

* docs: add line highlight validation

* add to all projects

* fixes

* fixes

* fix

* fixes
This commit is contained in:
Shahed Nasser authored and GitHub committed 2025-12-22 14:10:39 +02:00
1 parent 6380c1fdf4
commit cb33388202
59 files changed
+514 -243

No files matched your search

+50 -4
View File
@@ -9,24 +9,39 @@ import {
JSXTextExpression,
LiteralExpression,
ObjectExpression,
TemplateLiteralExpression,
VariableDeclaration,
} from "types"
const ALLOWED_BODY_TYPES = ["ExpressionStatement", "ExportNamedDeclaration"]
export function estreeToJs(estree: Estree) {
// TODO improve on this utility. Currently it's implemented to work
// for specific use cases as we don't have a lot of info on other
// use cases.
if (
!estree.body?.length ||
estree.body[0].type !== "ExpressionStatement" ||
!estree.body[0].expression
!estree.body[0].type ||
!ALLOWED_BODY_TYPES.includes(estree.body[0].type)
) {
return
}
return expressionToJs(estree.body[0].expression)
switch (estree.body[0].type) {
case "ExpressionStatement":
if (!estree.body[0].expression) {
return
}
return expressionToJs(estree.body[0].expression)
case "ExportNamedDeclaration":
if (!estree.body[0].declaration) {
return
}
return declarationToJs(estree.body[0].declaration)
}
}
function expressionToJs(
export function expressionToJs(
expression: Expression
): ExpressionJsVar | ExpressionJsVar[] | undefined {
switch (expression.type) {
@@ -84,5 +99,36 @@ function expressionToJs(
},
data: text,
}
case "TemplateLiteral":
const templateExpression = expression as TemplateLiteralExpression
let value = ""
let raw = ""
templateExpression.quasis.forEach((quasi) => {
value += quasi.value.cooked
raw += quasi.value.raw
})
return {
original: {
type: "Literal",
value,
raw,
},
data: value,
}
}
}
function declarationToJs(declaration: VariableDeclaration): {
name: string
value: ExpressionJsVar | ExpressionJsVar[] | undefined
} {
if (!declaration.declarations.length) {
throw new Error("No declarations found")
}
const name = declaration.declarations[0].id.name
const value = expressionToJs(declaration.declarations[0].init)
return {
name,
value,
}
}
@@ -175,7 +175,7 @@ function componentChecker({
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
if (!itemJsVar || "name" in itemJsVar) {
return
}
@@ -217,7 +217,7 @@ function checkLink({
currentPageFilePath: string
options: BrokenLinkCheckerOptions
}) {
if (!link || typeof link !== "string") {
if (!link || typeof link !== "string" || link === "/" || link === "#") {
return
}
// try to remove hash
@@ -88,7 +88,7 @@ function componentFixer(
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
if (!itemJsVar || "name" in itemJsVar) {
return
}
@@ -11,6 +11,7 @@ export * from "./prerequisites-link-fixer.js"
export * from "./resolve-admonitions.js"
export * from "./type-list-link-fixer.js"
export * from "./ui-rehype-plugin.js"
export * from "./validate-highlights.js"
export * from "./workflow-diagram-link-fixer.js"
export * from "./utils/fix-link.js"
@@ -105,7 +105,7 @@ export function componentLinkFixer(
const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) {
if (!itemJsVar || "name" in itemJsVar) {
return
}
@@ -0,0 +1,253 @@
import {
ArrayExpression,
Estree,
ExpressionJsVar,
Identifier,
JSXElementExpression,
JSXExpressionContainer,
LiteralExpression,
UnistNode,
UnistTree,
} from "types"
import type { Transformer } from "unified"
import { estreeToJs, expressionToJs } from "docs-utils"
type Options = {
verbose?: boolean
}
export function validateHighlightsPlugin(options?: Options): Transformer {
const { verbose } = options || {}
return async (tree) => {
const { visit } = await import("unist-util-visit")
const highlightConsts = new Map<string, string[][]>()
visit(tree as UnistTree, ["mdxjsEsm", "element"], (node: UnistNode) => {
if (node.type === "mdxjsEsm") {
if (node.data && "estree" in node.data && node.data.estree) {
const highlightsJsVar = estreeToJs(node.data.estree)
if (
!highlightsJsVar ||
!("name" in highlightsJsVar) ||
typeof highlightsJsVar.name !== "string" ||
!Array.isArray(highlightsJsVar.value)
) {
return
}
const highlights = getHighlightsFromExpressionJsVar(
highlightsJsVar.value
)
if (highlights) {
highlightConsts.set(highlightsJsVar.name, highlights)
}
}
return
}
if (
node.tagName !== "pre" ||
!node.children?.length ||
!node.children[0].data ||
!("estree" in node.children[0].data) ||
!node.children[0].data.estree
) {
return
}
const body = (node.children[0].data.estree as Estree).body?.[0]
if (
!body ||
body.type !== "ExpressionStatement" ||
!body.expression ||
body.expression.type !== "JSXElement"
) {
return
}
const bodyExpression = body.expression as JSXElementExpression
if (
!bodyExpression.children.length ||
bodyExpression.children[0].type !== "JSXExpressionContainer"
) {
return
}
const codeExpression = (
bodyExpression.children[0] as JSXExpressionContainer
).expression
if (codeExpression.type !== "Literal") {
return
}
const highlightsAttr = bodyExpression.openingElement?.attributes.find(
(attr) => attr.name.name === "highlights"
)
if (!highlightsAttr) {
return
}
let highlights: string[][] | undefined
if (
highlightsAttr.value.expression.type !== "Identifier" &&
highlightsAttr.value.expression.type === "ArrayExpression"
) {
const highlightsJs = getExpressionJsVarFromArrExpression(
highlightsAttr.value.expression as ArrayExpression
)
if (!highlightsJs || !Array.isArray(highlightsJs)) {
return
}
highlights = getHighlightsFromExpressionJsVar(highlightsJs)
if (!highlights) {
return
}
} else {
if (highlightsAttr.value.expression.type !== "Identifier") {
return
}
const name = (highlightsAttr.value.expression as Identifier).name
highlights = highlightConsts.get(name)
if (!highlights) {
return
}
}
const code = (codeExpression as LiteralExpression).value as string
validateCodeHighlights({
highlights,
code,
verbose,
})
})
}
}
const getExpressionJsVarFromArrExpression = (
expression: ArrayExpression
): ExpressionJsVar[][] => {
if (!Array.isArray(expression.elements)) {
return []
}
return expression.elements.map((elm) => {
const elmJsVar = expressionToJs(elm)
if (!elmJsVar || !Array.isArray(elmJsVar)) {
return []
}
return elmJsVar
})
}
const getHighlightsFromExpressionJsVar = (
expression: ExpressionJsVar[] | ExpressionJsVar[][]
): string[][] | undefined => {
const highlights: string[][] = []
const shouldExtractHighlights = !Array.isArray(expression[0])
// the expression is an array where each three items are a highlight
// so we need to split the expression into groups of three items first
const highlightExpressions = shouldExtractHighlights
? (expression as ExpressionJsVar[]).reduce((acc, item, index) => {
if (index % 3 === 0) {
acc.push([])
}
acc[acc.length - 1].push(item)
return acc
}, [] as ExpressionJsVar[][])
: (expression as ExpressionJsVar[][])
if (!highlightExpressions.length) {
return undefined
}
for (const highlightExpression of highlightExpressions) {
if (highlightExpression.length !== 3) {
continue
}
const highlight = []
for (const item of highlightExpression) {
if (typeof item.data !== "string") {
continue
}
highlight.push(item.data)
}
highlights.push(highlight)
}
return !highlights.length ? undefined : highlights
}
/**
* Validates that the highlights of each code block are correct. It validates:
*
* 1. The highlight's first parameter is a string number of a valid line number in the code block.
* 2. The highlight's second parameter is a string that is a valid text in the line of the code block.
*/
const validateCodeHighlights = ({
highlights,
code,
verbose,
}: {
highlights: string[][]
code: string
verbose?: boolean
}): void => {
try {
const lines = code.trim().split("\n")
for (const highlight of highlights) {
if (highlight.length < 2) {
throw new Error(`Highlight array must have at least 2 elements`)
}
const lineNumber = highlight[0]
const text = highlight[1]
if (typeof lineNumber !== "string" || typeof text !== "string") {
throw new Error(`Highlight array must have at least 2 elements`)
}
if (isNaN(Number(lineNumber))) {
throw new Error(`Highlight line number must be a number`)
}
const line = lines[Number(lineNumber) - 1]?.trim()
if (line === undefined) {
throw new Error(`Highlight line number ${lineNumber} not found in code`)
}
if (text.length && !line.includes(text)) {
throw new Error(
`Highlight text ${text} not found in line ${lineNumber}`
)
}
}
} catch (err) {
throw new Error(
formatError({
message: (err as Error).message,
code,
verbose,
})
)
}
}
const formatError = ({
message,
code,
verbose,
}: {
message: string
code: string
verbose?: boolean
}): string => {
return `${message}${verbose ? `:\n${code}` : ""}`
}
+58 -4
View File
@@ -41,6 +41,10 @@ export type LiteralExpression = {
export type JSXElementExpression = {
type: "JSXElement" | "JSXFragment"
children: Expression[]
openingElement: {
type: "JSXOpeningElement"
attributes: UnistJSXAttributeNode[]
}
}
export type JSXTextExpression = {
@@ -49,6 +53,33 @@ export type JSXTextExpression = {
raw: string
}
export type VariableDeclaration = {
type: "VariableDeclaration"
declarations: VariableDeclarator[]
}
export type VariableDeclarator = {
type: "VariableDeclarator"
id: Identifier
init: Expression
}
export type Identifier = {
type: "Identifier"
name: string
}
export type TemplateLiteralExpression = {
type: "TemplateLiteral"
expressions: Expression[]
quasis: {
type: "TemplateElement"
value: {
raw: string
cooked: string
}
}[]
}
export type Expression =
| {
type: string
@@ -60,10 +91,28 @@ export type Expression =
| JSXTextExpression
export interface Estree {
body?: {
type?: string
expression?: Expression
}[]
body?: (
| {
type?: "ExpressionStatement"
expression?: Expression
}
| {
type?: "ExportNamedDeclaration"
declaration?: VariableDeclaration
}
)[]
}
export interface UnistJSXAttributeNode {
type: "JSXAttribute"
name: {
type: "JSXIdentifier"
name: string
}
value: {
type: "JSXExpressionContainer"
expression: Identifier | Expression
}
}
export interface UnistNodeWithData extends UnistNode {
@@ -244,3 +293,8 @@ export type ExpressionJsVarObj = {
}
export type ExpressionJsVar = ExpressionJsVarObj | ExpressionJsVarLiteral
export type JSXExpressionContainer = {
type: "JSXExpressionContainer"
expression: Expression
}