docs: add line highlight validation (#14380)
* docs: add line highlight validation * add to all projects * fixes * fixes * fix * fixes
This commit is contained in:
1 parent
6380c1fdf4
commit
cb33388202
59 files changed
+514
-243
No files matched your search
@@ -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}` : ""}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user