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 authored and GitHub committed 2024-12-06 19:54:46 +02:00
1 parent a76b533604
commit e7e36f39fb
28 files changed
+492 -166

No files matched your search

+1 -1
View File
@@ -832,7 +832,7 @@ If you click on the workflow, you'll view a reference of that workflow, includin
This is useful if you want to extend an API route and pass additional data or perform custom actions. This is useful if you want to extend an API route and pass additional data or perform custom actions.
Refer to [this guide](https://docs.medusajs.com/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. Refer to [this guide](!docs!/learn/customization/extend-features/extend-create-product) to find an example of extending an API route.
<Feedback <Feedback
event="survey_api-ref" event="survey_api-ref"
@@ -4,7 +4,7 @@ import DownloadFull from "@/components/DownloadFull"
<H3 className="!mt-0">Just Getting Started?</H3> <H3 className="!mt-0">Just Getting Started?</H3>
Check out the [Medusa v2 Documentation](https://docs.medusajs.com). Check out the [Medusa v2 Documentation](!docs!).
<Space bottom={8} /> <Space bottom={8} />
@@ -16,7 +16,7 @@ To use Medusa's JS SDK library, install the following packages in your project (
npm install @medusajs/js-sdk@latest @medusajs/types@latest npm install @medusajs/js-sdk@latest @medusajs/types@latest
``` ```
Learn more about the JS SDK in [this documentation](https://docs.medusajs.com/resources/js-sdk). Learn more about the JS SDK in [this documentation](!resources!/js-sdk).
### Download Full Reference ### Download Full Reference
+1 -1
View File
@@ -831,7 +831,7 @@ If you click on the workflow, you'll view a reference of that workflow, includin
This is useful if you want to extend an API route and pass additional data or perform custom actions. This is useful if you want to extend an API route and pass additional data or perform custom actions.
Refer to [this guide](https://docs.medusajs.com/learn/customization/extend-features/extend-create-product) to find an example of extending an API route. Refer to [this guide](!docs!/learn/customization/extend-features/extend-create-product) to find an example of extending an API route.
<Feedback <Feedback
event="survey_api-ref" event="survey_api-ref"
+47
View File
@@ -2,6 +2,11 @@ import createMDX from "@next/mdx"
import bundleAnalyzer from "@next/bundle-analyzer" import bundleAnalyzer from "@next/bundle-analyzer"
import rehypeMdxCodeProps from "rehype-mdx-code-props" import rehypeMdxCodeProps from "rehype-mdx-code-props"
import rehypeSlug from "rehype-slug" import rehypeSlug from "rehype-slug"
import {
brokenLinkCheckerPlugin,
crossProjectLinksPlugin,
} from "remark-rehype-plugins"
import path from "path"
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
@@ -28,6 +33,48 @@ const nextConfig = {
const withMDX = createMDX({ const withMDX = createMDX({
options: { options: {
rehypePlugins: [ rehypePlugins: [
[
brokenLinkCheckerPlugin,
{
crossProjects: {
docs: {
projectPath: path.resolve("..", "book"),
},
resources: {
projectPath: path.resolve("..", "resources"),
hasGeneratedSlugs: true,
},
ui: {
projectPath: path.resolve("..", "ui"),
contentPath: "src/content/docs",
},
},
},
],
[
crossProjectLinksPlugin,
{
baseUrl: process.env.NEXT_PUBLIC_BASE_URL,
projectUrls: {
docs: {
url: process.env.NEXT_PUBLIC_DOCS_URL,
path: "",
},
resources: {
url: process.env.NEXT_PUBLIC_RESOURCES_URL,
},
"user-guide": {
url: process.env.NEXT_PUBLIC_USER_GUIDE_URL,
},
ui: {
url: process.env.NEXT_PUBLIC_UI_URL,
},
},
useBaseUrl:
process.env.NODE_ENV === "production" ||
process.env.VERCEL_ENV === "production",
},
],
[ [
rehypeMdxCodeProps, rehypeMdxCodeProps,
{ {
+1
View File
@@ -41,6 +41,7 @@
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"rehype-mdx-code-props": "^3.0.1", "rehype-mdx-code-props": "^3.0.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-rehype-plugins": "*",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"swr": "^2.2.0", "swr": "^2.2.0",
"tailwind": "*", "tailwind": "*",
@@ -288,6 +288,6 @@ You can now execute this workflow in a custom API route, scheduled job, or subsc
<Note title="Tip"> <Note title="Tip">
Find a full list of the registered resources in the Medusa container and their registration key in [this reference](!resources!/resources/medusa-container-resources). You can use these resources in your custom workflows. Find a full list of the registered resources in the Medusa container and their registration key in [this reference](!resources!/medusa-container-resources). You can use these resources in your custom workflows.
</Note> </Note>
@@ -6,7 +6,7 @@ export const metadata = {
# {metadata.title} # {metadata.title}
In the previous chapters, you [defined a link](../define-link/page.mdx) between the [custom Brand Module](../../custom-features/module/page.mdx) and Medusa's [Product Module](!resources!/comerce-modules/product), then [extended the create-product flow](../extend-create-product/page.mdx) to link a product to a brand. In the previous chapters, you [defined a link](../define-link/page.mdx) between the [custom Brand Module](../../custom-features/module/page.mdx) and Medusa's [Product Module](!resources!/commerce-modules/product), then [extended the create-product flow](../extend-create-product/page.mdx) to link a product to a brand.
In this chapter, you'll learn how to retrieve a product's brand (and vice-versa) in two ways: Using Medusa's existing API route, or in customizations, such as a custom API route. In this chapter, you'll learn how to retrieve a product's brand (and vice-versa) in two ways: Using Medusa's existing API route, or in customizations, such as a custom API route.
+16 -1
View File
@@ -9,11 +9,27 @@ import {
crossProjectLinksPlugin, crossProjectLinksPlugin,
} from "remark-rehype-plugins" } from "remark-rehype-plugins"
import { sidebar } from "./sidebar.mjs" import { sidebar } from "./sidebar.mjs"
import path from "path"
const withMDX = mdx({ const withMDX = mdx({
extension: /\.mdx?$/, extension: /\.mdx?$/,
options: { options: {
rehypePlugins: [ rehypePlugins: [
[
brokenLinkCheckerPlugin,
{
crossProjects: {
resources: {
projectPath: path.resolve("..", "resources"),
hasGeneratedSlugs: true,
},
ui: {
projectPath: path.resolve("..", "ui"),
contentPath: "src/content/docs",
},
},
},
],
[ [
crossProjectLinksPlugin, crossProjectLinksPlugin,
{ {
@@ -37,7 +53,6 @@ const withMDX = mdx({
process.env.VERCEL_ENV === "production", process.env.VERCEL_ENV === "production",
}, },
], ],
[brokenLinkCheckerPlugin],
[localLinksRehypePlugin], [localLinksRehypePlugin],
[ [
rehypeMdxCodeProps, rehypeMdxCodeProps,
@@ -11,7 +11,7 @@ export const metadata = {
The Medusa Admin has two types of forms: The Medusa Admin has two types of forms:
1. Create forms, created using the [FocusModal UI component](!ui!/components/focus-modal). 1. Create forms, created using the [FocusModal UI component](!ui!/components/focus-modal).
2. Edit or update forms, created using the [Drawer UI component](!ui!/ui/components/drawer). 2. Edit or update forms, created using the [Drawer UI component](!ui!/components/drawer).
This guide explains how to create these two form types following the Medusa Admin's conventions. This guide explains how to create these two form types following the Medusa Admin's conventions.
@@ -66,7 +66,7 @@ remoteLink.create({
<Note title="Tip"> <Note title="Tip">
Learn more about the remote link in [this documentation](!docs!/advanced-development/module-links/remote-link). Learn more about the remote link in [this documentation](!docs!/learn/advanced-development/module-links/remote-link).
</Note> </Note>
@@ -14,7 +14,7 @@ In this document, you'll learn how to calculate a product variant's price with t
You'll need the following resources for the taxes calculation: You'll need the following resources for the taxes calculation:
1. [Query](!docs!/advanced-development/module-links/query) to retrieve the product's variants' prices for a context. Learn more about that in [this guide](../price/page.mdx). 1. [Query](!docs!/learn/advanced-development/module-links/query) to retrieve the product's variants' prices for a context. Learn more about that in [this guide](../price/page.mdx).
2. The Tax Module's main service to get the tax lines for each product. 2. The Tax Module's main service to get the tax lines for each product.
```ts ```ts
@@ -121,7 +121,7 @@ To do that, you'll consume the [promotionsCreated](/references/medusa-workflows/
<Note title="Tip"> <Note title="Tip">
Learn more about workflow hooks in [this guide](!docs!/advanced-development/workflows/workflow-hooks). Learn more about workflow hooks in [this guide](!docs!/learn/advanced-development/workflows/workflow-hooks).
</Note> </Note>
@@ -156,7 +156,7 @@ In the snippet above, you add a validation rule indicating that `custom_name` is
<Note title="Tip"> <Note title="Tip">
Learn more about additional data validation in [this guide](!docs!/advanced-development/api-routes/additional-data). Learn more about additional data validation in [this guide](!docs!/learn/advanced-development/api-routes/additional-data).
</Note> </Note>
@@ -208,7 +208,7 @@ In the compensation function that undoes the step's actions in case of an error,
<Note title="Tip"> <Note title="Tip">
Learn more about compensation functions in [this guide](!docs!/advanced-development/workflows/compensation-function). Learn more about compensation functions in [this guide](!docs!/learn/advanced-development/workflows/compensation-function).
</Note> </Note>
@@ -266,9 +266,9 @@ The workflow accepts as an input the created promotion and the `additional_data`
In the workflow, you: In the workflow, you:
1. Use the `transform` utility to get the value of `custom_name` based on whether it's set in `additional_data`. Learn more about why you can't use conditional operators in a workflow without using `transform` in [this guide](!docs!/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). 1. Use the `transform` utility to get the value of `custom_name` based on whether it's set in `additional_data`. Learn more about why you can't use conditional operators in a workflow without using `transform` in [this guide](!docs!/learn/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows).
2. Create the `Custom` record using the `createCustomStep`. 2. Create the `Custom` record using the `createCustomStep`.
3. Use the `when-then` utility to link the promotion to the `Custom` record if it was created. Learn more about why you can't use if-then conditions in a workflow without using `when-then` in [this guide](!docs!/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows). 3. Use the `when-then` utility to link the promotion to the `Custom` record if it was created. Learn more about why you can't use if-then conditions in a workflow without using `when-then` in [this guide](!docs!/learn/advanced-development/workflows/conditions#why-if-conditions-arent-allowed-in-workflows).
You'll next execute the workflow in the hook handler. You'll next execute the workflow in the hook handler.
@@ -379,7 +379,7 @@ Among the returned `promotion` object, you'll find a `custom` property which hol
### Retrieve using Query ### Retrieve using Query
You can also retrieve the `Custom` record linked to a promotion in your code using [Query](!docs!/advanced-development/module-links/query). You can also retrieve the `Custom` record linked to a promotion in your code using [Query](!docs!/learn/advanced-development/module-links/query).
For example: For example:
@@ -393,7 +393,7 @@ const { data: [promotion] } = await query.graph({
}) })
``` ```
Learn more about how to use Query in [this guide](!docs!/advanced-development/module-links/query). Learn more about how to use Query in [this guide](!docs!/learn/advanced-development/module-links/query).
--- ---
+1 -1
View File
@@ -736,7 +736,7 @@ The Medusa Admin plugin can be extended to add widgets, new pages, and setting p
icon: AcademicCapSolid, icon: AcademicCapSolid,
}, },
{ {
href: "!docs!/learn/advanced-development/admin/setting-pages", href: "!docs!/learn/advanced-development/admin/ui-routes#create-settings-page",
title: "Create Admin Setting Page", title: "Create Admin Setting Page",
text: "Learn how to add new page to the Medusa Admin settings.", text: "Learn how to add new page to the Medusa Admin settings.",
icon: AcademicCapSolid, icon: AcademicCapSolid,
@@ -2008,7 +2008,7 @@ This loops over the returned subscriptions and executes the `createSubscriptionO
### Further Reads ### Further Reads
- [How to Create a Scheduled Job](!docs!/learn/basics/scheeduled-jobs) - [How to Create a Scheduled Job](!docs!/learn/basics/scheduled-jobs)
--- ---
-3
View File
@@ -9118,9 +9118,6 @@ export const generatedSidebar = [
} }
] ]
}, },
{
"type": "separator"
},
{ {
"loaded": true, "loaded": true,
"isPathHref": true, "isPathHref": true,
+15 -1
View File
@@ -7,13 +7,27 @@ import {
workflowDiagramLinkFixerPlugin, workflowDiagramLinkFixerPlugin,
} from "remark-rehype-plugins" } from "remark-rehype-plugins"
import mdxPluginOptions from "./mdx-options.mjs" import mdxPluginOptions from "./mdx-options.mjs"
import path from "node:path"
const withMDX = mdx({ const withMDX = mdx({
extension: /\.mdx?$/, extension: /\.mdx?$/,
options: { options: {
rehypePlugins: [ rehypePlugins: [
[
brokenLinkCheckerPlugin,
{
crossProjects: {
docs: {
projectPath: path.resolve("..", "book"),
},
ui: {
projectPath: path.resolve("..", "ui"),
contentPath: "src/content/docs",
},
},
},
],
...mdxPluginOptions.options.rehypePlugins, ...mdxPluginOptions.options.rehypePlugins,
[brokenLinkCheckerPlugin],
[localLinksRehypePlugin], [localLinksRehypePlugin],
[typeListLinkFixerPlugin], [typeListLinkFixerPlugin],
[ [
-3
View File
@@ -2131,9 +2131,6 @@ export const sidebar = sidebarAttachHrefCommonOptions([
}, },
], ],
}, },
{
type: "separator",
},
{ {
type: "category", type: "category",
title: "General", title: "General",
+2 -2
View File
@@ -7,7 +7,7 @@ const monoRepoPath = path.resolve("..", "..", "..")
/** /**
* *
* @param {string} dir - The directory to search in * @param {string} dir - The directory to search in
* @returns {Promise<{ origSlug: string; newSlug: string }[]>} * @returns {Promise<import("types").SlugChange[]>}
*/ */
export default async function getSlugs(options = {}) { export default async function getSlugs(options = {}) {
let { dir, basePath = path.resolve("app"), baseSlug = basePath } = options let { dir, basePath = path.resolve("app"), baseSlug = basePath } = options
@@ -15,7 +15,7 @@ export default async function getSlugs(options = {}) {
dir = basePath dir = basePath
} }
/** /**
* @type {{ origSlug: string; newSlug: string }[]} * @type {import("types").SlugChange[]}
*/ */
const slugs = [] const slugs = []
@@ -1,9 +1,253 @@
import { existsSync } from "fs" import { existsSync, readdirSync, readFileSync } from "fs"
import path from "path" import path from "path"
import type { Transformer } from "unified" 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) => { return async (tree, file) => {
const { visit } = await import("unist-util-visit") const { visit } = await import("unist-util-visit")
@@ -12,20 +256,26 @@ export function brokenLinkCheckerPlugin(): Transformer {
"" ""
) )
visit(tree as UnistTree, "element", (node: UnistNode) => { visit(
if (node.tagName !== "a" || !node.properties?.href?.match(/page\.mdx?/)) { tree as UnistTree,
return ["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 { Transformer } from "unified"
import type { import type {
CrossProjectLinksOptions, CrossProjectLinksOptions,
ExpressionJsVar,
UnistNode, UnistNode,
UnistNodeWithData, UnistNodeWithData,
UnistTree, UnistTree,
} from "./types/index.js" } from "./types/index.js"
import { estreeToJs } from "./utils/estree-to-js.js" import { estreeToJs } from "./utils/estree-to-js.js"
import getAttribute from "./utils/get-attribute.js" import getAttribute from "./utils/get-attribute.js"
import { import { performActionOnLiteral } from "./utils/perform-action-on-literal.js"
isExpressionJsVarLiteral,
isExpressionJsVarObj,
} from "./utils/expression-is-utils.js"
const PROJECT_REGEX = /^!(?<area>[\w-]+)!/ const PROJECT_REGEX = /^!(?<area>[\w-]+)!/
@@ -61,89 +56,65 @@ function componentFixer(
return return
} }
const fixProperty = (item: ExpressionJsVar) => { let attributeName: string | undefined
if (!isExpressionJsVarObj(item)) {
const maybeCheckAttribute = () => {
if (!attributeName) {
return return
} }
Object.entries(item).forEach(([key, value]) => { const attribute = getAttribute(node, attributeName)
if (
(key !== "href" && key !== "link") ||
!isExpressionJsVarLiteral(value)
) {
return
}
value.original.value = matchAndFixLinks( if (
value.original.value as string, !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 options
) )
value.original.raw = JSON.stringify(value.original.value) item.original.raw = JSON.stringify(item.original.value)
}) })
} }
switch (node.name) { switch (node.name) {
case "CardList": 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": case "Prerequisites":
const prerequisitesItemsAttribute = getAttribute(node, "items") attributeName = "items"
break
if ( case "Card":
!prerequisitesItemsAttribute?.value || attributeName = "href"
typeof prerequisitesItemsAttribute.value === "string" || break
!prerequisitesItemsAttribute.value.data?.estree case "WorkflowDiagram":
) { attributeName = "workflow"
return break
} case "TypeList":
attributeName = "types"
const prerequisitesJsVar = estreeToJs( break
prerequisitesItemsAttribute.value.data.estree
)
if (!prerequisitesJsVar) {
return
}
if (Array.isArray(prerequisitesJsVar)) {
prerequisitesJsVar.forEach(fixProperty)
} else {
fixProperty(prerequisitesJsVar)
}
return
} }
maybeCheckAttribute()
} }
const allowedComponentNames = [
"Card",
"CardList",
"Prerequisites",
"WorkflowDiagram",
"TypeList",
]
export function crossProjectLinksPlugin( export function crossProjectLinksPlugin(
options: CrossProjectLinksOptions options: CrossProjectLinksOptions
): Transformer { ): Transformer {
@@ -155,9 +126,7 @@ export function crossProjectLinksPlugin(
["element", "mdxJsxFlowElement"], ["element", "mdxJsxFlowElement"],
(node: UnistNode) => { (node: UnistNode) => {
const isComponent = const isComponent =
node.name === "Card" || node.name && allowedComponentNames.includes(node.name)
node.name === "CardList" ||
node.name === "Prerequisites"
const isLink = node.tagName === "a" && node.properties?.href const isLink = node.tagName === "a" && node.properties?.href
if (!isComponent && !isLink) { if (!isComponent && !isLink) {
return return
@@ -118,6 +118,16 @@ export declare type CrossProjectLinksOptions = {
useBaseUrl?: boolean useBaseUrl?: boolean
} }
export declare type BrokenLinkCheckerOptions = {
crossProjects: {
[k: string]: {
projectPath: string
contentPath?: string
hasGeneratedSlugs?: boolean
}
}
}
export declare type ComponentLinkFixerLinkType = "md" | "value" export declare type ComponentLinkFixerLinkType = "md" | "value"
export declare type ComponentLinkFixerOptions = { export declare type ComponentLinkFixerOptions = {
@@ -1,21 +1,13 @@
import path from "path" import path from "path"
import { Transformer } from "unified" import { Transformer } from "unified"
import { import { UnistNodeWithData, UnistTree } from "../types/index.js"
ComponentLinkFixerLinkType,
ExpressionJsVar,
UnistNodeWithData,
UnistTree,
} from "../types/index.js"
import { FixLinkOptions, fixLinkUtil } from "../index.js" import { FixLinkOptions, fixLinkUtil } from "../index.js"
import getAttribute from "../utils/get-attribute.js" import getAttribute from "../utils/get-attribute.js"
import { estreeToJs } from "../utils/estree-to-js.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 { 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 const VALUE_LINK_REGEX = /^(![a-z]+!|\.)/gm
function matchMdLinks( 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( export function componentLinkFixer(
componentName: string, componentName: string,
attributeName: string, attributeName: string,
@@ -117,12 +82,12 @@ export function componentLinkFixer(
return return
} }
const workflowAttribute = getAttribute(node, attributeName) const attribute = getAttribute(node, attributeName)
if ( if (
!workflowAttribute || !attribute ||
typeof workflowAttribute.value === "string" || typeof attribute.value === "string" ||
!workflowAttribute.value.data?.estree !attribute.value.data?.estree
) { ) {
return return
} }
@@ -132,13 +97,17 @@ export function componentLinkFixer(
appsPath, appsPath,
} }
const itemJsVar = estreeToJs(workflowAttribute.value.data.estree) const itemJsVar = estreeToJs(attribute.value.data.estree)
if (!itemJsVar) { if (!itemJsVar) {
return 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)
})
}
}
+5
View File
@@ -0,0 +1,5 @@
export type SlugChange = {
origSlug: string
newSlug: string
filePath: string
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./api-testing.js" export * from "./api-testing.js"
export * from "./build-scripts.js"
export * from "./config.js" export * from "./config.js"
export * from "./general.js" export * from "./general.js"
export * from "./menu.js" export * from "./menu.js"
+1
View File
@@ -6805,6 +6805,7 @@ __metadata:
react-transition-group: ^4.4.5 react-transition-group: ^4.4.5
rehype-mdx-code-props: ^3.0.1 rehype-mdx-code-props: ^3.0.1
rehype-slug: ^6.0.0 rehype-slug: ^6.0.0
remark-rehype-plugins: "*"
slugify: ^1.6.6 slugify: ^1.6.6
swr: ^2.2.0 swr: ^2.2.0
tailwind: "*" tailwind: "*"