diff --git a/.gitmodules b/.gitmodules index 5111f342d8..a4e47f19f9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,3 @@ -[submodule "content/13.x"] - path = content/13.x - url = https://github.com/gravitational/teleport - branch = branch/v13 [submodule "content/14.x"] path = content/14.x url = https://github.com/gravitational/teleport @@ -13,4 +9,8 @@ [submodule "content/16.x"] path = content/16.x url = https://github.com/gravitational/teleport + branch = branch/v16 +[submodule "content/17.x"] + path = content/17.x + url = https://github.com/gravitational/teleport branch = master diff --git a/.remarkrc.mjs b/.remarkrc.mjs index e93ec547cd..5d798247e0 100644 --- a/.remarkrc.mjs +++ b/.remarkrc.mjs @@ -4,6 +4,7 @@ import remarkIncludes from "./.build/server/remark-includes.mjs"; import remarkCodeSnippet from "./.build/server/remark-code-snippet.mjs"; import remarkLintDetails from "./.build/server/remark-lint-details.mjs"; import remarkLintFrontmatter from "./.build/server/remark-lint-frontmatter.mjs"; +import remarkTOC from "./.build/server/remark-toc.mjs"; import { remarkLintTeleportDocsLinks} from "./.build/server/lint-teleport-docs-links.mjs" import { getVersion, @@ -48,6 +49,8 @@ const configLint = { ["lint-ordered-list-marker-value", "single"], ["lint-maximum-heading-length", false], ["lint-no-shortcut-reference-link", false], + ["lint-no-file-name-irregular-characters", false], + [remarkTOC], [ remarkIncludes, // Lints (!include.ext!) syntax { diff --git a/.storybook/main.ts b/.storybook/main.ts index 290946945a..472f128627 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from "@storybook/nextjs"; const config: StorybookConfig = { - stories: ["../components/**/*.stories.@(js|jsx|ts|tsx)"], + stories: [ + "../components/**/*.stories.@(js|jsx|ts|tsx)", + "../layouts/**/*.stories.@(js|jsx|ts|tsx)", + ], addons: ["@storybook/addon-interactions", "@storybook/addon-viewport"], framework: { name: "@storybook/nextjs", diff --git a/config.json b/config.json index d2a46fe7d6..3c97a47a4a 100644 --- a/config.json +++ b/config.json @@ -57,7 +57,8 @@ }, { "name": "13.x", - "branch": "branch/v13" + "branch": "branch/v13", + "deprecated": true }, { "name": "14.x", @@ -65,11 +66,15 @@ }, { "name": "15.x", - "branch": "branch/v15", - "latest": true + "branch": "branch/v15" }, { "name": "16.x", + "branch": "branch/v16", + "latest": true + }, + { + "name": "17.x", "branch": "master" } ] diff --git a/content/13.x b/content/13.x deleted file mode 160000 index 96cbdac87b..0000000000 --- a/content/13.x +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 96cbdac87b73e9944072d4dc227f5480b1b66230 diff --git a/content/17.x b/content/17.x new file mode 160000 index 0000000000..a98b0430ad --- /dev/null +++ b/content/17.x @@ -0,0 +1 @@ +Subproject commit a98b0430add9e1cfdff68acf90960aef396d5822 diff --git a/docs-contributors/style-guide.md b/docs-contributors/style-guide.md index c79a5cb7bf..f3407d5d4c 100644 --- a/docs-contributors/style-guide.md +++ b/docs-contributors/style-guide.md @@ -60,7 +60,7 @@ In most cases, how-to guides contain the following sections: - Frontmatter with a guide title, description, and other information. The title should be short and identify the subject of the how-to topic in the fewest words possible. - The description should be a sentence that starts with a verb and summarizes the content of the topic. + The description should be a sentence that starts with a verb, summarizes the content of the topic, and ends with a period. Additional information might include a video banner link, a list of keywords, or an alternate first-level heading. @@ -138,7 +138,7 @@ Conceptual guides typically contain the following sections: - Frontmatter with a guide title, description, and other information. The title should be short and identify the subject of the how-to topic in the fewest words possible. - The description should be a sentence that starts with a verb and summarizes the content of the topic. + The description should be a sentence that starts with a verb, summarizes the content of the topic, and ends with a period. Additional information might include a list of keywords, or an alternate first-level heading. - Body paragraphs that explain that explain concepts, components, system operations, and context to help the reader understand what something is, why it's important, and how it works. @@ -164,7 +164,7 @@ Reference manuals typically contain the following sections: - Frontmatter with a guide title, description, and other information. The title should be short and identify the subject of the how-to topic in the fewest words possible. - The description should be a sentence that starts with a verb and summarizes the content of the topic. + The description should be a sentence that starts with a verb, summarizes the content of the topic, and ends with a period. Additional information might include a list of keywords. - One or more introductory paragraphs that explain what information the covers. - Formatted reference information. The format might resemble a `man` page or API description with a @@ -183,6 +183,20 @@ If a commonly debated style question does not have a resolution in this guide (e.g., the Oxford comma), all we ask is that you keep your style consistent within a particular page to maintain a professional polish. +### Use of frontend components + +In general, we want pages in the documentation to emphasize text and provide an +uncluttered experience to readers. Before adding a component besides a +paragraph, heading, or example code snippet, ask what benefit the component adds +to a page, and if it is possible to achieve a similar result with only +paragraphs, headings, and code snippets. + +For example, when adding a `Tabs` component, ask if it would make sense to add a +subheading instead of each `TabItem`. `TabItems` would be useful if only one +variation of the instructions you are adding is relevant to a reader, and the +other two would only add distraction. If all variations of the instructions are +useful, subheadings would make more sense. + ### Voice The documentation should be technically precise and directed toward a technical diff --git a/docs-contributors/ui-reference.md b/docs-contributors/ui-reference.md index 4055cae1c8..5a942e610b 100644 --- a/docs-contributors/ui-reference.md +++ b/docs-contributors/ui-reference.md @@ -338,6 +338,23 @@ Here is an image: When including the partial, the docs engine will rewrite the link path to load the image in `docs/img/screenshot.png`. +## Tables of Contents + +You can add a list of links to pages in the current directory by adding the +following line to a docs page: + +``` +(!toc!) +``` + +The docs engine replaces this line with a list of links to pages in the current +directory, using the title and description of each page to populate the link: + +``` +- [Page 1](page1.mdx): This is a description of Page 1. +- [Page 2](page2.mdx): This is a description of Page 2. +``` + ## Tabs To insert a tabs block like the one above, use this syntax: diff --git a/layouts/DocsPage/DocsPage.tsx b/layouts/DocsPage/DocsPage.tsx index c4e7f2b5d5..3ced583faf 100644 --- a/layouts/DocsPage/DocsPage.tsx +++ b/layouts/DocsPage/DocsPage.tsx @@ -67,6 +67,10 @@ const DocsPage = ({ let path = getPath(latest); + let urlCurrent = "/docs" + path; + // handles the case where it's the home page with / to avoid /docs/docs/ + if (path == "/") urlCurrent = "/"; + return ( <> This chapter covers a past release: {current}. We - recommend the latest{" "} + recommend the latest{" "} version instead. )} {isBetaVersion && ( <> This chapter covers an upcoming release: {current}. We - recommend the latest{" "} + recommend the latest{" "} version instead. )} diff --git a/layouts/DocsPage/Navigation.module.css b/layouts/DocsPage/Navigation.module.css index b3318fe14b..3d79d93ae6 100644 --- a/layouts/DocsPage/Navigation.module.css +++ b/layouts/DocsPage/Navigation.module.css @@ -107,12 +107,23 @@ display: block; } - & .link { - padding-left: var(--m-4); + & .link{ font-size: var(--fs-text-sm); line-height: var(--lh-md); } + & .link-1 { + padding-left: var(--m-4); + } + + & .link-2 { + padding-left: var(--m-5); + } + + & .link-3 { + padding-left: var(--m-6); + } + .link.active + & { display: block; } diff --git a/layouts/DocsPage/Navigation.stories.tsx b/layouts/DocsPage/Navigation.stories.tsx new file mode 100644 index 0000000000..cd3e40fc91 --- /dev/null +++ b/layouts/DocsPage/Navigation.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/testing-library"; +import { expect } from "@storybook/jest"; +import { default as DocNavigation } from "layouts/DocsPage/Navigation"; +import { NavigationCategory } from "./types"; + +export const NavigationFourLevels = () => { + const data = [ + { + icon: "cloud", + title: "Enroll Resources", + entries: [ + { + title: "Machine ID", + slug: "/enroll-resources/machine-id/", + entries: [ + { + title: "Deploy Machine ID", + slug: "/enroll-resources/machine-id/deployment/", + entries: [ + { + title: "Deploy Machine ID on AWS", + slug: "/enroll-resources/machine-id/deployment/aws/", + }, + ], + }, + ], + }, + ], + }, + ]; + + return ( + } + section={true} + currentVersion="16.x" + currentPathGetter={() => { + return "/enroll-resources/machine-id/deployment/aws/"; + }} + > + ); +}; + +const meta: Meta = { + title: "layouts/DocNavigation", + component: NavigationFourLevels, +}; +export default meta; diff --git a/layouts/DocsPage/Navigation.tsx b/layouts/DocsPage/Navigation.tsx index 07ad63b558..4de84d9678 100644 --- a/layouts/DocsPage/Navigation.tsx +++ b/layouts/DocsPage/Navigation.tsx @@ -24,14 +24,22 @@ const SCOPE_DICTIONARY: Record = { interface DocsNavigationItemsProps { entries: NavigationItem[]; onClick: () => void; + currentPath: string; + level?: number; } const DocsNavigationItems = ({ entries, onClick, + currentPath, + level, }: DocsNavigationItemsProps) => { - const docPath = useCurrentHref().split(SCOPELESS_HREF_REGEX)[0]; + const docPath = currentPath.split(SCOPELESS_HREF_REGEX)[0]; const { getVersionAgnosticRoute } = useVersionAgnosticPages(); + const maxLevel = 3; + if (!level) { + level = 1; + } return ( <> @@ -39,13 +47,15 @@ const DocsNavigationItems = ({ entries.map((entry) => { const selected = entry.slug === docPath; const active = - selected || entry.entries?.some((entry) => entry.slug === docPath); + selected || + entry.entries?.some((entry) => docPath.startsWith(entry.slug)); return (
  • )} - {!!entry.entries?.length && ( + {!!entry.entries?.length && level <= maxLevel && (
    )} @@ -77,6 +89,7 @@ interface DocNavigationCategoryProps extends NavigationCategory { opened: boolean; onToggleOpened: (value: number) => void; onClick: () => void; + currentPath: string; } const DocNavigationCategory = ({ @@ -87,6 +100,7 @@ const DocNavigationCategory = ({ icon, title, entries, + currentPath, }: DocNavigationCategoryProps) => { const toggleOpened = useCallback( () => onToggleOpened(opened ? null : id), @@ -105,7 +119,11 @@ const DocNavigationCategory = ({ {opened && (
      - +
    )} @@ -134,14 +152,19 @@ interface DocNavigationProps { section?: boolean; currentVersion?: string; data: NavigationCategory[]; + currentPathGetter?: () => string; } const DocNavigation = ({ data, section, currentVersion, + currentPathGetter, }: DocNavigationProps) => { - const route = useCurrentHref(); + if (!currentPathGetter) { + currentPathGetter = useCurrentHref; + } + const route = currentPathGetter(); const [openedId, setOpenedId] = useState( getCurrentCategoryIndex(data, route) @@ -171,6 +194,7 @@ const DocNavigation = ({ opened={index === openedId} onToggleOpened={setOpenedId} onClick={toggleMenu} + currentPath={route} {...props} />
  • diff --git a/layouts/DocsPage/types.ts b/layouts/DocsPage/types.ts index ccf33daf48..be29a372e1 100644 --- a/layouts/DocsPage/types.ts +++ b/layouts/DocsPage/types.ts @@ -43,6 +43,7 @@ export interface NavigationCategory { icon: IconName; title: string; entries: NavigationItem[]; + generateFrom?: string; } interface LinkWithRedirect { diff --git a/pages/404.tsx b/pages/404.tsx index 3d2574d95b..d5c7de32b5 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -1,14 +1,63 @@ // 404.js import Link from "next/link"; +import { sendPageNotFoundError } from "utils/posthog"; +import { useEffect } from "react"; +import SiteHeader from "components/Header"; +import Footer from "layouts/DocsPage/Footer"; export default function FourOhFour() { + useEffect(() => { + void sendPageNotFoundError(); // Report Error to PostHog for tracking + }, []); + return ( - <> -

    404 - Page Not Found

    - We're very sorry - we could not find the page you were looking for. - Please navigate to the Teleport Documentation or the - Home Page to find what you're looking for. - +
    + +
    +

    404 Page Not Found

    +

    Sorry, we couldn't find that page

    +

    + Go back to Documentation home? +

    +

    Other pages you may find useful

    +
      +
    • + Home Page +
    • +
    • + About Us +
    • +
    • + Blog +
    • +
    • + Customer Support +
    • +
    • + Documentation +
    • +
    • + Installation +
    • +
    • + Teleport Server Access +
    • +
    • + Teleport Kubernetes Access +
    • +
    • + Teleport Database Access +
    • +
    • + Teleport Desktop Access +
    • +
    • + Teleport Application Access +
    • +
    +
    +
    +
    ); } diff --git a/pages/_app.tsx b/pages/_app.tsx index 685a67aa3b..2bf0bbb491 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -55,6 +55,7 @@ export const lato = localLato({ import "styles/varaibles.css"; import "styles/global.css"; +const NEXT_PUBLIC_REDDIT_ID = process.env.NEXT_PUBLIC_REDDIT_ID; const NEXT_PUBLIC_GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; const NEXT_PUBLIC_GTAG_ID = process.env.NEXT_PUBLIC_GTAG_ID; const MUNCHKIN_ID = process.env.MUNCHKIN_ID; @@ -162,6 +163,17 @@ const Analytics = () => { {/* End Google Tag Manager (noscript) */} )} + + {NEXT_PUBLIC_REDDIT_ID && ( + <> + {/* Reddit Pixel */} + + {/* DO NOT MODIFY UNLESS TO REPLACE A USER IDENTIFIER /*} + {/* End Reddit Pixel */} + + )} ); }; diff --git a/public/favicon.ico b/public/favicon.ico index 0b2174ae08..c192f337b4 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index a980b4801b..aef65c71ee 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/server/config-docs.ts b/server/config-docs.ts index 9ef49c4608..83aea430bd 100644 --- a/server/config-docs.ts +++ b/server/config-docs.ts @@ -8,11 +8,12 @@ import type { Redirect } from "next/dist/lib/load-custom-routes"; import Ajv from "ajv"; import { validateConfig } from "./config-common"; -import { resolve, join } from "path"; -import { existsSync, readFileSync } from "fs"; +import { dirname, resolve, join } from "path"; +import fs from "fs"; import { isExternalLink, isHash, splitPath } from "../utils/url"; import { NavigationCategory, NavigationItem } from "../layouts/DocsPage/types"; import { loadConfig as loadSiteConfig } from "./config-site"; +import { generateNavPaths } from "./pages-helpers"; const { latest } = loadSiteConfig(); export interface Config { @@ -31,8 +32,8 @@ const getConfigPath = (version: string) => export const load = (version: string) => { const path = getConfigPath(version); - if (existsSync(path)) { - const content = readFileSync(path, "utf-8"); + if (fs.existsSync(path)) { + const content = fs.readFileSync(path, "utf-8"); return JSON.parse(content) as Config; } else { @@ -61,6 +62,8 @@ const validator = ajv.compile({ properties: { icon: { type: "string" }, title: { type: "string" }, + generateFrom: { type: "string" }, + // Entries must be empty if generateFrom is present. entries: { type: "array", items: { @@ -228,7 +231,7 @@ const correspondingFileExistsForURL = ( if ( [docsPagePath, indexPath, introPath].find((p) => { - return existsSync(p); + return fs.existsSync(p); }) == undefined ) { return false; @@ -304,8 +307,6 @@ export const normalize = (config: Config, version: string): Config => { return config; }; -/* Load, validate and normalize config. */ - export const loadConfig = (version: string) => { const config = load(version); @@ -327,5 +328,18 @@ export const loadConfig = (version: string) => { validateConfig(validator, config); + config.navigation.forEach((item, i) => { + if (!!item.generateFrom && item.entries.length > 0) { + throw "a navigation item cannot contain both generateFrom and entries"; + } + + if (!!item.generateFrom) { + config.navigation[i].entries = generateNavPaths( + fs, + join("content", version, "docs", "pages", item.generateFrom) + ); + } + }); + return normalize(config, version); }; diff --git a/server/fixtures/result/code-snippet-heredoc.mdx b/server/fixtures/result/code-snippet-heredoc.mdx index 755973932f..a7a9a41bff 100644 --- a/server/fixtures/result/code-snippet-heredoc.mdx +++ b/server/fixtures/result/code-snippet-heredoc.mdx @@ -67,6 +67,8 @@ +
    + Create role diff --git a/server/fixtures/toc/database-access/database-access.mdx b/server/fixtures/toc/database-access/database-access.mdx new file mode 100644 index 0000000000..b2d241c9e6 --- /dev/null +++ b/server/fixtures/toc/database-access/database-access.mdx @@ -0,0 +1,4 @@ +--- +title: Protect Databases with Teleport +description: Guides to protecting databases with Teleport. +--- diff --git a/server/fixtures/toc/database-access/mysql.mdx b/server/fixtures/toc/database-access/mysql.mdx new file mode 100644 index 0000000000..d4d3b4602e --- /dev/null +++ b/server/fixtures/toc/database-access/mysql.mdx @@ -0,0 +1,4 @@ +--- +title: Protect MySQL with Teleport +description: How to enroll your MySQL database with Teleport +--- diff --git a/server/fixtures/toc/database-access/postgres.mdx b/server/fixtures/toc/database-access/postgres.mdx new file mode 100644 index 0000000000..5b5c52a58a --- /dev/null +++ b/server/fixtures/toc/database-access/postgres.mdx @@ -0,0 +1,4 @@ +--- +title: Protect Postgres with Teleport +description: How to enroll Postgres with your Teleport cluster +--- diff --git a/server/fixtures/toc/database-access/source.mdx b/server/fixtures/toc/database-access/source.mdx new file mode 100644 index 0000000000..d89f98fd6c --- /dev/null +++ b/server/fixtures/toc/database-access/source.mdx @@ -0,0 +1,5 @@ +## Header + +Here is an intro. + +(!toc!) diff --git a/server/fixtures/toc/expected.mdx b/server/fixtures/toc/expected.mdx new file mode 100644 index 0000000000..9cbec5eee8 --- /dev/null +++ b/server/fixtures/toc/expected.mdx @@ -0,0 +1,7 @@ +## Header + +Here is an intro. + +* [Protect Databases with Teleport](database-access.mdx): Guides to protecting databases with Teleport. +* [Protect MySQL with Teleport](mysql.mdx): How to enroll your MySQL database with Teleport +* [Protect Postgres with Teleport](postgres.mdx): How to enroll Postgres with your Teleport cluster diff --git a/server/markdown-config.ts b/server/markdown-config.ts index 02c71a4eb9..053654667d 100644 --- a/server/markdown-config.ts +++ b/server/markdown-config.ts @@ -16,6 +16,7 @@ import remarkVariables from "./remark-variables"; import remarkCodeSnippet from "./remark-code-snippet"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; +import remarkTOC from "./remark-toc"; import remarkCopyLinkedFiles from "remark-copy-linked-files"; import rehypeImages from "./rehype-images"; import { getVersion, getVersionRootPath } from "./docs-helpers"; @@ -42,6 +43,8 @@ export const transformToAST = async (value: string, vfile: VFile) => { // run() will apply plugins and return modified AST const AST = await unified() + // Resolves (!toc dir/path!) syntax + .use(remarkTOC) .use(remarkIncludes, { rootDir: getVersionRootPath(vfile.path), }) // Resolves (!include.ext!) syntax diff --git a/server/pages-helpers.ts b/server/pages-helpers.ts index 00b652b4af..88f68f6f6e 100644 --- a/server/pages-helpers.ts +++ b/server/pages-helpers.ts @@ -4,9 +4,9 @@ import type { MDXPage, MDXPageData, MDXPageFrontmatter } from "./types-unist"; -import { resolve } from "path"; import { readSync } from "to-vfile"; import matter from "gray-matter"; +import { sep, parse, dirname, resolve, join } from "path"; export const extensions = ["md", "mdx", "ts", "tsx", "js", "jsx"]; @@ -61,3 +61,123 @@ export const getPageInfo = ( return result; }; + +// getEntryForPath returns a navigation item for the file at filePath in the +// given filesystem. +const getEntryForPath = (fs, filePath) => { + const txt = fs.readFileSync(filePath, "utf8"); + const { data } = matter(txt); + const slug = filePath.split("docs/pages")[1].replace(".mdx", "/"); + return { + title: data.title, + slug: slug, + }; +}; + +// sortByTitle takes two navigation entries, a and b, and sorts them in +// alphabetically ascending order by their "title" field. If either title +// includes the substring "introduction", sortByTitle sorts that entry first. +const sortByTitle = (a, b) => { + switch (true) { + case a.title.toLowerCase().includes("introduction"): + return -1; + break; + case b.title.toLowerCase().includes("introduction"): + return 1; + break; + default: + return a.title < b.title ? -1 : 1; + } +}; + +// categoryPagePathForDir looks for a category page at the same directory level +// as its associated directory OR within the associated directory. Throws an +// error if there is no category page for the directory. +const categoryPagePathForDir = (fs, dirPath) => { + const { name } = parse(dirPath); + + const outerCategoryPage = join(dirname(dirPath), name + ".mdx"); + const innerCategoryPage = join(dirPath, name + ".mdx"); + + if (fs.existsSync(outerCategoryPage)) { + return outerCategoryPage; + } + if (fs.existsSync(innerCategoryPage)) { + return innerCategoryPage; + } + throw new Error( + `subdirectory in generated sidebar section ${dirPath} has no category page ${innerCategoryPage} or ${outerCategoryPage}` + ); +}; + +export const navEntriesForDir = (fs, dirPath) => { + const firstLvl = fs.readdirSync(dirPath, "utf8"); + let result = []; + let firstLvlFiles = new Set(); + let firstLvlDirs = new Set(); + + // Sort the contents of dirPath into files and directoreis. + firstLvl.forEach((p) => { + const fullPath = join(dirPath, p); + const info = fs.statSync(fullPath); + if (info.isDirectory()) { + firstLvlDirs.add(fullPath); + return; + } + const fileName = parse(fullPath).name; + const dirName = parse(dirPath).name; + + // This is a category page for the containing directory. We would have + // already handled this in the previous iteration. The first iteration + // does not require a category page. + if (fileName == dirName) { + return; + } + + firstLvlFiles.add(fullPath); + }); + + // Map category pages to the directories they introduce so we can can add a + // sidebar entry for each category page, then traverse each directory to add + // further sidebar pages. + let sectionIntros = new Map(); + firstLvlDirs.forEach((d: string) => { + sectionIntros.set(categoryPagePathForDir(fs, d), d); + }); + + // Add files with no corresponding directory to the navigation first. Section + // introductions, by convention, have a filename that corresponds to the + // subdirectory containing pages in the section, or have the name + // "introduction.mdx". + firstLvlFiles.forEach((f: string) => { + // Handle section intros separately + if (sectionIntros.has(f)) { + return; + } + if (!f.endsWith(".mdx")) { + return; + } + result.push(getEntryForPath(fs, f)); + }); + + // Add a category page for each section intro, then traverse the contents of + // the directory that the category page introduces, adding the contents to + // entries. + sectionIntros.forEach((dirPath, categoryPagePath) => { + const { slug, title } = getEntryForPath(fs, categoryPagePath); + const section = { + title: title, + slug: slug, + entries: [], + }; + + section.entries = navEntriesForDir(fs, dirPath); + result.push(section); + }); + result.sort(sortByTitle); + return result; +}; + +export const generateNavPaths = (fs, dirPath) => { + return navEntriesForDir(fs, dirPath); +}; diff --git a/server/remark-toc.ts b/server/remark-toc.ts new file mode 100644 index 0000000000..d66e13936d --- /dev/null +++ b/server/remark-toc.ts @@ -0,0 +1,120 @@ +import * as nodeFS from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { visitParents } from "unist-util-visit-parents"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import type { Parent } from "unist"; +import type { VFile } from "vfile"; +import type { Content } from "mdast"; +import type { Transformer } from "unified"; + +// 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 +// to the directory where we are placing the table of contents page. +// @param root {string} - the directory path to the table of contents page. +// @param filepath {string} - the path from which to generate a link path. +const relativePathToFile = (root: string, filepath: string) => { + // Return the filepath without the first segment, removing the first + // slash. This is because the TOC file we are generating is located at + // root. + return filepath.slice(root.length).replace(/^\//, ""); +}; + +// getTOC generates a list of links to all files in the same directory as +// filePath except for filePath. The return value is an object with two +// properties: +// - result: a string containing the resulting list of links. +// - error: an error message encountered during processing +export const getTOC = (filePath: string, fs: any = nodeFS) => { + const dirPath = path.dirname(filePath); + if (!fs.existsSync(dirPath)) { + return { + error: `Cannot generate a table of contents for nonexistent directory at ${dirPath}`, + }; + } + + const { name } = path.parse(filePath); + + const files = fs.readdirSync(dirPath, "utf8"); + let mdxFiles = new Set(); + const dirs = files.reduce((accum, current) => { + // Don't add a TOC entry for the current file. + if (name == path.parse(current).name) { + return accum; + } + const stats = fs.statSync(path.join(dirPath, current)); + if (!stats.isDirectory() && current.endsWith(".mdx")) { + mdxFiles.add(path.join(dirPath, current)); + return accum; + } + accum.add(path.join(dirPath, current)); + return accum; + }, new Set()); + + // Add rows to the menu page for non-menu pages. + const entries = []; + mdxFiles.forEach((f: string, idx: number) => { + const text = fs.readFileSync(f, "utf8"); + let relPath = relativePathToFile(dirPath, f); + const { data } = matter(text); + entries.push(`- [${data.title}](${relPath}): ${data.description}`); + }); + + // Add rows to the menu page for first-level child menu pages + dirs.forEach((f: string, idx: number) => { + const menuPath = path.join(f, path.parse(f).base + ".mdx"); + if (!fs.existsSync(menuPath)) { + return { + error: `there must be a page called ${menuPath} that introduces ${f}`, + }; + } + const text = fs.readFileSync(menuPath, "utf8"); + let relPath = relativePathToFile(dirPath, menuPath); + const { data } = matter(text); + + entries.push(`- [${data.title}](${relPath}): ${data.description}`); + }); + entries.sort(); + return { result: entries.join("\n") }; +}; + +const tocRegexpPattern = "^\\(!toc!\\)$"; + +// remarkTOC replaces (!toc!) syntax in a page with a list of docs pages at a +// given directory location. +export default function remarkTOC(): Transformer { + return (root: Content, vfile: VFile) => { + const lastErrorIndex = vfile.messages.length; + + visitParents(root, (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; + } + + const tocExpr = node.value.trim().match(tocRegexpPattern); + if (!tocExpr) { + return; + } + + const { result, error } = getTOC(vfile.path); + if (!!error) { + vfile.message(error, node); + return; + } + const tree = fromMarkdown(result, {}); + + const grandParent = ancestors[ancestors.length - 2] as Parent; + const parentIndex = grandParent.children.indexOf(parent); + + grandParent.children.splice(parentIndex, 1, ...tree.children); + }); + }; +} diff --git a/utils/posthog.ts b/utils/posthog.ts index f1108289d2..80e096f7d2 100644 --- a/utils/posthog.ts +++ b/utils/posthog.ts @@ -16,7 +16,6 @@ export const posthog = async (): Promise => { return; } - if (PH_IS_ENABLED && PH_API_URL && PH_API_KEY) { posthogGlobal.init(PH_API_KEY, { api_host: PH_API_URL, @@ -41,6 +40,11 @@ export const sendPageview = async () => { ph?.capture("$pageview"); }; +export const sendPageNotFoundError = async () => { + const ph = await posthog(); + ph?.capture("web.errors.pageNotFound"); +}; + export const sendDocsFeedback = async (rating: string, comment: string) => { const ph = await posthog(); diff --git a/uvu-tests/config-docs.test.ts b/uvu-tests/config-docs.test.ts index 6fda3675ac..b7272875d6 100644 --- a/uvu-tests/config-docs.test.ts +++ b/uvu-tests/config-docs.test.ts @@ -2,9 +2,10 @@ import { Redirect } from "next/dist/lib/load-custom-routes"; import { suite } from "uvu"; import * as assert from "uvu/assert"; import { Config, checkURLsForCorrespondingFiles } from "../server/config-docs"; +import { generateNavPaths } from "../server/pages-helpers"; import { randomUUID } from "crypto"; import { join } from "path"; -import { opendirSync } from "fs"; +import { Volume, createFsFromVolume } from "memfs"; const Suite = suite("server/config-docs"); @@ -112,4 +113,325 @@ Suite("Ensures that URLs correspond to docs pages", () => { assert.equal(actual, expected); }); +Suite("generateNavPaths generates a sidebar from a file tree", () => { + const files = { + "/docs/pages/database-access/introduction.mdx": `--- +title: Protect Databases with Teleport +---`, + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/rbac/rbac.mdx": `--- +title: Database Access RBAC +---`, + "/docs/pages/database-access/rbac/get-started.mdx": `--- +title: Get Started with DB RBAC +---`, + "/docs/pages/database-access/rbac/reference.mdx": `--- +title: Database RBAC Reference +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "MySQL Guide", + slug: "/database-access/guides/mysql/", + }, + { + title: "Postgres Guide", + slug: "/database-access/guides/postgres/", + }, + ], + }, + { + title: "Database Access RBAC", + slug: "/database-access/rbac/rbac/", + entries: [ + { + title: "Database RBAC Reference", + slug: "/database-access/rbac/reference/", + }, + { + title: "Get Started with DB RBAC", + slug: "/database-access/rbac/get-started/", + }, + ], + }, + { + title: "Protect Databases with Teleport", + slug: "/database-access/introduction/", + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); +}); + +Suite( + "generateNavPaths alphabetizes second-level links except 'Introduction'", + () => { + const files = { + "/docs/pages/database-access/mongodb.mdx": `--- +title: MongoDB +---`, + "/docs/pages/database-access/azure-dbs.mdx": `--- +title: Azure +---`, + "/docs/pages/database-access/introduction.mdx": `--- +title: Introduction to Database Access +---`, + }; + + const expected = [ + { + title: "Introduction to Database Access", + slug: "/database-access/introduction/", + }, + { + title: "Azure", + slug: "/database-access/azure-dbs/", + }, + { + title: "MongoDB", + slug: "/database-access/mongodb/", + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); + } +); + +Suite( + "generateNavPaths alphabetizes third-level links except 'Introduction'", + () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/get-started.mdx": `--- +title: Introduction to Database RBAC +---`, + "/docs/pages/database-access/guides/reference.mdx": `--- +title: Database RBAC Reference +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Introduction to Database RBAC", + slug: "/database-access/guides/get-started/", + }, + { + title: "Database RBAC Reference", + slug: "/database-access/guides/reference/", + }, + { + title: "MySQL Guide", + slug: "/database-access/guides/mysql/", + }, + { + title: "Postgres Guide", + slug: "/database-access/guides/postgres/", + }, + ], + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); + } +); + +Suite( + "generateNavPaths throws if there is no category page in a subdirectory", + () => { + const files = { + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + }; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + assert.throws(() => { + generateNavPaths(fs, "/docs/pages/database-access"); + }, "database-access/guides/guides.mdx"); + } +); + +Suite("generateNavPaths shows third-level pages on the sidebar", () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/rbac/rbac.mdx": `--- +title: Database Access RBAC +---`, + "/docs/pages/database-access/guides/rbac/get-started.mdx": `--- +title: Get Started with DB RBAC +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Database Access RBAC", + slug: "/database-access/guides/rbac/rbac/", + entries: [ + { + title: "Get Started with DB RBAC", + slug: "/database-access/guides/rbac/get-started/", + }, + ], + }, + { + title: "MySQL Guide", + slug: "/database-access/guides/mysql/", + }, + { + title: "Postgres Guide", + slug: "/database-access/guides/postgres/", + }, + ], + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + const actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); +}); + +Suite( + "allows category pages in the same directory as the associated subdirectory", + () => { + const files = { + "/docs/pages/database-access/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/postgres.mdx": `--- +title: Postgres Guide +---`, + "/docs/pages/database-access/guides/mysql.mdx": `--- +title: MySQL Guide +---`, + "/docs/pages/database-access/guides/rbac.mdx": `--- +title: Database Access RBAC +---`, + "/docs/pages/database-access/guides/rbac/get-started.mdx": `--- +title: Get Started with DB RBAC +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/", + entries: [ + { + title: "Database Access RBAC", + slug: "/database-access/guides/rbac/", + entries: [ + { + title: "Get Started with DB RBAC", + slug: "/database-access/guides/rbac/get-started/", + }, + ], + }, + { + title: "MySQL Guide", + slug: "/database-access/guides/mysql/", + }, + { + title: "Postgres Guide", + slug: "/database-access/guides/postgres/", + }, + ], + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + let actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); + } +); + +Suite("generates four levels of the sidebar", () => { + const files = { + "/docs/pages/database-access/guides/guides.mdx": `--- +title: Database Access Guides +---`, + "/docs/pages/database-access/guides/deployment/kubernetes.mdx": `--- +title: Database Access Kubernetes Deployment +---`, + "/docs/pages/database-access/guides/deployment/deployment.mdx": `--- +title: Database Access Deployment Guides +---`, + }; + + const expected = [ + { + title: "Database Access Guides", + slug: "/database-access/guides/guides/", + entries: [ + { + title: "Database Access Deployment Guides", + slug: "/database-access/guides/deployment/deployment/", + entries: [ + { + title: "Database Access Kubernetes Deployment", + slug: "/database-access/guides/deployment/kubernetes/", + }, + ], + }, + ], + }, + ]; + + const vol = Volume.fromJSON(files); + const fs = createFsFromVolume(vol); + let actual = generateNavPaths(fs, "/docs/pages/database-access"); + assert.equal(actual, expected); +}); + Suite.run(); diff --git a/uvu-tests/remark-code-snippet.test.ts b/uvu-tests/remark-code-snippet.test.ts index d04ee351c0..1196104863 100644 --- a/uvu-tests/remark-code-snippet.test.ts +++ b/uvu-tests/remark-code-snippet.test.ts @@ -243,7 +243,7 @@ Suite("Variables in multiline command support", () => { assert.equal(result, expected); }); -Suite.only("Includes empty lines in example command output", () => { +Suite("Includes empty lines in example command output", () => { const value = readFileSync( resolve("server/fixtures/code-snippet-empty-line.mdx"), "utf-8" diff --git a/uvu-tests/remark-includes.test.ts b/uvu-tests/remark-includes.test.ts index 1a68a8eae2..ea3f651df5 100644 --- a/uvu-tests/remark-includes.test.ts +++ b/uvu-tests/remark-includes.test.ts @@ -590,21 +590,19 @@ boundary" section. } ); -Suite.only( - "Interprets anchor-only links correctly when loading partials", - () => { - const actual = transformer({ - value: `Here is the outer page. +Suite("Interprets anchor-only links correctly when loading partials", () => { + const actual = transformer({ + value: `Here is the outer page. (!anchor-links.mdx!) `, - path: "server/fixtures/mypage.mdx", - }).toString(); + path: "server/fixtures/mypage.mdx", + }).toString(); - assert.equal( - actual, - `Here is the outer page. + assert.equal( + actual, + `Here is the outer page. This is a [link to an anchor](#this-is-a-section). @@ -612,8 +610,7 @@ This is a [link to an anchor](#this-is-a-section). This is content within the section. ` - ); - } -); + ); +}); Suite.run(); diff --git a/uvu-tests/remark-toc.test.ts b/uvu-tests/remark-toc.test.ts new file mode 100644 index 0000000000..d0390f9be7 --- /dev/null +++ b/uvu-tests/remark-toc.test.ts @@ -0,0 +1,185 @@ +import { Volume, createFsFromVolume } from "memfs"; +import { default as remarkTOC, getTOC } from "../server/remark-toc"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +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"; +import { remark } from "remark"; + +const Suite = suite("server/remark-toc"); + +const testFilesTwoSections = { + "/docs/docs.mdx": `--- +title: "Documentation Home" +description: "Guides to setting up the product." +--- + +Guides to setting up the product. + +`, + "/docs/database-access/database-access.mdx": `--- +title: "Database Access" +description: Guides related to Database Access. +--- + +Guides related to Database Access. + +`, + "/docs/database-access/page1.mdx": `--- +title: "Database Access Page 1" +description: "Protecting DB 1 with Teleport" +---`, + "/docs/database-access/page2.mdx": `--- +title: "Database Access Page 2" +description: "Protecting DB 2 with Teleport" +---`, + "/docs/application-access/application-access.mdx": `--- +title: "Application Access" +description: "Guides related to Application Access" +--- + +Guides related to Application Access. + +`, + "/docs/application-access/page1.mdx": `--- +title: "Application Access Page 1" +description: "Protecting App 1 with Teleport" +---`, + "/docs/application-access/page2.mdx": `--- +title: "Application Access Page 2" +description: "Protecting App 2 with Teleport" +---`, +}; + +Suite("getTOC with one link to a directory", () => { + const expected = `- [Application Access](application-access/application-access.mdx): Guides related to Application Access`; + + const vol = Volume.fromJSON({ + "/docs/docs.mdx": `--- +title: Documentation Home +description: Guides for setting up the product. +--- + +Guides for setting up the product. + +`, + "/docs/application-access/application-access.mdx": `--- +title: "Application Access" +description: "Guides related to Application Access" +--- + +`, + "/docs/application-access/page1.mdx": `--- +title: "Application Access Page 1" +description: "Protecting App 1 with Teleport" +---`, + "/docs/application-access/page2.mdx": `--- +title: "Application Access Page 2" +description: "Protecting App 2 with Teleport" +---`, + }); + const fs = createFsFromVolume(vol); + const actual = getTOC("/docs/docs.mdx", fs); + assert.equal(actual.result, expected); +}); + +Suite("getTOC with multiple links to directories", () => { + const expected = `- [Application Access](application-access/application-access.mdx): Guides related to Application Access +- [Database Access](database-access/database-access.mdx): Guides related to Database Access.`; + + const vol = Volume.fromJSON(testFilesTwoSections); + const fs = createFsFromVolume(vol); + const actual = getTOC("/docs/docs.mdx", fs); + assert.equal(actual.result, expected); +}); + +Suite("getTOC orders sections correctly", () => { + const expected = `- [API Usage](api.mdx): Using the API. +- [Application Access](application-access/application-access.mdx): Guides related to Application Access +- [Desktop Access](desktop-access/desktop-access.mdx): Guides related to Desktop Access +- [Initial Setup](initial-setup.mdx): How to set up the product for the first time. +- [Kubernetes](kubernetes.mdx): A guide related to Kubernetes.`; + + const vol = Volume.fromJSON({ + "/docs/docs.mdx": `--- +title: Documentation Home +description: Guides to setting up the product. +--- + +Guides to setting up the product. + +`, + "/docs/desktop-access/desktop-access.mdx": `--- +title: "Desktop Access" +description: "Guides related to Desktop Access" +--- + +`, + + "/docs/application-access/application-access.mdx": `--- +title: "Application Access" +description: "Guides related to Application Access" +--- + +`, + "/docs/desktop-access/get-started.mdx": `--- +title: "Get Started" +description: "Get started with desktop access." +---`, + "/docs/application-access/page1.mdx": `--- +title: "Application Access Page 1" +description: "Protecting App 1 with Teleport" +---`, + "/docs/kubernetes.mdx": `--- +title: "Kubernetes" +description: "A guide related to Kubernetes." +---`, + + "/docs/initial-setup.mdx": `--- +title: "Initial Setup" +description: "How to set up the product for the first time." +---`, + "/docs/api.mdx": `--- +title: "API Usage" +description: "Using the API." +---`, + }); + const fs = createFsFromVolume(vol); + const actual = getTOC("/docs/docs.mdx", fs); + assert.equal(actual.result, expected); +}); + +const transformer = (vfileOptions: VFileOptions) => { + const file = new VFile(vfileOptions); + + return remark() + .use(remarkMdx) + .use(remarkGFM) + .use(remarkTOC) + .processSync(file); +}; + +Suite("replaces inclusion expressions", () => { + const sourcePath = "server/fixtures/toc/database-access/source.mdx"; + const value = readFileSync(resolve(sourcePath), "utf-8"); + + const result = transformer({ + value, + path: sourcePath, + }); + + const actual = result.toString(); + + const expected = readFileSync( + resolve("server/fixtures/toc/expected.mdx"), + "utf-8" + ); + + assert.equal(result.messages, []); + assert.equal(actual, expected); +}); + +Suite.run(); diff --git a/yarn.lock b/yarn.lock index cb7a056140..120db1739e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7710,11 +7710,11 @@ brace-expansion@^2.0.1: balanced-match "^1.0.0" braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" @@ -10511,10 +10511,10 @@ filesize@^10.0.8: resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.0.tgz#846f5cd8d16e073c5d6767651a8264f6149183cd" integrity sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ== -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1"