diff --git a/server/fixtures/toc/source.mdx b/server/fixtures/toc/source.mdx new file mode 100644 index 0000000000..f044330979 --- /dev/null +++ b/server/fixtures/toc/source.mdx @@ -0,0 +1,8 @@ +--- +title: My Page +description: Sample page for testing +--- + +Here is an intro. + +(!toc database-access!) diff --git a/server/remark-toc.ts b/server/remark-toc.ts index 5aa7487e20..a3199ba465 100644 --- a/server/remark-toc.ts +++ b/server/remark-toc.ts @@ -1,6 +1,7 @@ import * as nodeFS from "fs"; import * as path from "path"; import matter from "gray-matter"; +import { visitParents } from "unist-util-visit-parents"; // relativePathToFile takes a filepath and returns a path we can use in links // to the file in a table of contents page. The link path is a relative path @@ -72,3 +73,56 @@ export const resolveTOCIncludes = (dirPath: string, fs = nodeFS) => { entries.sort(); return entries.join("\n"); }; + +const tocRegexp = new RegExp(`^\(!toc ([^!]+)!)$`); + +export default function remarkIncludes({}: RemarkIncludesOptions = {}): Transformer { + return (root: Content, vfile: VFile) => { + const lastErrorIndex = vfile.messages.length; + + visitParents(root, [isInclude], (node, ancestors: Parent[]) => { + if (node.type !== "text") { + return; + } + const parent = ancestors[ancestors.length - 1]; + + if (parent.type !== "paragraph") { + return; + } + if (!parent.children || parent.children.length === 1) { + return; + } + + if (tocRegexp.test(node.value.trim())) { + return; + } + // TODO: rename resolveTocIncludes to reflect function signature (takes + // one dir path) + // TODO: call resolveTOCIncludes for each instance of the expression + // TODO: get dirPath + const { result, error } = resolveTOCIncludes(dirPath); + + const path = node.value.match(tocRegexp)[1]; + + if (resolve) { + if (path.split(" ")[0].match(/\.mdx?$/)) { + const tree = fromMarkdown(result, { + extensions: [mdxjs(), gfm(), frontmatter()], + mdastExtensions: [ + mdxFromMarkdown(), + gfmFromMarkdown(), + frontmatterFromMarkdown(["yaml"]), + ], + }); + + const grandParent = ancestors[ancestors.length - 2] as Parent; + const parentIndex = grandParent.children.indexOf(parent); + + grandParent.children.splice(parentIndex, 1, ...tree.children); + } else { + node.value = result; + } + } + }); + }; +} diff --git a/uvu-tests/remark-toc.test.ts b/uvu-tests/remark-toc.test.ts index 0d152d1e2a..e323be57d9 100644 --- a/uvu-tests/remark-toc.test.ts +++ b/uvu-tests/remark-toc.test.ts @@ -1,7 +1,10 @@ import { Volume, createFsFromVolume } from "memfs"; -import { resolveTOCIncludes } from "../server/remark-toc"; +import { remarkTOC, resolveTOCIncludes } from "../server/remark-toc"; import { suite } from "uvu"; import * as assert from "uvu/assert"; +import { VFile, VFileOptions } from "vfile"; +import remarkMdx from "remark-mdx"; +import remarkGFM from "remark-gfm"; const Suite = suite("server/remark-toc"); @@ -48,7 +51,7 @@ description: "Protecting App 2 with Teleport" ---`, }; -Suite("one link to a directory", () => { +Suite("resolveTOCIncludes with one link to a directory", () => { const expected = `- [Application Access](application-access/application-access.mdx) (section): Guides related to Application Access`; const vol = Volume.fromJSON({ @@ -80,7 +83,7 @@ description: "Protecting App 2 with Teleport" assert.equal(actual, expected); }); -Suite("multiple links to directories", () => { +Suite("resolveTOCIncludes with multiple links to directories", () => { const expected = `- [Application Access](application-access/application-access.mdx) (section): Guides related to Application Access - [Database Access](database-access/database-access.mdx) (section): Guides related to Database Access.`; @@ -91,7 +94,7 @@ Suite("multiple links to directories", () => { }); Suite( - `throws an error on a generated menu page that does not correspond to a subdirectory`, + `resolveTOCIncludes throws an error on a generated menu page that does not correspond to a subdirectory`, () => { const vol = Volume.fromJSON({ "/docs/docs.mdx": `--- @@ -125,7 +128,7 @@ description: "Guides related to JWTs" } ); -Suite.only("orders sections correctly", () => { +Suite.only("resolveTOCIncludes orders sections correctly", () => { const expected = `- [API Usage](api.mdx): Using the API. - [Application Access](application-access/application-access.mdx) (section): Guides related to Application Access - [Desktop Access](desktop-access/desktop-access.mdx) (section): Guides related to Desktop Access @@ -182,4 +185,33 @@ description: "Using the API." assert.equal(actual, expected); }); +const transformer = (vfileOptions: VFileOptions) => { + const file = new VFile(vfileOptions); + + return remark() + .use(remarkMdx) + .use(remarkGFM) + .use(remarkTOC) + .processSync(file); +}; + +Suite("Fixture match result on resolve", () => { + const value = readFileSync( + resolve("server/fixtures/toc/source.mdx"), + "utf-8" + ); + + const result = transformer({ + value, + path: "/content/4.0/docs/pages/filename.mdx", + }).toString(); + + const expected = readFileSync( + resolve("server/fixtures/toc/expected.mdx"), + "utf-8" + ); + + assert.equal(result, expected); +}); + Suite.run();