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"