diff --git a/package-lock.json b/package-lock.json index 77b921667..962f4411d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11023,6 +11023,12 @@ "is-obj": "^1.0.0" } }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + }, "download": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/download/-/download-6.2.5.tgz", diff --git a/package.json b/package.json index f036265a4..6883749bd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@testing-library/jest-dom": "^5.11.6", "@testing-library/react": "^11.2.2", + "dotenv": "^8.2.0", "eslint": "^7.18.0", "husky": "^4.3.8", "identity-obj-proxy": "^3.0.0", diff --git a/packages/docs-page/README.md b/packages/docs-page/README.md index 508dabf29..c00c65375 100644 --- a/packages/docs-page/README.md +++ b/packages/docs-page/README.md @@ -1,44 +1,47 @@ # DocsPage -The **DocsPage** component lets you create a Hashicorp branded docs page in NextJS projects using `next-mdx-remote`. This is a very highly abstracted component with slightly more involved usage since it renders an entire page. +The **DocsPage** component lets you create a Hashicorp branded docs page in NextJS projects using `next-mdx-remote`. This is a very highly abstracted component with slightly more involved usage since it renders an entire collection of pages. ## Example Usage -This component is intended to be used on an [optional catch-all route](https://nextjs.org/docs/routing/dynamic-routes#optional-catch-all-routes) page, like `pages/docs/[[slug]].mdx` - example source shown below: +This component is intended to be used on an [optional catch-all route](https://nextjs.org/docs/routing/dynamic-routes#optional-catch-all-routes) page, like `pages/docs/[[...page]].mdx` - example source shown below: -```js -import order from 'data/docs-navigation.js' +```jsx import DocsPage from '@hashicorp/react-docs-page' +// Imports below are only used server-side import { generateStaticPaths, generateStaticProps, } from '@hashicorp/react-docs-page/server' -const productName = 'Vault' -const productSlug = 'vault' -// this example is at `pages/docs/[[slug]].mdx` - if the path is different -// this 'subpath' prop should be adjusted to match -const subpath = 'docs' +// Set up DocsPage settings +const BASE_ROUTE = 'docs' +const NAV_DATA = 'data/docs-nav-data.json' +const CONTENT_DIR = 'content/docs' +const PRODUCT = { + name: 'Packer', + slug: 'packer', +} function DocsLayout(props) { return ( - + ) } export async function getStaticPaths() { - return generateStaticPaths(subpath) + const paths = await generateStaticPaths(NAV_DATA, CONTENT_DIR) + return { paths, fallback: false } } export async function getStaticProps({ params }) { - return generateStaticProps({ subpath, productName, params }) + const props = await generateStaticProps( + NAV_DATA, + CONTENT_DIR, + params, + PRODUCT + ) + return { props } } export default DocsLayout diff --git a/packages/docs-page/docs.mdx b/packages/docs-page/docs.mdx index 1ff5e3924..6503110df 100644 --- a/packages/docs-page/docs.mdx +++ b/packages/docs-page/docs.mdx @@ -7,59 +7,24 @@ We have a lot of docs sites, all of which render content in exactly the same way = 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n' + - '\n' + - 'function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n' + - '\n' + - '/* @jsxRuntime classic */\n' + - '\n' + - '/* @jsx mdx */\n' + - 'var layoutProps = {};\n' + - 'var MDXLayout = "wrapper";\n' + - '\n' + - 'function MDXContent(_ref) {\n' + - ' var components = _ref.components,\n' + - ' props = _objectWithoutProperties(_ref, ["components"]);\n' + - '\n' + - ' return mdx(MDXLayout, _extends({}, layoutProps, props, {\n' + - ' components: components,\n' + - ' mdxType: "MDXLayout"\n' + - ' }), mdx("h1", { className: "g-type-display-2" }, "Example Page"), mdx("p", null, "This is a cool docs page!"));\n' + - '}\n' + - '\n' + - ';\n' + - 'MDXContent.isMDXComponent = true;', - renderedOutput: - '

Example Page

This is a cool docs page!

', - scope: {}, + mdxSource: componentProps.staticProps.properties.mdxSource.testValue, + frontMatter: { + page_title: + componentProps.staticProps.properties.frontMatter.properties + .page_title.testValue, + description: + componentProps.staticProps.properties.frontMatter.properties + .description.testValue, }, - data: [ - { - __resourcePath: 'docs/test.mdx', - page_title: 'Testing Page', - sidebar_title: 'Testing Page', - }, - { - __resourcePath: 'docs/test2.mdx', - page_title: 'Other Testing Page', - sidebar_title: 'Other Testing Page', - }, - ], - frontMatter: { page_title: 'Test Page', description: 'test description' }, - pagePath: '/docs/test', - filePath: 'test.mdx', + githubFileUrl: + componentProps.staticProps.properties.githubFileUrl.testValue, + currentPath: componentProps.staticProps.properties.currentPath.testValue, + navData: componentProps.staticProps.properties.navData.testValue, }, }} >{` diff --git a/packages/docs-page/index.js b/packages/docs-page/index.js index 47d2b3081..eb4acb21c 100644 --- a/packages/docs-page/index.js +++ b/packages/docs-page/index.js @@ -3,7 +3,6 @@ import Content from '@hashicorp/react-content' import DocsSidenav from '@hashicorp/react-docs-sidenav' import HashiHead from '@hashicorp/react-head' import Head from 'next/head' -import Link from 'next/link' import hydrate from 'next-mdx-remote/hydrate' import { SearchProvider } from '@hashicorp/react-search' import SearchBar from './search-bar' @@ -11,18 +10,16 @@ import generateComponents from './components' import temporary_injectJumpToSection from './temporary_jump-to-section' export function DocsPageWrapper({ - allPageData, canonicalUrl, children, description, - filePath, - mainBranch = 'main', - order, - pagePath, + navData, + currentPath, pageTitle, + baseRoute, + githubFileUrl, product: { name, slug }, showEditPage = true, - subpath, }) { // TEMPORARY (https://app.asana.com/0/1100423001970639/1160656182754009) // activates the "jump to section" feature @@ -49,11 +46,9 @@ export function DocsPageWrapper({
@@ -75,9 +70,7 @@ export function DocsPageWrapper({ {/* if desired, show an "edit this page" link on the bottom right, linking to github */} {showEditPage && (
- + github logo Edit this page @@ -89,12 +82,10 @@ export function DocsPageWrapper({ export default function DocsPage({ product, - subpath, - order, - mainBranch = 'main', + baseRoute, showEditPage = true, additionalComponents, - staticProps: { mdxSource, data, frontMatter, pagePath, filePath }, + staticProps: { mdxSource, frontMatter, currentPath, navData, githubFileUrl }, }) { // This component is written to work with next-mdx-remote -- here it hydrates the content const content = hydrate(mdxSource, { @@ -103,17 +94,15 @@ export default function DocsPage({ return ( {content} diff --git a/packages/docs-page/index.test.js b/packages/docs-page/index.test.js index 1336a0c3e..b59aaaac0 100644 --- a/packages/docs-page/index.test.js +++ b/packages/docs-page/index.test.js @@ -1,16 +1,130 @@ -test.todo( - 'passes `title`, `description`, and `siteName` correctly to ' -) -test.todo( - 'passes `product`, `category`, `currentPage`, `data`, and `order` correctly to ' -) -test.todo('passes `product` and `content` correctly to ') -test.todo('displays `showEditPage` as true by default') -test.todo('renders the `mainBranch` correctly within the edit page link') -test.todo('if `showEditPage` is set to false, does not display') -test.todo( - 'passes `additionalComponents` to mdx remote for rendering if present' -) -test.todo( - 'initializes jump to section UI if there are more than one `h2`s in the content' -) +require('dotenv').config() +// import 'regenerator-runtime/runtime' +import { render, screen } from '@testing-library/react' +// import expectThrow from '../../__test-helpers/expect-throw' +import DocsPage from './' +import props from './props' +import { getTestValues } from 'swingset/testing' +import renderPageMdx from './render-page-mdx' + +const defaultProps = getTestValues(props) + +// Mocking next/head makes it easier to confirm +// that we're passing stuff to +jest.mock('next/head', () => { + return { + __esModule: true, + default: function HeadMock({ children }) { + return <>{children} + }, + } +}) + +describe('', () => { + it('passes `title`, `description`, and `siteName` correctly to ', () => { + render() + // title renders correctly + expect(document.title).toBe(`Test Page | Terraform by HashiCorp`) + // description renders correctly + const description = Array.prototype.slice + .call(document.getElementsByTagName('meta')) + .filter((tag) => tag.getAttribute('name') === 'description')[0] + .getAttribute('content') + expect(description).toBe(`Test description`) + // siteName renders correctly + const site_name = Array.prototype.slice + .call(document.getElementsByTagName('meta')) + .filter((tag) => tag.getAttribute('property') === 'og:site_name')[0] + .getAttribute('content') + expect(site_name).toBe(`Terraform by HashiCorp`) + }) + + it('passes props correctly to ', () => { + render() + // Confirm `product` is passed via document title + expect(document.title).toBe(`Test Page | Terraform by HashiCorp`) + // Confirm `baseRoute` and `navData` by checking for a rendered link + const activeLeaf = screen.getByText('AWS').closest('a') + expect(activeLeaf.getAttribute('href')).toBe( + '/docs/agent/autoauth/methods/aws' + ) + // Confirm `currentPath` by ensuring a link is marked as active + expect(activeLeaf.getAttribute('data-is-active')).toBe('true') + }) + + it('passes `product` and `content` correctly to ', () => { + render() + // Confirm `content` is being rendered + const contentParagraph = screen.getByText('This is a cool docs page!') + expect(contentParagraph.tagName).toBe('P') + // Confirm `product` is passed via class + const contentContainer = contentParagraph.closest('article') + expect(contentContainer.className).toContain('terraform') + }) + + it('displays `showEditPage` as true by default, and renders `mainBranch` in the link', () => { + render() + const expectedHref = + 'https://github.com/hashicorp/vault/blob/master/website/content/docs/agent/autoauth/methods/aws.mdx' + const editPageLink = screen.getByText('Edit this page').closest('a') + expect(editPageLink.getAttribute('href')).toBe(expectedHref) + }) + + it('if `showEditPage` is set to false, does not display', () => { + render() + const editPageLink = screen.queryByText('Edit this page') + expect(editPageLink).toBeNull() + }) + + it('passes `additionalComponents` to mdx remote for rendering if present', async () => { + function CustomComponent() { + return Text in custom component + } + const additionalComponents = { CustomComponent } + const { + mdxSource, + frontMatter, + } = await renderPageMdx( + "## Heading Two\n\nHere's a paragraph of content.\n\n", + { productName: 'Terraform', additionalComponents } + ) + render( + + ) + // Find the text rendered by the custom component + const customComponentElem = screen.getByText('Text in custom component') + expect(customComponentElem.tagName).toBe('STRONG') + }) + + it('initializes jump to section UI if there is an h1 and two or more h2s', async () => { + const { + mdxSource, + frontMatter, + } = await renderPageMdx( + "---\n\npage_title: Test Title\ndescription: Test description\n---\n\n# Heading One\n\nAn intro paragraph.\n\n## Heading Two\n\nHere's a paragraph of content.\n\n## Here a second heading\n\nAnd another paragraph.", + { productName: 'Terraform' } + ) + render( + + ) + const jumpToSectionElem = screen.getByText('Jump to Section') + expect(jumpToSectionElem.tagName).toBe('SPAN') + }) +}) diff --git a/packages/docs-page/package-lock.json b/packages/docs-page/package-lock.json index 289a0cb4a..68c38a358 100644 --- a/packages/docs-page/package-lock.json +++ b/packages/docs-page/package-lock.json @@ -1,126 +1,150 @@ { - "name": "@hashicorp/react-docs-page", - "version": "10.9.3", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "requires": { - "is-extendable": "^0.1.0" - } - }, - "fast-equals": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-1.6.3.tgz", - "integrity": "sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==" - }, - "fast-stringify": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-stringify/-/fast-stringify-1.1.2.tgz", - "integrity": "sha512-SfslXjiH8km0WnRiuPfpUKwlZjW5I878qsOm+2x8x3TgqmElOOLh1rgJFb+PolNdNRK3r8urEefqx0wt7vx1dA==" - }, - "fs-exists-sync": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", - "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=" - }, - "gray-matter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.2.tgz", - "integrity": "sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==", - "requires": { - "js-yaml": "^3.11.0", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - } - }, - "is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" - }, - "js-yaml": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", - "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" - }, - "line-reader": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/line-reader/-/line-reader-0.4.0.tgz", - "integrity": "sha1-F+RIGNoKwzVnW6MAlU+U72cOZv0=" - }, - "micro-memoize": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-2.1.2.tgz", - "integrity": "sha512-COjNutiFgnDHXZEIM/jYuZPwq2h8zMUeScf6Sh6so98a+REqdlpaNS7Cb2ffGfK5I+xfgoA3Rx49NGuNJTJq3w==" - }, - "moize": { - "version": "5.4.7", - "resolved": "https://registry.npmjs.org/moize/-/moize-5.4.7.tgz", - "integrity": "sha512-7PZH8QFJ51cIVtDv7wfUREBd3gL59JB0v/ARA3RI9zkSRa9LyGjS1Bdldii2J1/NQXRQ/3OOVOSdnZrCcVaZlw==", - "requires": { - "fast-equals": "^1.6.0", - "fast-stringify": "^1.1.0", - "micro-memoize": "^2.1.1" - } - }, - "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" - }, - "readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "requires": { - "picomatch": "^2.2.1" - } - }, - "section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "requires": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" - }, - "strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" - } - } + "name": "@hashicorp/react-docs-page", + "version": "10.9.3", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@hashicorp/react-content": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@hashicorp/react-content/-/react-content-6.3.0.tgz", + "integrity": "sha512-B+QMlkMGryeNx3dGON4ExbzNvvll2ZXN3x+TkX80tUGClMI80MKjfSXiXIoVixlp22DMNG6wrnL42LC4WzZOxg==" + }, + "@hashicorp/react-docs-sidenav": { + "version": "6.1.1-alpha.60", + "resolved": "https://registry.npmjs.org/@hashicorp/react-docs-sidenav/-/react-docs-sidenav-6.1.1-alpha.60.tgz", + "integrity": "sha512-mfR6Wl4R4k5KD7lJpl0l4pn4NecmoqV6HxQ+nvMW/d6nIkb6/xWxynmDkEeiyEVpphLKqJFh6boWuFYaar7PbA==", + "requires": { + "@hashicorp/react-link-wrap": "^2.0.2", + "fuzzysearch": "1.0.3" + } + }, + "@hashicorp/react-link-wrap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@hashicorp/react-link-wrap/-/react-link-wrap-2.0.2.tgz", + "integrity": "sha512-q8s2TTd9Uy3BSYyUe2TTr2Kbc0ViRc7XQga2fZI0bzlFqBTiMXtf6gh2cg3QvimHY42y4YtaO5C109V9ahMUpQ==" + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "fast-equals": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-1.6.3.tgz", + "integrity": "sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==" + }, + "fast-stringify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-stringify/-/fast-stringify-1.1.2.tgz", + "integrity": "sha512-SfslXjiH8km0WnRiuPfpUKwlZjW5I878qsOm+2x8x3TgqmElOOLh1rgJFb+PolNdNRK3r8urEefqx0wt7vx1dA==" + }, + "fs-exists-sync": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", + "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=" + }, + "fuzzysearch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fuzzysearch/-/fuzzysearch-1.0.3.tgz", + "integrity": "sha1-3/yA9tawQiPyImqnndGUIxCW0Ag=" + }, + "gray-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.2.tgz", + "integrity": "sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==", + "requires": { + "js-yaml": "^3.11.0", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + }, + "line-reader": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/line-reader/-/line-reader-0.4.0.tgz", + "integrity": "sha1-F+RIGNoKwzVnW6MAlU+U72cOZv0=" + }, + "micro-memoize": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-2.1.2.tgz", + "integrity": "sha512-COjNutiFgnDHXZEIM/jYuZPwq2h8zMUeScf6Sh6so98a+REqdlpaNS7Cb2ffGfK5I+xfgoA3Rx49NGuNJTJq3w==" + }, + "moize": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/moize/-/moize-5.4.7.tgz", + "integrity": "sha512-7PZH8QFJ51cIVtDv7wfUREBd3gL59JB0v/ARA3RI9zkSRa9LyGjS1Bdldii2J1/NQXRQ/3OOVOSdnZrCcVaZlw==", + "requires": { + "fast-equals": "^1.6.0", + "fast-stringify": "^1.1.0", + "micro-memoize": "^2.1.1" + } + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" + } + } } diff --git a/packages/docs-page/package.json b/packages/docs-page/package.json index a81522de0..fed1af4a7 100644 --- a/packages/docs-page/package.json +++ b/packages/docs-page/package.json @@ -8,7 +8,7 @@ ], "dependencies": { "@hashicorp/react-content": "^6.3.0", - "@hashicorp/react-docs-sidenav": "^6.1.0", + "@hashicorp/react-docs-sidenav": "6.1.1-alpha.60", "@hashicorp/react-head": "^1.2.0", "@hashicorp/react-search": "^4.1.0", "fs-exists-sync": "0.1.0", diff --git a/packages/docs-page/props.js b/packages/docs-page/props.js index a64e6a295..5598aa272 100644 --- a/packages/docs-page/props.js +++ b/packages/docs-page/props.js @@ -1,60 +1,121 @@ +const docsSidenavProps = require('../docs-sidenav/props') +const sharedProps = require('../../props') + module.exports = { product: { type: 'string', - description: 'Name and slug of the product this page is being rendered for', + required: true, + description: + 'The `name` and `slug` of the product this page is being rendered for. The `slug` is used for the `Edit this page` link.', properties: { name: { type: 'string', - description: 'Human-readable proper case product name', - }, - slug: { - type: 'string', - description: 'HashiCorp product slug', - control: { type: 'select' }, - options: [ - 'hashicorp', - 'boundary', - 'consul', - 'nomad', - 'packer', - 'terraform', - 'vault', - 'vagrant', - 'waypoint', - ], + required: true, + description: + 'Human-readable proper case product name. Used for the page `` and `og:site_name`.', }, + slug: sharedProps.product, }, + testValue: { name: 'Terraform', slug: sharedProps.product.testValue }, }, - subpath: { + baseRoute: { type: 'string', + required: true, description: - 'The path this page is rendering under, for example "docs" or "api-docs". Passed directly to the `category` prop of `@hashicorp/react-docs-sidenav`', - }, - order: { - type: 'object', - description: - 'Pass in the export of a `data/xxx-navigation.js` file, this is the user-defined navigation order and structure. Passed directly to the `order` prop to `@hashicorp/react-docs-sidenav` - see that component for details on object structure.', - }, - additionalComponents: { - type: 'object', - description: - 'Object containing additional components to be made available within mdx pages. Uses the format { [key]: Component }, for example, `{ TestComponent: () => <p>hello world</p> }`', + 'The path this page is rendering under, for example `"docs"` or `"api-docs"`. Passed directly to the `baseRoute` prop of `@hashicorp/react-docs-sidenav`.', + testValue: 'docs', }, showEditPage: { type: 'boolean', description: - 'if true, an "edit this page" link will appear on the bottom right', + 'If `true`, an `Edit this page` link will appear on the bottom right of each page.', default: true, }, - mainBranch: { - type: 'string', + additionalComponents: { + type: 'object', description: - 'The default branch of the project being documented, typically either "master" or "main". Used for the `showEditPage` prop', - default: 'main', + 'Object containing additional components to be made available within mdx pages. Uses the format `{ [key]: Component }`, for example, `{ TestComponent: () => <p>hello world</p> }`', }, staticProps: { type: 'object', + required: true, description: 'Directly pass the return value of `server/generateStaticProps` in here.', + properties: { + githubFileUrl: { + type: 'string', + description: + "A link to the page's associated `.mdx` file on GitHub. Used for the `Edit this page` link.", + testValue: `https://github.com/hashicorp/vault/blob/master/website/content/docs/agent/autoauth/methods/aws.mdx`, + }, + mdxSource: { + type: 'object', + description: + "Data returned from running `next-mdx-remote/render-to-string` on the page's `.mdx` file contents.", + required: true, + testValue: { + compiledSource: + '"use strict";\n' + + '\n' + + 'function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n' + + '\n' + + 'function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n' + + '\n' + + 'function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n' + + '\n' + + '/* @jsxRuntime classic */\n' + + '\n' + + '/* @jsx mdx */\n' + + 'var layoutProps = {};\n' + + 'var MDXLayout = "wrapper";\n' + + '\n' + + 'function MDXContent(_ref) {\n' + + ' var components = _ref.components,\n' + + ' props = _objectWithoutProperties(_ref, ["components"]);\n' + + '\n' + + ' return mdx(MDXLayout, _extends({}, layoutProps, props, {\n' + + ' components: components,\n' + + ' mdxType: "MDXLayout"\n' + + ' }), mdx("h1", { className: "g-type-display-2" }, "Example Page"), mdx("p", null, "This is a cool docs page!"));\n' + + '}\n' + + '\n' + + ';\n' + + 'MDXContent.isMDXComponent = true;', + renderedOutput: + '<h1 className="g-type-display-2">Example Page</h1><p>This is a cool docs page!</p>', + scope: {}, + }, + }, + frontMatter: { + type: 'object', + required: true, + description: "Frontmatter object parsed from the page's `.mdx` file.", + properties: { + canonical_url: { + type: 'string', + description: + 'Optional canonical URL. Passed directly to [@hashicorp/react-head](/?component=Head).', + }, + description: { + type: 'string', + description: + 'Used for the `<meta name="description" />`. Passed directly to [@hashicorp/react-head](/?component=Head).', + required: true, + testValue: 'Test description', + }, + page_title: { + type: 'string', + description: + 'Used to construct the meta `<title />` tag, then passed to [@hashicorp/react-head](/?component=Head).', + required: true, + testValue: 'Test Page', + }, + }, + testValue: {}, + }, + currentPath: docsSidenavProps.currentPath, + navData: docsSidenavProps.navData, + }, + testValue: {}, }, } diff --git a/packages/docs-page/render-page-mdx.js b/packages/docs-page/render-page-mdx.js new file mode 100644 index 000000000..03c960dc0 --- /dev/null +++ b/packages/docs-page/render-page-mdx.js @@ -0,0 +1,31 @@ +import path from 'path' +import renderToString from 'next-mdx-remote/render-to-string' +import markdownDefaults from '@hashicorp/nextjs-scripts/markdown' +import generateComponents from './components' +import grayMatter from 'gray-matter' + +async function renderPageMdx( + mdxFileString, + { + productName, + mdxContentHook = (c) => c, + additionalComponents = {}, + remarkPlugins = [], + scope, + } = {} +) { + const components = generateComponents(productName, additionalComponents) + const { data: frontMatter, content: rawContent } = grayMatter(mdxFileString) + const content = mdxContentHook(rawContent) + const mdxSource = await renderToString(content, { + mdxOptions: markdownDefaults({ + resolveIncludes: path.join(process.cwd(), 'content/partials'), + addRemarkPlugins: remarkPlugins, + }), + components, + scope, + }) + return { mdxSource, frontMatter } +} + +export default renderPageMdx diff --git a/packages/docs-page/server.js b/packages/docs-page/server.js index b3edfa949..1922eeab5 100644 --- a/packages/docs-page/server.js +++ b/packages/docs-page/server.js @@ -1,161 +1,158 @@ import fs from 'fs' import path from 'path' -import existsSync from 'fs-exists-sync' -import readdirp from 'readdirp' -import lineReader from 'line-reader' -import moize from 'moize' -import matter from 'gray-matter' -import { safeLoad } from 'js-yaml' -import renderToString from 'next-mdx-remote/render-to-string' -import markdownDefaults from '@hashicorp/nextjs-scripts/markdown' -import generateComponents from './components' +import validateFilePaths from '@hashicorp/react-docs-sidenav/utils/validate-file-paths' +import validateRouteStructure from '@hashicorp/react-docs-sidenav/utils/validate-route-structure' +import renderPageMdx from './render-page-mdx' -export async function generateStaticPaths(subpath) { - const paths = await getStaticMdxPaths( - path.join(process.cwd(), 'content', subpath) - ) +// So far, we have a pattern of using a common value for +// docs catchall route parameters: route/[[...page]].jsx. +// This default parameter ID captures that pattern. +// It can be overridden via options. +const DEFAULT_PARAM_ID = 'page' - return { paths, fallback: false } +async function generateStaticPaths( + navDataFile, + localContentDir, + { paramId = DEFAULT_PARAM_ID } = {} +) { + // Fetch and parse navigation data + const navData = await resolveNavData(navDataFile, localContentDir) + const paths = getPathsFromNavData(navData, paramId) + return paths } -export async function generateStaticProps({ - subpath, - productName, - params, - additionalComponents, - scope, - remarkPlugins, -}) { - const docsPath = path.join(process.cwd(), 'content', subpath) - const pagePath = params.page ? params.page.join('/') : '/' +async function resolveNavData(filePath, localContentDir) { + const navDataFile = path.join(process.cwd(), filePath) + const navDataRaw = JSON.parse(fs.readFileSync(navDataFile, 'utf8')) + const withFilePaths = await validateFilePaths(navDataRaw, localContentDir) + return withFilePaths +} - // get frontmatter from all other pages in the category, for the sidebar - const allFrontMatter = await fastReadFrontMatter(docsPath) +async function getPathsFromNavData( + navDataResolved, + paramId = DEFAULT_PARAM_ID +) { + // Transform navigation data into path arrays + const pagePathArrays = getPathArraysFromNodes(navDataResolved) + // Include an empty array for the "/" index page path + const allPathArrays = [[]].concat(pagePathArrays) + const paths = allPathArrays.map((p) => ({ params: { [paramId]: p } })) + return paths +} - // render the current page path markdown - const { mdxSource, frontMatter, filePath } = await renderPageMdx( - docsPath, - pagePath, - generateComponents(productName, additionalComponents), +async function generateStaticProps( + navDataFile, + localContentDir, + params, + product, + { + additionalComponents = {}, + mainBranch = 'main', + remarkPlugins = [], + scope, // optional, I think? + paramId = DEFAULT_PARAM_ID, + } = {} +) { + // Read in the nav data, and resolve local filePaths + const navData = await resolveNavData(navDataFile, localContentDir) + // Build the currentPath from page parameters + const currentPath = params[paramId] ? params[paramId].join('/') : '' + // Get the navNode that matches this path + const navNode = getNodeFromPath(currentPath, navData, localContentDir) + // Read in and process MDX content from the navNode's filePath + const mdxFile = path.join(process.cwd(), navNode.filePath) + const mdxString = fs.readFileSync(mdxFile, 'utf8') + const { mdxSource, frontMatter } = await renderPageMdx(mdxString, { + productName: product.name, + additionalComponents, + remarkPlugins, scope, - remarkPlugins - ) - - return { - props: { - data: allFrontMatter.map((p) => ({ - ...p, - __resourcePath: `${subpath}/${p.__resourcePath}`, - })), - mdxSource, - frontMatter, - filePath: `${subpath}/${filePath}`, - pagePath: `/${subpath}/${pagePath}`, - }, - } + }) + // Construct the githubFileUrl, used for "Edit this page" link + const githubFileUrl = `https://github.com/hashicorp/${product.slug}/blob/${mainBranch}/website/${navNode.filePath}` + // Return all the props + return { currentPath, frontMatter, githubFileUrl, mdxSource, navData } } -async function getStaticMdxPaths(root) { - const files = await readdirp.promise(root, { fileFilter: ['*.mdx'] }) +async function validateNavData(navData, localContentDir) { + const withFilePaths = await validateFilePaths(navData, localContentDir) + // Note: validateRouteStructure returns navData with additional __stack properties, + // which detail the path we've inferred for each branch and node + // (branches do not have paths defined explicitly, so we need to infer them) + // We don't actually need the __stack properties for rendering, they're just + // used in validation, so we don't use the output of this function. + validateRouteStructure(withFilePaths) + // Return the resolved, validated navData + return withFilePaths +} - return files.map(({ path: p }) => { +function getNodeFromPath(pathToMatch, navData, localContentDir) { + // If there is no path array, we return a + // constructed "home page" node. This is just to + // provide authoring convenience to not have to define + // this node. However, we could ask for this node to + // be explicitly defined in `navData` (and if it isn't, + // then we'd render a 404 for the root path) + const isLandingPage = pathToMatch === '' + if (isLandingPage) { return { - params: { - page: p - .replace(/\.mdx$/, '') - .split('/') - .filter((p) => p !== 'index'), - }, + filePath: path.join(localContentDir, 'index.mdx'), } - }) -} - -async function renderPageMdx( - root, - pagePath, - components, - scope, - remarkPlugins = [] -) { - // get the page being requested - figure out if its index page or leaf - // prefer leaf if both are present - const leafPath = path.join(root, `${pagePath}.mdx`) - const indexPath = path.join(root, `${pagePath}/index.mdx`) - let page, filePath - - if (existsSync(leafPath)) { - page = fs.readFileSync(leafPath, 'utf8') - filePath = leafPath - } else if (existsSync(indexPath)) { - page = fs.readFileSync(indexPath, 'utf8') - filePath = indexPath - } else { - // NOTE: if we decide to let docs pages render dynamically, we should replace this - // error with a straight 404, at least in production. + } + // If it's not a landing page, then we search + // through our navData to find the node with a path + // that matches the pathArray we're looking for. + function flattenRoutes(nodes) { + return nodes.reduce((acc, n) => { + if (!n.routes) return acc.concat(n) + return acc.concat(flattenRoutes(n.routes)) + }, []) + } + const allNodes = flattenRoutes(navData) + const matches = allNodes.filter((n) => n.path === pathToMatch) + // Throw an error for missing files - if this happens, + // we might have an issue with `getStaticPaths` or something + if (matches.length === 0) { + throw new Error(`Missing resource to match "${pathToMatch}"`) + } + // Throw an error if there are multiple matches + // If this happens, there's likely an issue in the + // content source repo + if (matches.length > 1) { throw new Error( - `We went looking for "${leafPath}" and "${indexPath}" but neither one was found.` + `Ambiguous path matches for "${pathToMatch}". Found:\n\n${JSON.stringify( + matches + )}` ) } - - const { data: frontMatter, content } = matter(page) - const mdxSource = await renderToString(content, { - mdxOptions: markdownDefaults({ - resolveIncludes: path.join(process.cwd(), 'content/partials'), - addRemarkPlugins: remarkPlugins, - }), - components, - scope, - }) - - return { - mdxSource, - frontMatter, - filePath: filePath.replace(`${root}/`, ''), - } + // Otherwise, we have exactly one match, + // and we can return the filePath off of it + return matches[0] } -// We are memoizing this function as it does a non-trivial amount of I/O to read frontmatter for all mdx files in a directory -export const fastReadFrontMatter = - process.env.NODE_ENV === 'production' - ? moize(fastReadFrontMatterFn) - : fastReadFrontMatterFn +function getPathArraysFromNodes(navNodes) { + const slugs = navNodes.reduce((acc, navNode) => { + // Individual items have a path, these should be added + if (navNode.path) return acc.concat([navNode.path.split('/')]) + // Category items have child routes, these should all be added + if (navNode.routes) + return acc.concat(getPathArraysFromNodes(navNode.routes)) + // All other node types (dividers, external links) can be ignored + return acc + }, []) + return slugs +} -async function fastReadFrontMatterFn(p) { - const fm = [] - for await (const entry of readdirp(p, { fileFilter: '*.mdx' })) { - let lineNum = 0 - const content = [] - fm.push( - new Promise((resolve, reject) => { - lineReader.eachLine( - entry.fullPath, - (line) => { - // if it has any content other than `---`, the file doesn't have front matter, so we close - if (lineNum === 0 && !line.match(/^---$/)) { - console.warn( - `WARNING: The file "${entry.path}" is missing front matter. Please add front matter to ensure the file's metadata is properly populated.` - ) - content.push('---') - content.push('page_title: "ERROR: Missing Frontmatter"') - return false - } - // if it's not the first line and we have a bottom delimiter, exit - if (lineNum !== 0 && line.match(/^---$/)) return false - // now we read lines until we match the bottom delimiters - content.push(line) - // increment line number - lineNum++ - }, - (err) => { - if (err) return reject(err) - content.push(`__resourcePath: "${entry.path}"`) - resolve(safeLoad(content.slice(1).join('\n')), { - filename: entry.fullPath, - }) - } - ) - }) - ) - } - return Promise.all(fm) +// We currently export most utilities individually, +// since we have cases such as Packer remote plugin docs +// where we want to re-use these utilities to build +// getStaticPaths and getStaticProps functions that +// fall outside the use case of local-only content +export { + generateStaticPaths, + generateStaticProps, + getNodeFromPath, + getPathsFromNavData, + validateNavData, + validateFilePaths, } diff --git a/packages/docs-page/temporary_jump-to-section.js b/packages/docs-page/temporary_jump-to-section.js index 40fc0cb7c..b52310a29 100644 --- a/packages/docs-page/temporary_jump-to-section.js +++ b/packages/docs-page/temporary_jump-to-section.js @@ -14,7 +14,7 @@ export default function temporary_injectJumpToSection(node) { // slice removes the anchor link character return { id: h2.querySelector('.__target-h').id, - text: h2.innerText.slice(1), + text: h2.textContent.slice(1), // slice removes permalink ยป character } }) diff --git a/packages/docs-sidenav/README.md b/packages/docs-sidenav/README.md index 31420e860..99b3f5097 100644 --- a/packages/docs-sidenav/README.md +++ b/packages/docs-sidenav/README.md @@ -2,6 +2,6 @@ Side navigation for HashiCorp's product documentation. Fairly tightly tied to our specific documentation sites and the format that middleman uses to catalog pages and frontmatter at the moment. -### Props +## Props See [the props file](props.js) for more details. diff --git a/packages/docs-sidenav/chevron-icon.js b/packages/docs-sidenav/chevron-icon.js deleted file mode 100644 index cebb2a374..000000000 --- a/packages/docs-sidenav/chevron-icon.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' - -export default function ChevronIcon() { - return ( - <svg - width="5" - height="8" - xmlns="http://www.w3.org/2000/svg" - className="chevron" - > - <path - d="M1.429.194A.5.5 0 1 0 .722.9l2.646 2.647L.722 6.194a.5.5 0 1 0 .707.707l3-3a.5.5 0 0 0 0-.708l-3-3z" - fill="#9396A2" - fillRule="evenodd" - /> - </svg> - ) -} diff --git a/packages/docs-sidenav/docs.mdx b/packages/docs-sidenav/docs.mdx index aeb0f35fb..f3c0d6696 100644 --- a/packages/docs-sidenav/docs.mdx +++ b/packages/docs-sidenav/docs.mdx @@ -2,14 +2,13 @@ componentName: 'DocsSidenav' --- -This is a cool component for documentation +DocsSidenav renders a tree of links, with the option to include nested sections. Horizontal dividers can also be included between items. <LiveComponent>{`<DocsSidenav product="${componentProps.product.testValue}" - currentPage="${componentProps.currentPage.testValue}" - category="${componentProps.category.testValue}" - order={${JSON.stringify(componentProps.order.testValue, null, 2)}} - data={${JSON.stringify(componentProps.data.testValue, null, 2)}} + currentPath="${componentProps.currentPath.testValue}" + baseRoute="${componentProps.baseRoute.testValue}" + navData={${JSON.stringify(componentProps.navData.testValue, null, 2)}} />`}</LiveComponent> <UsageDetails packageJson={packageJson} /> diff --git a/packages/docs-sidenav/fixtures/content/agent/index.mdx b/packages/docs-sidenav/fixtures/content/agent/index.mdx new file mode 100644 index 000000000..131609398 --- /dev/null +++ b/packages/docs-sidenav/fixtures/content/agent/index.mdx @@ -0,0 +1,173 @@ +--- +page_title: Vault Agent +description: |- + Vault Agent is a client-side daemon that can be used to perform some Vault + functionality automatically. +--- + +# Vault Agent + +Vault Agent is a client daemon that provides the following features: + +- [Auto-Auth][autoauth] - Automatically authenticate to Vault and manage the token renewal process for locally-retrieved dynamic secrets. +- [Caching][caching] - Allows client-side caching of responses containing newly created tokens and responses containing leased secrets generated off of these newly created tokens. +- [Windows Service][winsvc] - Allows running the Vault Agent as a Windows service. +- [Templating][template] - Allows rendering of user supplied templates by Vault Agent, using the token generated by the Auto-Auth step. + To get help, run: + +```shell-session +$ vault agent -h +``` + +## Auto-Auth + +Vault Agent allows for easy authentication to Vault in a wide variety of +environments. Please see the [Auto-Auth docs][autoauth] +for information. + +Auto-Auth functionality takes place within an `auto_auth` configuration stanza. + +## Caching + +Vault Agent allows client-side caching of responses containing newly created tokens +and responses containing leased secrets generated off of these newly created tokens. +Please see the [Caching docs][caching] for information. + +## Configuration + +These are the currently-available general configuration option: + +- `vault` <code>([vault][vault]: <optional\>)</code> - Specifies the remote Vault server the Agent connects to. + +- `auto_auth` <code>([auto_auth][autoauth]: <optional\>)</code> - Specifies the method and other options used for Auto-Auth functionality. + +- `cache` <code>([cache][caching]: <optional\>)</code> - Specifies options used for Caching functionality. + +- `listener` <code>([listener][listener]: <optional\>)</code> - Specifies the addresses and ports on which the Agent will respond to requests. + +- `pid_file` `(string: "")` - Path to the file in which the agent's Process ID + (PID) should be stored + +- `exit_after_auth` `(bool: false)` - If set to `true`, the agent will exit + with code `0` after a single successful auth, where success means that a + token was retrieved and all sinks successfully wrote it + +- `template` <code>([template][template]: <optional\>)</code> - Specifies options used for templating Vault secrets to files. + +### vault Stanza + +There can at most be one top level `vault` block and it has the following +configuration entries: + +- `address` `(string: <optional>)` - The address of the Vault server. This should + be a complete URL such as `https://127.0.0.1:8200`. This value can be + overridden by setting the `VAULT_ADDR` environment variable. + +- `ca_cert` `(string: <optional>)` - Path on the local disk to a single PEM-encoded + CA certificate to verify the Vault server's SSL certificate. This value can + be overridden by setting the `VAULT_CACERT` environment variable. + +- `ca_path` `(string: <optional>)` - Path on the local disk to a directory of + PEM-encoded CA certificates to verify the Vault server's SSL certificate. + This value can be overridden by setting the `VAULT_CAPATH` environment + variable. + +- `client_cert` `(string: <optional>)` - Path on the local disk to a single + PEM-encoded CA certificate to use for TLS authentication to the Vault server. + This value can be overridden by setting the `VAULT_CLIENT_CERT` environment + variable. + +- `client_key` `(string: <optional>)` - Path on the local disk to a single + PEM-encoded private key matching the client certificate from `client_cert`. + This value can be overridden by setting the `VAULT_CLIENT_KEY` environment + variable. + +- `tls_skip_verify` `(string: <optional>)` - Disable verification of TLS + certificates. Using this option is highly discouraged as it decreases the + security of data transmissions to and from the Vault server. This value can + be overridden by setting the `VAULT_SKIP_VERIFY` environment variable. + +- `tls_server_name` `(string: <optional>)` - Name to use as the SNI host when + connecting via TLS. This value can be overridden by setting the + `VAULT_TLS_SERVER_NAME` environment variable. + +### listener Stanza + +Agent supports one or more [listener][listener_main] stanzas. In addition to +the standard listener configuration, an Agent's listener configuration also +supports an additional optional entry: + +- `require_request_header` `(bool: false)` - Require that all incoming HTTP + requests on this listener must have an `X-Vault-Request: true` header entry. + Using this option offers an additional layer of protection from Server Side + Request Forgery attacks. Requests on the listener that do not have the proper + `X-Vault-Request` header will fail, with a HTTP response status code of `412: Precondition Failed`. + +## Example Configuration + +An example configuration, with very contrived values, follows: + +```python +pid_file = "./pidfile" + +vault { + address = "https://127.0.0.1:8200" +} + +auto_auth { + method "aws" { + mount_path = "auth/aws-subaccount" + config = { + type = "iam" + role = "foobar" + } + } + + sink "file" { + config = { + path = "/tmp/file-foo" + } + } + + sink "file" { + wrap_ttl = "5m" + aad_env_var = "TEST_AAD_ENV" + dh_type = "curve25519" + dh_path = "/tmp/file-foo-dhpath2" + config = { + path = "/tmp/file-bar" + } + } +} + +cache { + use_auto_auth_token = true +} + +listener "unix" { + address = "/path/to/socket" + tls_disable = true +} + +listener "tcp" { + address = "127.0.0.1:8100" + tls_disable = true +} + +template { + source = "/etc/vault/server.key.ctmpl" + destination = "/etc/vault/server.key" +} + +template { + source = "/etc/vault/server.crt.ctmpl" + destination = "/etc/vault/server.crt" +} +``` + +[vault]: /docs/agent#vault-stanza +[autoauth]: /docs/agent/autoauth +[caching]: /docs/agent/caching +[template]: /docs/agent/template +[listener]: /docs/agent#listener-stanza +[listener_main]: /docs/configuration/listener/tcp diff --git a/packages/docs-sidenav/fixtures/content/ambiguous.mdx b/packages/docs-sidenav/fixtures/content/ambiguous.mdx new file mode 100644 index 000000000..f86aab5d3 --- /dev/null +++ b/packages/docs-sidenav/fixtures/content/ambiguous.mdx @@ -0,0 +1,17 @@ +--- +page_title: Ambiguous +description: >- + This file is located at both ambiguous.mdx and ambiguous/index.mdx, so trying to + include it should throw an error. +--- + +# Introduction to Vault + +Welcome to the introduction guide to HashiCorp Vault! This guide is the best +place to get started with Vault. This guide covers what Vault is, what problems +it can solve, how it compares to existing software, and contains a quick start +for using Vault. + +If you are already familiar with the basics of Vault, the +[documentation](/docs) provides a better reference guide for all +available features as well as internals. diff --git a/packages/docs-sidenav/fixtures/content/ambiguous/index.mdx b/packages/docs-sidenav/fixtures/content/ambiguous/index.mdx new file mode 100644 index 000000000..f86aab5d3 --- /dev/null +++ b/packages/docs-sidenav/fixtures/content/ambiguous/index.mdx @@ -0,0 +1,17 @@ +--- +page_title: Ambiguous +description: >- + This file is located at both ambiguous.mdx and ambiguous/index.mdx, so trying to + include it should throw an error. +--- + +# Introduction to Vault + +Welcome to the introduction guide to HashiCorp Vault! This guide is the best +place to get started with Vault. This guide covers what Vault is, what problems +it can solve, how it compares to existing software, and contains a quick start +for using Vault. + +If you are already familiar with the basics of Vault, the +[documentation](/docs) provides a better reference guide for all +available features as well as internals. diff --git a/packages/docs-sidenav/fixtures/content/what-is-vault.mdx b/packages/docs-sidenav/fixtures/content/what-is-vault.mdx new file mode 100644 index 000000000..a220e6cb1 --- /dev/null +++ b/packages/docs-sidenav/fixtures/content/what-is-vault.mdx @@ -0,0 +1,72 @@ +--- +page_title: Introduction +description: >- + Welcome to the intro guide to Vault! This guide is the best place to start + with Vault. We cover what Vault is, what problems it can solve, how it + compares to existing software, and contains a quick start for using Vault. +--- + +# Introduction to Vault + +Welcome to the introduction guide to HashiCorp Vault! This guide is the best +place to get started with Vault. This guide covers what Vault is, what problems +it can solve, how it compares to existing software, and contains a quick start +for using Vault. + +If you are already familiar with the basics of Vault, the +[documentation](/docs) provides a better reference guide for all +available features as well as internals. + +## What is Vault? + +Vault is a tool for securely accessing _secrets_. A secret is anything that you +want to tightly control access to, such as API keys, passwords, or certificates. +Vault provides a unified interface to any secret, while providing tight access +control and recording a detailed audit log. + +A modern system requires access to a multitude of secrets: database credentials, +API keys for external services, credentials for service-oriented architecture +communication, etc. Understanding who is accessing what secrets is already very +difficult and platform-specific. Adding on key rolling, secure storage, and +detailed audit logs is almost impossible without a custom solution. This is +where Vault steps in. + +Examples work best to showcase Vault. Please see the +[use cases](/docs/use-cases). + +The key features of Vault are: + +- **Secure Secret Storage**: Arbitrary key/value secrets can be stored + in Vault. Vault encrypts these secrets prior to writing them to persistent + storage, so gaining access to the raw storage isn't enough to access + your secrets. Vault can write to disk, [Consul](https://www.consul.io), + and more. + +- **Dynamic Secrets**: Vault can generate secrets on-demand for some + systems, such as AWS or SQL databases. For example, when an application + needs to access an S3 bucket, it asks Vault for credentials, and Vault + will generate an AWS keypair with valid permissions on demand. After + creating these dynamic secrets, Vault will also automatically revoke them + after the lease is up. + +- **Data Encryption**: Vault can encrypt and decrypt data without storing + it. This allows security teams to define encryption parameters and + developers to store encrypted data in a location such as SQL without + having to design their own encryption methods. + +- **Leasing and Renewal**: All secrets in Vault have a _lease_ associated + with them. At the end of the lease, Vault will automatically revoke that + secret. Clients are able to renew leases via built-in renew APIs. + +- **Revocation**: Vault has built-in support for secret revocation. Vault + can revoke not only single secrets, but a tree of secrets, for example + all secrets read by a specific user, or all secrets of a particular type. + Revocation assists in key rolling as well as locking down systems in the + case of an intrusion. + +## Next Steps + +See the page on [Vault use cases](/docs/use-cases) to see the multiple ways +Vault can be used. Then, continue onwards with the [getting started +guide](https://learn.hashicorp.com/vault/getting-started/install) to use Vault +to read, write, and create real secrets and see how it works in practice. diff --git a/packages/docs-sidenav/fixtures/nav-data.json b/packages/docs-sidenav/fixtures/nav-data.json new file mode 100644 index 000000000..3c8e2926e --- /dev/null +++ b/packages/docs-sidenav/fixtures/nav-data.json @@ -0,0 +1,77 @@ +[ + { + "title": "What is Vault?", + "path": "what-is-vault" + }, + { + "title": "Vault Agent", + "routes": [ + { + "title": "Overview", + "path": "agent" + }, + { + "title": "Auto-Auth", + "routes": [ + { + "title": "Overview", + "path": "agent/autoauth" + }, + { + "title": "AWS Agent", + "path": "agent/autoauth/aws" + }, + { + "title": "Methods", + "routes": [ + { + "title": "Overview", + "path": "agent/autoauth/methods" + }, + { + "title": "AliCloud", + "path": "agent/autoauth/methods/alicloud" + }, + { + "title": "AWS", + "path": "agent/autoauth/methods/aws" + }, + { "divider": true }, + { + "title": "<code>GCP</code>", + "path": "agent/autoauth/methods/gcp" + }, + { "divider": true }, + { + "title": "External Link", + "href": "https://google.com" + }, + { + "title": "Internal Direct Link", + "href": "/some-non-docs-path" + } + ] + } + ] + }, + { + "title": "No Index Category", + "routes": [ + { + "title": "Foo Item", + "path": "agent/no-index-test/foo" + } + ] + }, + { + "title": "Only Index Test <sup>ENT</sup>", + "routes": [ + { + "title": "Overview", + "path": "agent/only-index-test" + } + ] + } + ] + } +] diff --git a/packages/docs-sidenav/icons/bullet.svg b/packages/docs-sidenav/icons/bullet.svg new file mode 100644 index 000000000..6e68f3963 --- /dev/null +++ b/packages/docs-sidenav/icons/bullet.svg @@ -0,0 +1,3 @@ +<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="4" cy="4" r="2" fill="black"/> +</svg> diff --git a/packages/docs-sidenav/icons/chevron.svg b/packages/docs-sidenav/icons/chevron.svg new file mode 100644 index 000000000..7b0cfaaee --- /dev/null +++ b/packages/docs-sidenav/icons/chevron.svg @@ -0,0 +1,3 @@ +<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2.86488 0.661047C2.81944 0.611606 2.76447 0.571862 2.70328 0.544206C2.64209 0.51655 2.57593 0.501553 2.5088 0.500114C2.44166 0.498676 2.37493 0.510827 2.31261 0.535837C2.25029 0.560847 2.19367 0.598199 2.14615 0.645649C2.09863 0.693099 2.0612 0.749667 2.0361 0.811952C2.011 0.874238 1.99876 0.940955 2.0001 1.00809C2.00144 1.07523 2.01635 1.14141 2.04392 1.20264C2.07149 1.26387 2.11115 1.31889 2.16053 1.3644L4.79663 4.0015L2.16053 6.63859C2.11295 6.68454 2.075 6.73951 2.0489 6.80028C2.02279 6.86106 2.00905 6.92642 2.00847 6.99256C2.0079 7.0587 2.0205 7.1243 2.04555 7.18552C2.0706 7.24673 2.10758 7.30235 2.15435 7.34912C2.20112 7.39589 2.25674 7.43288 2.31796 7.45793C2.37918 7.48297 2.44477 7.49558 2.51091 7.495C2.57705 7.49443 2.64242 7.48069 2.70319 7.45458C2.76397 7.42847 2.81893 7.39052 2.86488 7.34295L5.85366 4.35417C5.90004 4.3079 5.93685 4.25293 5.96196 4.19242C5.98707 4.1319 6 4.06702 6 4.0015C6 3.93598 5.98707 3.8711 5.96196 3.81058C5.93685 3.75007 5.90004 3.6951 5.85366 3.64882L2.86488 0.660051V0.661047Z" fill="black"/> +</svg> diff --git a/packages/docs-sidenav/img/external.svg b/packages/docs-sidenav/icons/external-link.svg similarity index 100% rename from packages/docs-sidenav/img/external.svg rename to packages/docs-sidenav/icons/external-link.svg diff --git a/packages/docs-sidenav/menu-icon.js b/packages/docs-sidenav/icons/menu.svg similarity index 94% rename from packages/docs-sidenav/menu-icon.js rename to packages/docs-sidenav/icons/menu.svg index a3b50f53b..c586219cc 100644 --- a/packages/docs-sidenav/menu-icon.js +++ b/packages/docs-sidenav/icons/menu.svg @@ -1,14 +1,8 @@ -import React from 'react' - -export default function MenuIcon() { - return ( - <svg width="20" height="20" fill="none" viewBox="0 0 20 20"> + <svg width="20" height="20" fill="none" view-box="0 0 20 20"> <path fill="black" - fillRule="evenodd" + fill-rule="evenodd" d="M3.08268 14.4112C3.00768 14.3362 2.91602 14.2695 2.81602 14.2278C2.66602 14.1695 2.49935 14.1528 2.33268 14.1862C2.28268 14.1945 2.22435 14.2112 2.17435 14.2278C2.12435 14.2528 2.08268 14.2778 2.03268 14.3112C1.99018 14.3362 1.94935 14.3695 1.90768 14.4112C1.83268 14.4862 1.77435 14.5778 1.72435 14.6778C1.68268 14.7862 1.66602 14.8862 1.66602 15.0028C1.66602 15.0528 1.66602 15.1112 1.68268 15.1612C1.69102 15.2195 1.70768 15.2695 1.72435 15.3195C1.74852 15.3695 1.77435 15.4195 1.80768 15.4612C1.83268 15.5112 1.87435 15.5528 1.90768 15.5862C1.94935 15.6278 1.99018 15.6612 2.03268 15.6945C2.08268 15.7195 2.12435 15.7528 2.17435 15.7695C2.22435 15.7862 2.28268 15.8028 2.33268 15.8195C2.39102 15.8278 2.44018 15.8362 2.49935 15.8362C2.60768 15.8362 2.71602 15.8112 2.81602 15.7695C2.91602 15.7278 3.00768 15.6695 3.08268 15.5862C3.12435 15.5528 3.15768 15.5112 3.19102 15.4612C3.21602 15.4195 3.24935 15.3695 3.26602 15.3195C3.29102 15.2695 3.30768 15.2195 3.31602 15.1612C3.32435 15.1112 3.33268 15.0528 3.33268 15.0028C3.33268 14.8862 3.30768 14.7862 3.26602 14.6778C3.22435 14.5778 3.16602 14.4862 3.08268 14.4112ZM3.08268 9.41118C3.00768 9.33618 2.91602 9.26951 2.81602 9.22785C2.66602 9.16951 2.49935 9.15284 2.33268 9.18618C2.28268 9.19451 2.22435 9.21118 2.17435 9.22785C2.12435 9.25284 2.08268 9.27784 2.03268 9.31118C1.99018 9.33618 1.94935 9.36951 1.90768 9.41118C1.74852 9.56951 1.66602 9.77784 1.66602 10.0028C1.66602 10.1112 1.68268 10.2195 1.72435 10.3195C1.77435 10.4195 1.83268 10.5112 1.90768 10.5862C1.94935 10.6278 1.99018 10.6612 2.03268 10.6945C2.08268 10.7195 2.12435 10.7445 2.17435 10.7695C2.22435 10.7862 2.28268 10.8028 2.33268 10.8195C2.39102 10.8278 2.44018 10.8362 2.49935 10.8362C2.60768 10.8362 2.71602 10.8112 2.81602 10.7695C2.91602 10.7278 3.00768 10.6695 3.08268 10.5862C3.16602 10.5112 3.22435 10.4195 3.26602 10.3195C3.30768 10.2195 3.33268 10.1112 3.33268 10.0028C3.33268 9.88618 3.30768 9.78618 3.26602 9.67785C3.22435 9.57785 3.16602 9.48618 3.08268 9.41118ZM3.08268 4.41128C3.00768 4.33628 2.91602 4.26961 2.81602 4.22795C2.66602 4.16128 2.49935 4.15295 2.33268 4.18628C2.28268 4.19461 2.22435 4.21128 2.17435 4.22795C2.12435 4.25295 2.08268 4.27795 2.03268 4.31128C1.99018 4.33628 1.94935 4.36961 1.90768 4.41128C1.74852 4.56961 1.66602 4.78628 1.66602 5.00295C1.66602 5.11128 1.68268 5.21961 1.72435 5.31961C1.77435 5.41961 1.83268 5.51128 1.90768 5.58628C1.94935 5.62795 1.99018 5.66128 2.03268 5.69461C2.08268 5.71961 2.12435 5.74461 2.17435 5.76961C2.22435 5.78628 2.28268 5.80295 2.33268 5.81961C2.39102 5.82795 2.44018 5.83628 2.49935 5.83628C2.60768 5.83628 2.71602 5.81128 2.81602 5.76961C2.91602 5.72795 3.00768 5.66961 3.08268 5.58628C3.16602 5.51128 3.22435 5.41961 3.26602 5.31961C3.30768 5.21961 3.33268 5.11128 3.33268 5.00295C3.33268 4.89461 3.30768 4.78628 3.26602 4.67795C3.22435 4.57795 3.16602 4.48628 3.08268 4.41128ZM17.4967 14.167H6.66341C6.20258 14.167 5.83008 14.5395 5.83008 15.0003C5.83008 15.4603 6.20258 15.8337 6.66341 15.8337H17.4967C17.9576 15.8337 18.3301 15.4603 18.3301 15.0003C18.3301 14.5395 17.9576 14.167 17.4967 14.167ZM17.4967 9.16699H6.66341C6.20258 9.16699 5.83008 9.53949 5.83008 10.0003C5.83008 10.4603 6.20258 10.8337 6.66341 10.8337H17.4967C17.9576 10.8337 18.3301 10.4603 18.3301 10.0003C18.3301 9.53949 17.9576 9.16699 17.4967 9.16699ZM18.3301 5.00033C18.3301 5.46033 17.9576 5.83366 17.4967 5.83366H6.66341C6.20258 5.83366 5.83008 5.46033 5.83008 5.00033C5.83008 4.53949 6.20258 4.16699 6.66341 4.16699H17.4967C17.9576 4.16699 18.3301 4.53949 18.3301 5.00033Z" - clipRule="evenodd" + clip-rule="evenodd" /> - </svg> - ) -} + </svg> \ No newline at end of file diff --git a/packages/docs-sidenav/index.js b/packages/docs-sidenav/index.js index 997179d4b..194b3c309 100644 --- a/packages/docs-sidenav/index.js +++ b/packages/docs-sidenav/index.js @@ -1,478 +1,237 @@ -import React, { useState, useMemo } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' +// @hashicorp imports import useProductMeta from '@hashicorp/nextjs-scripts/lib/providers/product-meta' -import LinkWrap from '@hashicorp/react-link-wrap' -import MenuIcon from './menu-icon' -import ChevronIcon from './chevron-icon' -import fuzzysearch from 'fuzzysearch' +import LinkWrap, { isAbsoluteURL } from '@hashicorp/react-link-wrap' +import InlineSvg from '@hashicorp/react-inline-svg' +// local utilities +import flagActiveNodes from './utils/flag-active-nodes' +import filterContent from './utils/filter-content' +import useEventListener from './utils/use-event-listener' +// svg +import svgMenuIcon from './icons/menu.svg?include' +import svgChevron from './icons/chevron.svg?include' +import svgBullet from './icons/bullet.svg?include' +import svgExternalLink from './icons/external-link.svg?include' +// styles +import s from './style.module.css' export default function DocsSidenav({ - data, - order, - currentPage, - category, - Link, + currentPath, + baseRoute, product, + navData, disableFilter = false, }) { - const [open, setOpen] = useState(false) - const [filterInput, setFilterInput] = useState('') - const { themeClass } = useProductMeta(product) + const router = useRouter() + const pathname = router ? router.pathname : null - // we memoize here as the page matching is a pure, expensive calculation that - // does not need to re-run every render - const allContent = useMemo( - () => matchOrderToData(currentPage, order, calculatePath(data, category)), - [order, data, category, currentPage] - ) - const [content, setContent] = useState(allContent) + // Get theme class + // ( note: we could consider getting the product prop here, + // rather than requiring it to be passed in ) + const { themeClass } = useProductMeta(product) - // remove leading slash and base level "docs"/"api"/etc - const currentPath = currentPage - .split('/') - .slice(1 + category.split('/').length) + // Set up filtering state + const [filterInput, setFilterInput] = useState('') + const [content, setContent] = useState(navData) + const [filteredContent, setFilteredContent] = useState(navData) + + // isMobileOpen controls menu open / close state + const [isMobileOpen, setIsMobileOpen] = useState(false) + // isMobileFullyHidden reflects if the menu is fully transitioned to a hidden state + const [isMenuFullyHidden, setIsMenuFullyHidden] = useState(true) + // We want to avoid exposing links to keyboard navigation + // when the menu is hidden on mobile. But we don't want our + // menu to flash when hide and shown. To meet both needs, + // we listen for transition end on the menu element, and when + // a transition ends and the menu is not open, we set isMenuFullyHidden + // which translates into a visibility: hidden CSS property + const menuRef = useRef(null) + const handleMenuTransitionEnd = useCallback(() => { + setIsMenuFullyHidden(!isMobileOpen) + }, [isMobileOpen, setIsMenuFullyHidden]) + useEventListener('transitionend', handleMenuTransitionEnd, menuRef.current) + + // When client-side navigation occurs, + // we want to close the mobile rather than keep it open + useEffect(() => { + setIsMobileOpen(false) + }, [pathname]) + + // When path-related data changes, update content to ensure + // `__isActive` props on each content item are up-to-date + // Note: we could also reset filter input here, if we don't + // want to filter input to persist across client-side nav, ie: + // setFilterInput("") + useEffect(() => { + if (!navData) return + setContent(flagActiveNodes(navData, currentPath, pathname)) + }, [currentPath, navData, pathname]) + + // When filter input changes, update content + // to filter out items that don't match + useEffect(() => { + setFilteredContent(filterContent(content, filterInput)) + }, [filterInput, content]) return ( - <div - className={`g-docs-sidenav${open ? ' open' : ''} ${themeClass || ''}`} - data-testid="root" - > - <div - className="toggle" - onClick={() => setOpen(!open)} - data-testid="mobile-menu" + <div className={`g-docs-sidenav ${s.root} ${themeClass || ''}`}> + <button + className={s.mobileMenuToggle} + onClick={() => setIsMobileOpen(!isMobileOpen)} > <span> - <MenuIcon /> Documentation Menu + <InlineSvg src={svgMenuIcon} /> Documentation Menu </span> - </div> - <ul className="nav docs-nav"> - <div className="mobile-close" onClick={() => setOpen(!open)}> + </button> + <ul + className={s.rootList} + ref={menuRef} + data-is-mobile-hidden={!isMobileOpen && isMenuFullyHidden} + data-is-mobile-open={isMobileOpen} + > + <button + className={s.mobileClose} + onClick={() => setIsMobileOpen(!isMobileOpen)} + > × - </div> + </button> {!disableFilter && ( <input - className="filter" + className={s.filterInput} placeholder="Filter..." - onChange={filterInputChange.bind( - null, - setFilterInput, - JSON.parse(JSON.stringify(allContent)), // deep clone - setContent - )} + onChange={(e) => setFilterInput(e.target.value)} value={filterInput} /> )} - {renderNavTree({ - category, - content, - currentPath, - currentPage, - filterInput, - Link, - })} + <NavTree + baseRoute={baseRoute} + content={filteredContent || []} + currentPath={currentPath} + Link={Link} + /> </ul> </div> ) } -// Filter nav items -function filterInputChange(setFilterInput, allContent, setContent, e) { - setFilterInput(e.target.value) - setContent(findContent(allContent, e.target.value.toLowerCase())) -} - -function findContent(content, value) { - // if there's no search value we short-circuit and return everything - if (!value) return content - - return content.reduce((acc, item) => { - // recurse on content, depth-first - if (item.content) item.content = findContent(item.content, value) - - // here we check for conditions on a branch node - // first, if a branch has children, that means at least one of the leaf nodes has - // matched, so we push the branch so that the leaf is displayed - const hasContent = item.content && item.content.length - // second, we check to see if a branch's title matches against the query, if so we - // push the branch because it matched directly - const categoryTitleMatches = - item.indexData && - fuzzysearch(value, item.indexData.page_title.toLowerCase()) - if (hasContent || categoryTitleMatches) { - if (categoryTitleMatches) { - item.matchedFilter = true - } - acc.push(item) - } - - // now we check against content on leaf nodes - if (item.page_title && fuzzysearch(value, item.page_title.toLowerCase())) { - item.matchedFilter = true - acc.push(item) - } - - // and that's all! - return acc - }, []) -} - -// Given a set of front matter data, adds a `path` variable formatted for correct links -function calculatePath(pageData, category) { - return pageData.map((p) => ({ - ...p, - path: p.__resourcePath - .split('/') - .slice(category.split('/').length) - .join('/') - .replace(/\.mdx$/, ''), - })) -} - -// Matches up user-defined navigation hierarcy with front matter from the correct pages. -// -// For context, the user-defined nav hierarchy is called "content" in this function, and -// the shape of its data is as such: -// [{ -// category: 'foo', -// content: ['bar', 'baz'] -// }, -// '--------------', -// { -// category: 'quux' -// }] -// -// The front matter data is structured as such: -// { -// __resourcePath: '/docs/foo/bar.mdx', -// path: 'foo/bar', -// ...frontMatterObject -// } -function matchOrderToData(currentPage, order, pageData, stack = []) { - // go through each item in the user-established order - return order.map((item) => { - if (typeof item === 'string') { - // if a string like '-----' is given, we render a divider - if (item.match(/^-+$/)) return item - - // if we have a string, that's a terminal page. we match it with - // the provided page data and return the enhanced object - const itemData = pageData.filter((page) => { - // break down the full path and strip the html extension - const pageDataPath = page.path.split('/') - // copy the stack and push the item as the file path - const contentPath = [...stack, item] - // match them up! - return pageDataPath.join('/') === contentPath.join('/') - })[0] - - // If we don't have a match here, the user has defined a page that doesn't exist, so let's give them - // a very clear error message on how to resolve this situation. - if (!itemData) { - const pageCategory = currentPage.split('/')[1] - const missingPath = `${pageCategory}/${stack.join('/')}/${item}.mdx` - const cat = `${stack.join('/')}` - throw new Error( - `The page "${item}" was not found within the category "${cat}". Please double-check to ensure that "${missingPath}" exists. If this page was never intended to exist, remove the key "${item}" from the category "${cat}" in "data/${pageCategory}-navigation.js"` - ) - } - - return itemData - } else if (item.title && item.href) { - // this is the syntax for direct links, we can return them directly - return item - } else { - // catch errors where direct links are formatted incorrectly - if (item.title || item.href) { - throw new Error( - `Malformed direct sidebar link:\n\n ${JSON.stringify( - item - )}\n\nDirect links must have a "href" and a "title" property.` - ) - } - - // this method mutates the object, which causes an error on subsequent renders, - // so we clone it first. - const _item = Object.assign({}, item) - - // keep track of all parent categories - _item.stack = stack.concat(_item.category) - - // using a category without content is not allowed - if (_item.category && !_item.content) { - const topLevelCategory = currentPage.split('/')[1] - throw new Error( - `The item "${_item.stack.join( - '/' - )}" within "data/${topLevelCategory}-navigation.js" has a category but no content, indicating that there is a folder that contains only an "index.mdx" file, which is not allowed. To fix this, move and rename "pages/${topLevelCategory}/${ - _item.stack.join('/') + '/index.mdx' - }" to "pages/${topLevelCategory}/${ - _item.stack.join('/') + '.mdx' - }", then change the value from "{ category: '${ - _item.category - }' }" to just "${item.category}"` - ) - } - - // grab the index page, as it can contain data about the top level link - pageData.some((page) => { - const pageDataPath = page.path.split('/') - - const depthLevelsMatch = _item.stack.length === pageDataPath.length - 1 - const pathItemsMatch = _item.stack.every( - (s, i) => s === pageDataPath[i] - ) - const isIndexFile = pageDataPath[pageDataPath.length - 1] === 'index' - - if (depthLevelsMatch && pathItemsMatch && isIndexFile) { - _item.indexData = page - // now that we know its an index page, we can remove it from the path, as - // its not necessary for links - _item.indexData.path = _item.indexData.path.replace(/\/index$/, '') - return true - } - }) - - // error handling for nav nesting mistakes - if (!_item.indexData && !_item.name) { - throw new Error( - `An index page or "name" property is required for all categories.\nIf you would like an index page for this category, please add an index file at the path "${_item.stack.join( - '/' - )}/index.mdx".\nIf you do not want an index page for this category, please add a "name" property to the category object to specify the category's human-readable title.\n\nItem:\n${JSON.stringify( - _item, - null, - 2 - )}` - ) - } - - // using "name" and manually adding an "overview" page is silly. let's prevent that. - if (_item.name && _item.content.includes('overview')) { - throw new Error(`The category "${_item.stack.join( - '/' - )}" is using a "name" property to indicate that it has no index, but also has a manually added "overview" page. This can be fixed with the following steps: - -- Change the "overview.mdx" page to be "index.mdx" -- Remove the "name" property from the "${ - _item.category - }" data, instead indicate the category's name using the frontmatter on the new "index.mdx" page`) - } - - // otherwise, it's a nested category. if the category has content, we - // recurse, passing in that category's content, and the matching - // subsection of page data from middleman - if (_item.content) { - _item.content = matchOrderToData( - currentPage, - _item.content, - filterData(pageData, _item.category), - _item.stack - ) - } - - return _item - } - }) -} - -// Recursively renders the markup for the nested navigation -function renderNavTree({ - category, - content, - currentPath, - currentPage, - filterInput, - Link, -}) { +function NavTree({ baseRoute, content }) { return content.map((item, idx) => { - // dividers are the only items left as strings - // This array is stable, so we can use index as key - // eslint-disable-next-line react/no-array-index-key - if (typeof item === 'string') return <hr key={idx} /> - - // if the link property has been set to true, we're rendering a direct link - // rather than a link to a docs page + // Dividers + if (item.divider) { + // eslint-disable-next-line react/no-array-index-key + return <Divider key={idx} /> + } + // Direct links if (item.title && item.href) { - let className = item.href.match(/^http[s]*:\/\//) ? 'external ' : '' - // allow direct links to be highlighted if they happen to live in the docs hierarchy - if (item.href === currentPage) className += 'active' - return ( - <li - // This array is stable, so we can use index as key - // eslint-disable-next-line react/no-array-index-key - key={idx} - data-testid={item.href} - className={className} - > - <LinkWrap - Link={Link} - href={item.href} - dangerouslySetInnerHTML={{ __html: item.title }} - /> - </li> + <DirectLink + key={item.title + item.href} + title={item.title} + href={item.href} + isActive={item.__isActive} + /> ) } - + // Individual pages (leaf nodes) if (item.path) { - // if the item has a path, it's a leaf node so we render a link to the page - let className = '' - if ( - fileMatch( - item.path.split('/').filter((x) => x), - currentPath.filter((x) => x) - ) - ) - className += 'active ' - if (item.matchedFilter) className += 'matched' - - return ( - <li - // This array is stable, so we can use index as key - // eslint-disable-next-line react/no-array-index-key - key={idx} - className={className} - data-testid={`/${category}/${item.path}`} - > - <LinkWrap - Link={Link} - href={`/${category}/${item.path}`} - dangerouslySetInnerHTML={{ - __html: item.sidebar_title || item.page_title, - }} - /> - </li> - ) - } else { - // if not, its an index page in a folder, so we render it as an expandable category - // alternately its a folder with no index page, in which case we skip the "overview" link - - // here we search for the sidebar title. if the category has an index page, we look on this - // first, preferring sidebar_title and falling back to page_title. next we look for name, which - // is the standard for categories without index files, and all else failing, we use the raw - // folder name itself - const title = item.indexData - ? item.indexData.sidebar_title || item.indexData.page_title - : item.name || item.category - - // we need to know the path of the category/folder itself, which we can get from the stack - const folderPath = item.stack.join('/') - - // now we test whether the current url is a match for the category and the page - const categoryMatches = categoryMatch(folderPath.split('/'), currentPath) - const fileMatches = fileMatch( - folderPath.split('/').filter((x) => x), - currentPath.filter((x) => x) - ) - ? 'active' - : '' - const containsFilterMatches = findFilterMatches(item) - - let className = '' - if (item.content) className += 'dir ' - if (categoryMatches) className += 'open active ' - if (containsFilterMatches) className += 'open ' - if (item.matchedFilter && !item.content) className += 'matched' - - // and finally, we can render the folder return ( - <li - className={className} - data-testid={`/${category}/${folderPath}`} - // This array is stable, so we can use index as key - // eslint-disable-next-line react/no-array-index-key - key={idx} - > - <span> - {/* Note: this is rendered as a link, but with no href. We should test to see if */} - {/* a button element would be more semantically appropriate for a11y. (https://app.asana.com/0/1100423001970639/1199667739287943/f) */} - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - <a - onClick={item.content && toggleNav} - data-testid={`/${category}/${folderPath} - link`} - > - {item.content ? ( - <> - <ChevronIcon />{' '} - { - <span - dangerouslySetInnerHTML={{ - __html: title, - }} - ></span> - } - </> - ) : ( - <span - dangerouslySetInnerHTML={{ - __html: title, - }} - ></span> - )} - </a> - </span> - - {/* if the item has content, we need to recurse */} - {item.content && ( - <ul className="nav" key={folderPath}> - {!item.name && (!filterInput || item.matchedFilter) && ( - <li - className={`${fileMatches ? 'active ' : ''}${ - item.matchedFilter ? 'matched' : '' - }`} - data-testid={`/${category}/${folderPath}/index`} - > - {/* hide "overview" links if there's no overview (aka there is a name), or while searching */} - - <LinkWrap Link={Link} href={`/${category}/${folderPath}`}> - Overview - </LinkWrap> - </li> - )} - {renderNavTree({ - category, - content: item.content, - currentPath, - filterInput, - Link, - })} - </ul> - )} - </li> + <NavLeaf + key={item.path} + title={item.title} + isActive={item.__isActive} + url={`/${baseRoute}/${item.path}`} + /> ) } + // Otherwise, render a nav branch + // (this will recurse and render a nav tree) + return ( + <NavBranch + key={item.title} + title={item.title} + routes={item.routes} + isActive={item.__isActive} + isFiltered={item.__isFiltered} + baseRoute={baseRoute} + /> + ) }) } -// Given a single item, returns whether it or any of its children have the -// `matchedFilter` property. -function findFilterMatches(item) { - if (item.matchedFilter) return true +function NavBranch({ title, routes, baseRoute, isActive, isFiltered }) { + const [isOpen, setIsOpen] = useState(false) + + // Ensure categories appear open if they're active + // or match the current filter + useEffect(() => setIsOpen(isActive || isFiltered), [isActive, isFiltered]) + return ( - item.content && - item.content.map((child) => findFilterMatches(child)).some((x) => x) + <li> + <button + className={s.navItem} + onClick={() => setIsOpen(!isOpen)} + data-is-open={isOpen} + data-is-active={isActive} + > + <InlineSvg + src={svgChevron} + className={s.navBranchIcon} + data-is-open={isOpen} + data-is-active={isActive} + /> + <span dangerouslySetInnerHTML={{ __html: title }} /> + </button> + + <ul className={s.navBranchSubnav} data-is-open={isOpen}> + <NavTree baseRoute={baseRoute} content={routes} /> + </ul> + </li> ) } -// Given an array of pages, returns only pages whose paths contain the given category. -function filterData(data, category) { - return data.filter((d) => d.path.split('/').indexOf(category) > -1) -} - -// If the nav item category is entirely contained by the current page's path, -// this means we're inside that category and should mark it as open. -function categoryMatch(navItemPath, currentPath) { - return navItemPath.every((item, i) => item === currentPath[i]) +function NavLeaf({ title, url, isActive }) { + // if the item has a path, it's a leaf node so we render a link to the page + return ( + <li> + <Link href={url}> + <a className={s.navItem} data-is-active={isActive}> + <InlineSvg + src={svgBullet} + className={s.navLeafIcon} + data-is-active={isActive} + /> + <span dangerouslySetInnerHTML={{ __html: title }} /> + </a> + </Link> + </li> + ) } -// If the current page's path exactly matches the passed in nav item's path, -// we have a match and can highlight the currently active page. -function fileMatch(navItemPath, currentPath) { - if (currentPath.length !== navItemPath.length) return false - return currentPath.every((item, i) => item === navItemPath[i]) +function DirectLink({ title, href, isActive }) { + return ( + <li> + <LinkWrap + className={s.navItem} + href={href} + Link={Link} + data-is-active={isActive} + > + <InlineSvg + src={svgBullet} + className={s.navLeafIcon} + data-is-active={isActive} + /> + <span dangerouslySetInnerHTML={{ __html: title }} /> + {isAbsoluteURL(href) ? ( + <InlineSvg src={svgExternalLink} className={s.externalLinkIcon} /> + ) : null} + </LinkWrap> + </li> + ) } -// Opens and closes a given nav category, the easy way -function toggleNav(e) { - e.preventDefault() - e.currentTarget.parentElement.parentElement.classList.toggle('open') +function Divider() { + return <hr className={s.divider} /> } diff --git a/packages/docs-sidenav/index.test.js b/packages/docs-sidenav/index.test.js index 27d196a42..8dc044c7a 100644 --- a/packages/docs-sidenav/index.test.js +++ b/packages/docs-sidenav/index.test.js @@ -1,145 +1,100 @@ -import 'regenerator-runtime/runtime' import { render, fireEvent, screen } from '@testing-library/react' import DocsSidenav from './' -import expectThrow from '../../__test-helpers/expect-throw' import props from './props' import { getTestValues } from 'swingset/testing' const defaultProps = getTestValues(props) describe('<DocsSidenav />', () => { - it('should render and display nesting levels correctly', () => { - render(<DocsSidenav {...defaultProps} />) - expect(screen.getByTestId('root').className).toContain('g-docs-sidenav') + it('renders a root element with a g-docs-sidenav className', () => { + const { container } = render(<DocsSidenav {...defaultProps} />) + expect(container.firstChild.className).toContain('g-docs-sidenav') + }) + it('renders and displays nesting levels correctly', () => { + render(<DocsSidenav {...defaultProps} />) // For this test, we step through the expected nesting levels based on // the fixture data, ensuring that each level is nested properly and has // the classes to reflect whether it's shown as active - const levelOne = screen.getByTestId('/docs/agent') - expect(levelOne.className).toMatch(/dir/) - expect(levelOne.className).toMatch(/open active/) + const branchOne = screen.getByText('Vault Agent').closest('button') + expect(branchOne.getAttribute('data-is-active')).toBe('true') + expect(branchOne.getAttribute('data-is-open')).toBe('true') - const levelTwo = screen.getByTestId('/docs/agent/autoauth') - expect(levelTwo.className).toMatch(/dir/) - expect(levelTwo.className).toMatch(/open active/) + const branchTwo = screen.getByText('Auto-Auth').closest('button') + expect(branchTwo.getAttribute('data-is-active')).toBe('true') + expect(branchTwo.getAttribute('data-is-open')).toBe('true') - const levelThree = screen.getByTestId('/docs/agent/autoauth/methods') - expect(levelThree.className).toMatch(/dir/) - expect(levelThree.className).toMatch(/open active/) + const branchThree = screen.getByText('Methods').closest('button') + expect(branchThree.getAttribute('data-is-active')).toBe('true') + expect(branchThree.getAttribute('data-is-open')).toBe('true') - const levelFour = screen.getByTestId('/docs/agent/autoauth/methods/aws') - expect(levelFour.className).toMatch(/active/) + const activeLeaf = screen.getByText('AWS').closest('a') + expect(activeLeaf.getAttribute('data-is-active')).toBe('true') // Let's also make sure that other pages are not also displaying as active - // First we check an identically named page at a different level - const dupe1 = screen.getByTestId('/docs/agent/autoauth/aws') - expect(dupe1.className).not.toMatch(/active/) + // First we check an similarly named page at a different level + const inactiveLeafOne = screen.getByText('AWS Agent').closest('a') + expect(inactiveLeafOne.getAttribute('data-is-active')).toBe('false') // Next we check a page at the same level but with a different name - const dupe2 = screen.getByTestId('/docs/agent/autoauth/methods/gcp') - expect(dupe2.className).not.toMatch(/active/) + const inactiveLeafTwo = screen.getByText('GCP').closest('a') + expect(inactiveLeafTwo.getAttribute('data-is-active')).toBe('false') // Finally we check the overview page at the same level - const dupe3 = screen.getByTestId('/docs/agent/autoauth/methods/index') - expect(dupe3.className).not.toMatch(/active/) + const inactiveLeafThree = screen + .getAllByText('Overview') + .map((node) => node.closest('a')) + .filter((linkElem) => { + return linkElem.getAttribute('href') === '/docs/agent/autoauth/methods' + })[0] + expect(inactiveLeafThree.getAttribute('data-is-active')).toBe('false') }) - it.todo('should render accurately when the current page is an "overview"') - - it('should expand/collapse directory-level menu items when clicked', () => { - render(<DocsSidenav {...defaultProps} />) - - const levelTwoLink = screen.getByTestId('/docs/agent/autoauth - link') - fireEvent.click(levelTwoLink) - const levelTwo = screen.getByTestId('/docs/agent/autoauth') - expect(levelTwo.className).not.toMatch(/open/) - fireEvent.click(levelTwoLink) - expect(levelTwo.className).toMatch(/open/) + it('renders accurately when the current page is an "overview"', () => { + const currentPath = 'agent/autoauth/methods' + const expectedHref = `/docs/${currentPath}` + render(<DocsSidenav {...defaultProps} currentPath={currentPath} />) + // Check the "overview" index node we've set as active using currentPath + const activeIndexLeaf = screen + .getAllByText('Overview') + .map((node) => node.closest('a')) + .filter((linkElem) => { + return linkElem.getAttribute('href') === expectedHref + })[0] + expect(activeIndexLeaf.getAttribute('data-is-active')).toBe('true') }) - it('should show/hide the menu when the "menu" button is clicked on mobile', async () => { + it('expands and collapses nav branch items when clicked', () => { render(<DocsSidenav {...defaultProps} />) - - const mobileMenu = screen.getByTestId('mobile-menu') - const sidebarNav = screen.getByTestId('root') - - expect(sidebarNav).not.toHaveClass('open') - fireEvent.click(mobileMenu) - expect(sidebarNav).toHaveClass('open') - fireEvent.click(mobileMenu) - expect(sidebarNav).not.toHaveClass('open') + // Ensure the element exists, and is currently open + const branchTwo = screen.getByText('Auto-Auth').closest('button') + expect(branchTwo.getAttribute('data-is-active')).toBe('true') + expect(branchTwo.getAttribute('data-is-open')).toBe('true') + // Click the item, then ensure it's closed, but still active + fireEvent.click(branchTwo) + expect(branchTwo.getAttribute('data-is-active')).toBe('true') + expect(branchTwo.getAttribute('data-is-open')).toBe('false') + // Click it again, and it should be open again, still active + fireEvent.click(branchTwo) + expect(branchTwo.getAttribute('data-is-active')).toBe('true') + expect(branchTwo.getAttribute('data-is-open')).toBe('true') }) - it('should error when a category is used with no content', () => { - expectThrow(() => { - render(<DocsSidenav {...defaultProps} order={[{ category: 'test' }]} />) - }, 'The item "test" within "data/docs-navigation.js" has a category but no content, indicating that there is a folder that contains only an "index.mdx" file, which is not allowed. To fix this, move and rename "pages/docs/test/index.mdx" to "pages/docs/test.mdx", then change the value from "{ category: \'test\' }" to just "test"') - }) - - it('should error when a page is not found within a category', () => { - expectThrow(() => { - render( - <DocsSidenav - {...defaultProps} - order={[ - { - category: 'agent', - content: [{ category: 'test', content: ['foo'] }], - }, - ]} - /> - ) - }, 'The page "foo" was not found within the category "agent/test". Please double-check to ensure that "docs/agent/test/foo.mdx" exists. If this page was never intended to exist, remove the key "foo" from the category "agent/test" in "data/docs-navigation.js"') - }) - - it('should error when a direct link does not use both "title" and "href"', () => { - expectThrow(() => { - render(<DocsSidenav {...defaultProps} order={[{ title: 'foo' }]} />) - }, 'Malformed direct sidebar link:\n\n {"title":"foo"}\n\nDirect links must have a "href" and a "title" property.') - - expectThrow(() => { - render(<DocsSidenav {...defaultProps} order={[{ href: 'bar' }]} />) - }, 'Malformed direct sidebar link:\n\n {"href":"bar"}\n\nDirect links must have a "href" and a "title" property.') - }) - - it('should error when a category contains no index file and no name', () => { - expectThrow(() => { - render( - <DocsSidenav - {...defaultProps} - order={[ - { - category: 'agent', - content: [ - { - category: 'no-index-test', - content: ['foo'], - }, - ], - }, - ]} - /> - ) - }, 'An index page or "name" property is required for all categories.\nIf you would like an index page for this category, please add an index file at the path "agent/no-index-test/index.mdx".\nIf you do not want an index page for this category, please add a "name" property to the category object to specify the category\'s human-readable title.\n\nItem:\n{\n "category": "no-index-test",\n "content": [\n "foo"\n ],\n "stack": [\n "agent",\n "no-index-test"\n ]\n}') - }) + it('shows and hides the mobile menu when the "menu" button is clicked', () => { + render(<DocsSidenav {...defaultProps} />) - it('should error when a category uses a name property and specifies an overview page', () => { - expectThrow(() => { - render( - <DocsSidenav - {...defaultProps} - order={[ - { - category: 'agent', - content: [ - { - category: 'no-index-test', - name: 'No Index Test', - content: ['overview'], - }, - ], - }, - ]} - /> - ) - }, 'The category "agent/no-index-test" is using a "name" property to indicate that it has no index, but also has a manually added "overview" page. This can be fixed with the following steps:\n\n- Change the "overview.mdx" page to be "index.mdx"\n- Remove the "name" property from the "no-index-test" data, instead indicate the category\'s name using the frontmatter on the new "index.mdx" page') + // Get the sidebar nav list + // (it's the only role=list element with data-is-mobile-open defined) + const sidebarNavList = screen.getAllByRole('list')[0] + expect(sidebarNavList.nodeName).toBe('UL') + expect(sidebarNavList.getAttribute('data-is-mobile-open')).toBe('false') + // Get the menu button + const mobileMenuToggle = screen + .getByText('Documentation Menu') + .closest('button') + // Click the menu button, and check the sidebar opens + fireEvent.click(mobileMenuToggle) + expect(sidebarNavList.getAttribute('data-is-mobile-open')).toBe('true') + // Click the menu button again, and check the sidebar closes + fireEvent.click(mobileMenuToggle) + expect(sidebarNavList.getAttribute('data-is-mobile-open')).toBe('false') }) }) diff --git a/packages/docs-sidenav/package-lock.json b/packages/docs-sidenav/package-lock.json index 0679c0b20..c1ad4a81b 100644 --- a/packages/docs-sidenav/package-lock.json +++ b/packages/docs-sidenav/package-lock.json @@ -4,23 +4,10 @@ "lockfileVersion": 1, "requires": true, "dependencies": { - "@hashicorp/react-link-wrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@hashicorp/react-link-wrap/-/react-link-wrap-0.0.3.tgz", - "integrity": "sha512-BLH7SLMJZee8md+YU7seiOh0zpa7y8sEhEATYpK2ieFanXAQEgU58lNSC/V3ZdkSXrfyplftYuOj0E3APtE1pg==", - "requires": { - "is-absolute-url": "^3.0.3" - } - }, "fuzzysearch": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fuzzysearch/-/fuzzysearch-1.0.3.tgz", "integrity": "sha1-3/yA9tawQiPyImqnndGUIxCW0Ag=" - }, - "is-absolute-url": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", - "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==" } } } diff --git a/packages/docs-sidenav/package.json b/packages/docs-sidenav/package.json index 0ec99fd0b..06eaba39e 100644 --- a/packages/docs-sidenav/package.json +++ b/packages/docs-sidenav/package.json @@ -7,12 +7,12 @@ "Jeff Escalante" ], "dependencies": { - "@hashicorp/react-link-wrap": "^0.0.3", + "@hashicorp/react-link-wrap": "^2.0.2", "fuzzysearch": "1.0.3" }, "license": "MPL-2.0", "peerDependencies": { - "@hashicorp/nextjs-scripts": ">16.x", + "@hashicorp/nextjs-scripts": ">16.2", "react": "^16.9.0" }, "publishConfig": { diff --git a/packages/docs-sidenav/props.js b/packages/docs-sidenav/props.js index affb62107..d0c47729f 100644 --- a/packages/docs-sidenav/props.js +++ b/packages/docs-sidenav/props.js @@ -1,172 +1,31 @@ +const sampleNavData = require('./fixtures/nav-data.json') +const sharedProps = require('../../props') + module.exports = { - product: { - type: 'string', - description: 'Name of the current product for color theming.', - testValue: 'nomad', - options: [ - 'hashicorp', // default - 'boundary', - 'consul', - 'nomad', - 'packer', - 'terraform', - 'vault', - 'vagrant', - 'waypoint', - ], - }, - currentPage: { + product: sharedProps.product, + currentPath: { type: 'string', description: - 'Path to the current page, used to select the currently active page.', - testValue: '/docs/agent/autoauth/methods/aws', + 'Path to the current page, relative to the `baseRoute`. Used to highlight the current page.', + testValue: 'agent/autoauth/methods/aws', }, - category: { + baseRoute: { type: 'string', - description: 'Top level navigation category, for example docs, api, etc.', + required: true, + description: + 'Top level navigation route, for example `docs`, `api-docs`, etc.', testValue: 'docs', }, disableFilter: { type: 'boolean', - description: 'If true, disable the sidebar filter input', + description: 'If `true`, disable the sidebar filter input.', testValue: false, }, - order: { + navData: { type: 'object', - description: 'user-defined navigation configuration object', - testValue: [ - { - category: 'agent', - content: [ - { - category: 'autoauth', - content: [ - { - category: 'methods', - content: [ - { title: 'External Link', href: 'https://google.com' }, - 'alicloud', - 'aws', - 'azure', - '----------', - 'gcp', - 'jwt', - 'kubernetes', - ], - }, - { - category: 'sinks', - content: ['file'], - }, - 'aws', - ], - }, - // { category: 'test' }, - { - category: 'no-index-test', - name: 'No Index Category', - content: ['foo'], - }, - { - category: 'only-index-test', - content: [], - }, - // { category: 'only-index-no-content' } - ], - }, - ], - }, - data: { - type: 'array', + required: true, description: - 'array of frontmatter data objects from all pages that need to be displayed within the nav', - properties: [ - { - type: 'object', - properties: { - __resourcePath: { type: 'string' }, - page_title: { type: 'string' }, - sidebar_title: { type: 'string' }, - }, - }, - ], - testValue: [ - { - __resourcePath: 'docs/agent/autoauth/methods/gcp.mdx', - page_title: 'Vault Agent Auto-Auth GCP Method', - sidebar_title: '<code>GCP</code>', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/index.mdx', - page_title: 'Vault Agent Auto-Auth Methods', - sidebar_title: 'Methods', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/aws.mdx', - page_title: 'Vault Agent Auto-Auth AWS Method', - sidebar_title: '<code>AWS</code>', - sidebar_current: 'docs-agent-autoauth-methods-aws', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/kubernetes.mdx', - page_title: 'Vault Agent Auto-Auth Kubernetes Method', - sidebar_title: 'Kubernetes', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/azure.mdx', - page_title: 'Vault Agent Auto-Auth Azure Method', - sidebar_title: 'Azure', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/alicloud.mdx', - page_title: 'Vault Agent Auto-Auth AliCloud Method', - sidebar_title: 'AliCloud', - }, - { - __resourcePath: 'docs/agent/autoauth/methods/jwt.mdx', - page_title: 'Vault Agent Auto-Auth JWT Method', - sidebar_title: 'JWT', - }, - { - __resourcePath: 'docs/agent/autoauth/index.mdx', - page_title: 'Vault Agent Auto-Auth', - sidebar_title: 'Auto-Auth', - }, - { - __resourcePath: 'docs/agent/autoauth/sinks/file.mdx', - page_title: 'Vault Agent Auto-Auth File Sink', - sidebar_title: 'File', - }, - { - __resourcePath: 'docs/agent/autoauth/sinks/index.mdx', - page_title: 'Vault Agent Auto-Auth Sinks', - sidebar_title: 'Sinks', - }, - { - __resourcePath: 'docs/agent/index.mdx', - page_title: 'Vault Agent', - sidebar_title: 'Vault Agent', - }, - { - __resourcePath: 'docs/agent/test/index.mdx', - page_title: 'Test Item', - }, - { - __resourcePath: 'docs/agent/no-index-test/foo.mdx', - page_title: 'Foo Item', - }, - { - __resourcePath: 'docs/agent/autoauth/aws.mdx', - page_title: '<code>AWS</code>', - }, - { - __resourcePath: 'docs/agent/only-index-test/index.mdx', - page_title: 'Only Index Test <sup>ENT</sup>', - }, - { - __resourcePath: 'docs/agent/only-index-no-content/index.mdx', - page_title: 'Only Index No Content <sup>ENT</sup>', - }, - ], + 'Tree of navigation data to render. See `docs-sidenav/types.js` for details.', + testValue: sampleNavData, }, } diff --git a/packages/docs-sidenav/style.css b/packages/docs-sidenav/style.css deleted file mode 100644 index 0854ca2ec..000000000 --- a/packages/docs-sidenav/style.css +++ /dev/null @@ -1,276 +0,0 @@ -.g-docs-sidenav { - --highlight-color: var( - --brand - ); /* color overridden per product by themeClass */ - - & > .nav { - width: 275px; - } - - & .nav { - padding-left: 5px; - z-index: 900; - } - - & .filter { - color: var(--gray-4); - border: 1px solid var(--gray-4); - background: var(--white); - border-radius: 2px; - padding: 8px; - width: 100%; - margin-bottom: 10px; - max-width: 90%; - - &[data-has-error='true'] { - border-color: var(--danger); - } - - &::placeholder { - opacity: 0.8; - } - } - - & .mobile-close { - position: absolute; - top: 18px; - right: 13px; - font-size: 1.7em; - padding: 0 14px; - cursor: pointer; - color: #333; - transition: opacity 0.3s ease; - display: none; - z-index: 100; - - &:hover { - opacity: 0.7; - } - - @media (max-width: 939px) { - display: block; - } - } - - & ul { - list-style: none; - margin: 0; - padding: 0; - - & a { - color: var(--DEPRECATED-gray-4); - padding: 7px 0 7px 12px; - display: block; - transition: color 0.2s ease; - cursor: pointer; - - &:hover { - color: var(--DEPRECATED-gray-2); - } - } - - & hr { - background: none; - padding: 8px 0; - margin: 0; - - &::after { - content: ''; - border-bottom: 1px solid var(--DEPRECATED-gray-9); - display: block; - width: 90%; - - @media (max-width: 939px) { - width: 100%; - } - } - } - - & > li { - position: relative; - - &.active, - &.dir { - &::after, - &::before { - content: ''; - display: block; - position: absolute; - border-radius: 50%; - } - - &::before { - background: var(--white); - } - } - - &.active { - & > a, - & > span > a { - color: var(--highlight-color); - position: relative; - } - - &:not(.dir) { - &::after { - width: 4px; - height: 4px; - background: var(--highlight-color); - left: -2px; - top: 18px; - border-radius: 50%; - } - - &::before { - width: 14px; - height: 14px; - left: -7px; - top: 13px; - border-radius: 50%; - } - } - } - - &.dir { - & .chevron { - border-radius: 50%; - background: var(--white); - position: absolute; - top: 13px; - left: -6px; - width: 12px; - height: 16px; - text-align: center; - padding-top: 4px; - padding-left: 4px; - transition: transform 0.15s ease; - } - - &.active > span > a > .chevron path { - fill: var(--highlight-color); - } - - &.open > span > a > .chevron { - transform: rotate(90deg); - } - } - - &.external { - & > a { - display: inline-block; - position: relative; - - &::after { - content: ''; - display: block; - width: 12px; - height: 12px; - background: url('./img/external.svg'); - position: absolute; - right: -20px; - top: 15px; - opacity: 0.25; - transition: opacity 0.25s ease; - } - - &:hover::after { - opacity: 0.5; - } - } - } - - & > ul > li, - & > ul > hr { - display: none; - } - - &.open > ul > li, - &.open > ul > hr { - display: block; - } - - & > ul { - & > li { - margin-left: 21.5px; - border-left: 1px solid var(--DEPRECATED-gray-9); - } - - & > hr { - border-left: 1px solid var(--DEPRECATED-gray-9); - margin-left: 21.5px; - } - } - } - } - - &.open { - & > ul { - @media (max-width: 939px) { - box-shadow: 2px 2px 20px rgba(37, 38, 45, 0.2); - transform: translateX(100%); - padding-left: 25px; - } - } - - & .toggle { - transition-delay: 0s; - z-index: 102; - } - } - - & > ul { - @media (max-width: 939px) { - background: var(--white); - bottom: 0; - right: 100%; - min-width: 375px; - padding: 25px 32px 120px; - position: fixed; - overflow: auto; - transition: 0.3s ease-in-out; - transition-property: box-shadow, transform; - top: 0; - max-width: 100%; - z-index: 101; - } - - @media (max-width: 375px) { - min-width: 100%; - } - } - - & .toggle { - align-items: center; - background: var(--white); - bottom: 0; - border-top: 1px solid var(--gray-6); - border-bottom: 1px solid var(--gray-6); - cursor: pointer; - display: none; - justify-content: center; - left: 0; - padding: 12px; - transition-delay: 0.3s; /* waits for menu to close before adjusting z-index */ - width: 100%; - z-index: 74; /* less than product-subnav */ - - @media (max-width: 939px) { - display: flex; - } - - & span { - align-items: center; - display: flex; - justify-content: center; - } - - & svg { - margin-right: 12px; - } - } - - & code { - font-size: 1em; - line-height: unset; - } -} diff --git a/packages/docs-sidenav/style.module.css b/packages/docs-sidenav/style.module.css new file mode 100644 index 000000000..737c58879 --- /dev/null +++ b/packages/docs-sidenav/style.module.css @@ -0,0 +1,241 @@ +@custom-media --mobile-viewports (max-width: 939px); + +.root { + position: relative; + + @media (--mobile-viewports) { + z-index: 901; /* higher than g-subnav */ + } +} + +.rootList { + list-style: none; + margin: 0; + padding: 0; + width: 275px; + padding-left: 5px; + + @media (--mobile-viewports) { + background: var(--white); + bottom: 0; + right: 100%; + width: min(100%, 375px); + padding: 25px 32px 120px; + position: fixed; + overflow: auto; + transition: 0.3s ease-in-out; + transition-property: box-shadow, transform; + top: 0; + + &[data-is-mobile-open='true'] { + box-shadow: 2px 2px 20px rgba(37, 38, 45, 0.2); + transform: translateX(100%); + padding-left: 25px; + } + + &[data-is-mobile-hidden='true'] { + visibility: hidden; + } + } +} + +.mobileClose { + display: none; + + @media (--mobile-viewports) { + background: none; + border: none; + color: #333; + cursor: pointer; + display: block; + font-size: 1.7em; + line-height: inherit; + padding: 0 14px; + position: absolute; + right: 13px; + top: 18px; + transition: opacity 0.3s ease; + z-index: 100; + + &:hover { + opacity: 0.7; + } + } +} + +.mobileMenuToggle { + align-items: center; + background: var(--white); + bottom: 0; + border: none; + border-top: 1px solid var(--gray-6); + border-bottom: 1px solid var(--gray-6); + cursor: pointer; + display: none; + font-size: inherit; + justify-content: center; + line-height: inherit; + left: 0; + padding: 12px; + transition-delay: 0.3s; /* waits for menu to close before adjusting z-index */ + width: 100%; + z-index: 74; /* less than product-subnav */ + + @media (--mobile-viewports) { + display: flex; + } + + & span { + align-items: center; + display: flex; + justify-content: center; + } + + & svg { + display: block; + margin-right: 12px; + } +} + +.filterInput { + color: var(--gray-2); + border: 1px solid var(--gray-6); + background: var(--white); + border-radius: 2px; + padding: 8px; + width: 100%; + margin-bottom: 10px; + max-width: 90%; + font-family: inherit; + font-size: inherit; + + &[data-has-error='true'] { + border-color: var(--danger); + } +} + +.navItem { + color: var(--DEPRECATED-gray-4); + cursor: pointer; + display: flex; + padding: 7px 0; + transition: color 0.2s ease; + align-items: center; + background: none; + border: none; + font-size: inherit; + font-family: inherit; + line-height: inherit; + + &:hover { + color: var(--DEPRECATED-gray-2); + } + + &[data-is-active='true'] { + color: var(--brand); + + &:hover { + color: var(--brand); + } + } + + & code { + margin-left: 2px; + font-size: 0.875rem; + } +} + +.navItemIcon { + position: relative; + left: -8.5px; + top: 1px; + width: 16px; + height: 16px; + border-radius: 8px; + background: white; + display: flex; + align-items: center; + justify-content: center; + + & svg { + display: block; + & [stroke] { + stroke: var(--gray-4); + } + & [fill] { + fill: var(--gray-4); + } + } + + &[data-is-active='true'] { + & svg { + & [stroke] { + stroke: var(--brand); + } + & [fill] { + fill: var(--brand); + } + } + } +} + +.navLeafIcon { + composes: navItemIcon; + opacity: 0; + + &[data-is-active='true'] { + opacity: 1; + } +} + +.navBranchIcon { + composes: navItemIcon; + transition: transform 0.15s ease; + + &[data-is-open='true'] { + transform: rotate(90deg); + } +} + +.navBranchSubnav { + display: none; + list-style: none; + margin: 0; + padding: 0; + + &[data-is-open='true'] { + display: block; + } + + & > li { + margin-left: 21.5px; + border-left: 1px solid var(--DEPRECATED-gray-9); + } + + & > hr { + border-left: 1px solid var(--DEPRECATED-gray-9); + margin-left: 21.5px; + } +} + +.externalLinkIcon { + display: block; + margin-left: 8px; +} + +.divider { + background: none; + padding: 8px 0; + margin: 0; + + &::after { + content: ''; + border-bottom: 1px solid var(--DEPRECATED-gray-9); + display: block; + width: 90%; + + @media (--mobile-viewports) { + width: 100%; + } + } +} diff --git a/packages/docs-sidenav/types.ts b/packages/docs-sidenav/types.ts new file mode 100644 index 000000000..57cdad876 --- /dev/null +++ b/packages/docs-sidenav/types.ts @@ -0,0 +1,45 @@ +// NavData is an array of NavNodes +export type NavData = NavNode[] + +// A NavNode can be any of these types +export type NavNode = NavLeaf | NavDirectLink | NavDivider | NavBranch + +// A NavLeaf represents a page with content. +// +// The "path" refers to the URL route from the content subpath. +// For all current docs sites, this "path" also +// corresponds to the content location in the filesystem. +// +// Note that "path" can refer to either "named" or "index" files. +// For example, we will automatically resolve the path +// "commands" to either "commands.mdx" or "commands/index.mdx". +// If both exist, we will throw an error to alert authors +// to the ambiguity. +interface NavLeaf { + title: string + path: string +} + +// A NavDirectLink allows linking outside the content subpath. +// +// This includes links on the same domain, +// for example, where the content subpath is `/docs`, +// one can create a direct link with href `/use-cases`. +// +// This also allows for linking to external URLs, +// for example, one could link to `https://hashiconf.com/`. +interface NavDirectLink { + title: string + href: string +} + +// A NavDivider represents a divider line +interface NavDivider { + divider: true +} + +// A NavBranch represents nested navigation data. +interface NavBranch { + title: string + routes: NavNode[] +} diff --git a/packages/docs-sidenav/utils/filter-content.js b/packages/docs-sidenav/utils/filter-content.js new file mode 100644 index 000000000..9d5d0d8b4 --- /dev/null +++ b/packages/docs-sidenav/utils/filter-content.js @@ -0,0 +1,25 @@ +import fuzzysearch from 'fuzzysearch' + +function filterContent(content, searchValue) { + // if there's no search searchValue we short-circuit and return everything + if (!searchValue) return content + // Otherwise we reduce the content array to only matching content + return content.reduce((acc, item) => { + // if this is a divider node, don't show it in filtered results + if (item.divider) return acc + // all other nodes have a title, use it to check if the item is a direct match + const isTitleMatch = fuzzysearch(searchValue, item.title.toLowerCase()) + // For nodes with no children, return early, only add the item if the title matches + if (!item.routes) return isTitleMatch ? acc.concat(item) : acc + // for branch nodes with matching children, return a clone of the + // node with filtered content children + const filteredRoutes = filterContent(item.routes, searchValue) + const filteredItem = isTitleMatch + ? { ...item, __isFiltered: true } + : { ...item, routes: filteredRoutes, __isFiltered: true } + const isCategoryMatch = isTitleMatch || filteredRoutes.length > 0 + return isCategoryMatch ? acc.concat(filteredItem) : acc + }, []) +} + +export default filterContent diff --git a/packages/docs-sidenav/utils/flag-active-nodes.js b/packages/docs-sidenav/utils/flag-active-nodes.js new file mode 100644 index 000000000..9e7b720d0 --- /dev/null +++ b/packages/docs-sidenav/utils/flag-active-nodes.js @@ -0,0 +1,35 @@ +function addIsActiveToNodes(navNodes, currentPath, pathname) { + return navNodes + .slice() + .map((node) => addIsActiveToNode(node, currentPath, pathname)) +} + +function addIsActiveToNode(navNode, currentPath, pathname) { + // If it's a node with child routes, return true + // if any of the child routes are active + if (navNode.routes) { + const routesWithActive = addIsActiveToNodes( + navNode.routes, + currentPath, + pathname + ) + const isActive = routesWithActive.filter((r) => r.__isActive).length > 0 + return { ...navNode, routes: routesWithActive, __isActive: isActive } + } + // If it's a node with a path value, + // return true if the path is a match + if (navNode.path) { + const isActive = navNode.path === currentPath + return { ...navNode, __isActive: isActive } + } + // If it's a direct link, + // return true if the path matches the router.pathname + if (navNode.href) { + const isActive = navNode.href === pathname + return { ...navNode, __isActive: isActive } + } + // Otherwise, it's a divider, so return unmodified + return navNode +} + +export default addIsActiveToNodes diff --git a/packages/docs-sidenav/utils/use-event-listener.js b/packages/docs-sidenav/utils/use-event-listener.js new file mode 100644 index 000000000..20f0c15ad --- /dev/null +++ b/packages/docs-sidenav/utils/use-event-listener.js @@ -0,0 +1,26 @@ +import { useRef, useEffect } from 'react' + +const useEventListener = ( + eventName, + handler, + element = global, + options = {} +) => { + const savedHandler = useRef() + const { capture, passive, once } = options + + useEffect(() => { + savedHandler.current = handler + }, [handler]) + + useEffect(() => { + const isSupported = element && element.addEventListener + if (!isSupported) return + const eventListener = (event) => savedHandler.current(event) + const opts = { capture, passive, once } + element.addEventListener(eventName, eventListener, opts) + return () => element.removeEventListener(eventName, eventListener, opts) + }, [eventName, element, capture, passive, once]) +} + +export default useEventListener diff --git a/packages/docs-sidenav/utils/validate-file-paths/index.js b/packages/docs-sidenav/utils/validate-file-paths/index.js new file mode 100644 index 000000000..05253290f --- /dev/null +++ b/packages/docs-sidenav/utils/validate-file-paths/index.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const path = require('path') + +async function validateFilePaths(navNodes, localDir) { + // Clone the nodes, and validate each one + return await Promise.all( + navNodes.slice(0).map(async (navNode) => { + return await validateNode(navNode, localDir) + }) + ) +} + +async function validateNode(navNode, localDir) { + // Ignore remote leaf nodes, these already + // have their content file explicitly defined + // (note: remote leaf nodes are currently only used + // for Packer plugin documentation) + if (navNode.remoteFile) return navNode + // Handle local leaf nodes + if (navNode.path) { + const indexFilePath = path.join(navNode.path, 'index.mdx') + const namedFilePath = `${navNode.path}.mdx` + const hasIndexFile = fs.existsSync( + path.join(process.cwd(), localDir, indexFilePath) + ) + const hasNamedFile = fs.existsSync( + path.join(process.cwd(), localDir, namedFilePath) + ) + if (!hasIndexFile && !hasNamedFile) { + throw new Error( + `Could not find file to match path "${navNode.path}". Neither "${namedFilePath}" or "${indexFilePath}" could be found.` + ) + } + if (hasIndexFile && hasNamedFile) { + throw new Error( + `Ambiguous path "${navNode.path}". Both "${namedFilePath}" and "${indexFilePath}" exist. Please delete one of these files.` + ) + } + const filePath = path.join( + localDir, + hasIndexFile ? indexFilePath : namedFilePath + ) + return { ...navNode, filePath } + } + // Handle local branch nodes + if (navNode.routes) { + const routesWithFilePaths = await validateFilePaths( + navNode.routes, + localDir + ) + return { ...navNode, routes: routesWithFilePaths } + } + // Return all other node types unmodified + return navNode +} + +module.exports = validateFilePaths diff --git a/packages/docs-sidenav/utils/validate-file-paths/index.test.js b/packages/docs-sidenav/utils/validate-file-paths/index.test.js new file mode 100644 index 000000000..bee8fa0b5 --- /dev/null +++ b/packages/docs-sidenav/utils/validate-file-paths/index.test.js @@ -0,0 +1,61 @@ +import path from 'path' +import validateFilePaths from './' + +// We have a content folder fixture set up so that +// we can properly test this function +const CONTENT_DIR = 'packages/docs-sidenav/fixtures/content' + +describe('<DocsSidenav /> - validate-file-paths', () => { + it('resolves the path for a named .mdx file', async () => { + const navData = [ + { + title: 'What is Vault?', + path: 'what-is-vault', + }, + ] + const withFilePaths = await validateFilePaths(navData, CONTENT_DIR) + const resolvedPath = withFilePaths[0].filePath + expect(resolvedPath).toBe(path.join(CONTENT_DIR, 'what-is-vault.mdx')) + }) + + it('resolves the path for an index.mdx file', async () => { + const navData = [ + { + title: 'Vault Agent', + routes: [ + { + title: 'Overview', + path: 'agent', + }, + ], + }, + ] + const withFilePaths = await validateFilePaths(navData, CONTENT_DIR) + const resolvedPath = withFilePaths[0].routes[0].filePath + expect(resolvedPath).toBe(path.join(CONTENT_DIR, 'agent', 'index.mdx')) + }) + + it('throws an error if there is a NavLeaf with a missing file', async () => { + const navData = [ + { + title: 'Missing File Example', + path: 'this-file-should-not-exist', + }, + ] + await expect(validateFilePaths(navData, CONTENT_DIR)).rejects.toThrow( + 'Could not find file to match path "this-file-should-not-exist". Neither "this-file-should-not-exist.mdx" or "this-file-should-not-exist/index.mdx" could be found.' + ) + }) + + it('throws an error if there is a NavLeaf with an ambiguous file', async () => { + const navData = [ + { + title: 'Ambiguous File Example', + path: 'ambiguous', + }, + ] + await expect(validateFilePaths(navData, CONTENT_DIR)).rejects.toThrow( + `Ambiguous path "ambiguous". Both "ambiguous.mdx" and "ambiguous/index.mdx" exist. Please delete one of these files.` + ) + }) +}) diff --git a/packages/docs-sidenav/utils/validate-route-structure/index.js b/packages/docs-sidenav/utils/validate-route-structure/index.js new file mode 100644 index 000000000..c19298069 --- /dev/null +++ b/packages/docs-sidenav/utils/validate-route-structure/index.js @@ -0,0 +1,154 @@ +function validateRouteStructure(navData) { + return validateBranchRoutes(navData)[1] +} + +function validateBranchRoutes(navNodes, depth = 0) { + // In order to be a valid branch, there needs to be at least one navNode. + if (navNodes.length === 0) { + throw new Error( + `Found empty array of navNodes at depth ${depth}. There must be more than one route.` + ) + } + // Augment each navNode with its path __stack + const navNodesWithStacks = navNodes.map((navNode) => { + // Handle leaf nodes - split their paths into a __stack + if (typeof navNode.path !== 'undefined') { + if (navNode.path == '') { + throw new Error( + `Empty path value on NavLeaf. Path values must be non-empty strings. Node: ${JSON.stringify( + navNode + )}.` + ) + } + if (!navNode.title) { + throw new Error( + `Missing nav-data title. Please add a non-empty title to the node with the path "${navNode.path}".` + ) + } + return { ...navNode, __stack: navNode.path.split('/') } + } + // Handle branch nodes - we recurse depth-first here + if (navNode.routes) { + const nodeWithStacks = handleBranchNode(navNode, depth) + if (!navNode.title) { + const branchPath = nodeWithStacks.__stack.join('/') + throw new Error( + `Missing nav-data title on NavBranch. Please add a title to the node with the inferred path "${branchPath}".` + ) + } + return nodeWithStacks + } + // Handle direct link nodes, identifiable + // by the presence of an href, to ensure they have a title + if (typeof navNode.href !== 'undefined') { + if (navNode.href == '') { + throw new Error( + `Empty href value on NavDirectLink. href values must be non-empty strings. Node: ${JSON.stringify( + navNode + )}.` + ) + } + if (!navNode.title) { + throw new Error( + `Missing nav-data title on NavDirectLink. Please add a title to the node with href "${navNode.href}".` + ) + } + // Otherwise, we have a valid direct link node, we return it + return navNode + } + // Ensure the only other node type is + // a divider node, if not, throw an error + if (!navNode.divider) { + throw new Error( + `Unrecognized nav-data node. Please ensure all nav-data nodes are either NavLeaf, NavBranch, NavDirectLink, or NavDivider types. Invalid node: ${JSON.stringify( + navNode + )}.` + ) + } + // Other nodes, really just divider nodes, + // aren't relevant, so we don't touch them + return navNode + }) + // Gather all the path stacks at this level + const routeStacks = navNodesWithStacks.reduce((acc, navNode) => { + // Ignore nodes that don't have a path stack + if (!navNode.__stack) return acc + // For other nodes, add their stacks + return acc.concat([navNode.__stack]) + }, []) + // Ensure that there are no duplicate routes + // (for example, a nested route with a particular path, + // and a named page at the same level with the same path) + const routePaths = routeStacks.map((s) => s.join('/')) + const duplicateRoutes = routePaths.filter((value, index, self) => { + return self.indexOf(value) !== index + }) + if (duplicateRoutes.length > 0) { + throw new Error( + `Duplicate routes found for "${duplicateRoutes[0]}". Please resolve duplicates.` + ) + } + // Gather an array of all resolved paths at this level + const parentRoutes = routeStacks.map((stack) => { + // Index leaf nodes will have the same + // number of path parts as the current nesting depth. + const isIndexNode = stack.length === depth + if (isIndexNode) { + // The "dirPath" for index nodes is + // just the original path + return stack.join('/') + } + // Named leaf nodes, and nested routes, + // will have one more path part than the current nesting depth. + const isNamedNode = stack.length === depth + 1 + if (isNamedNode) { + // The "dirPath" for named nodes is + // the original path with the last part dropped. + return stack.slice(0, stack.length - 1).join('/') + } + // If we have any other number of parts in the + // leaf node's path, then it is invalid. + throw new Error( + `Invalid path depth. At depth ${depth}, found path "${stack.join( + '/' + )}". Please move this path to the correct depth of ${stack.length - 1}.` + ) + }) + // We expect all routes at any level to share the same parent directory. + // In other words, we expect there to be exactly one unique "dirPath" + // shared across all the routes at this level. + const uniqueParents = parentRoutes.filter((value, index, self) => { + return self.indexOf(value) === index + }) + // We throw an error if we find mismatched paths + // that don't share the same parent path. + if (uniqueParents.length > 1) { + throw new Error( + `Found mismatched paths at depth ${depth}: ${JSON.stringify( + uniqueParents + )}.` + ) + } + // Note: some branches may not have any children with paths, + // for example branches with only direct links. So, path may be undefined. + const path = uniqueParents[0] + // Finally, we return + return [path, navNodesWithStacks] +} + +function handleBranchNode(navNode, depth) { + // We recurse depth-first here, and we'll throw an error + // if any nested routes have structural issues + const [path, routesWithStacks] = validateBranchRoutes( + navNode.routes, + depth + 1 + ) + // Path will be undefined if the child routes are + // only non-path nodes (such as direct links). + // In this case, we set __stack to false so this route + // is left out of tree structure validation + const __stack = !path ? false : path.split('/') + return { ...navNode, __stack, routes: routesWithStacks } +} + +module.exports = validateRouteStructure diff --git a/packages/docs-sidenav/utils/validate-route-structure/index.test.js b/packages/docs-sidenav/utils/validate-route-structure/index.test.js new file mode 100644 index 000000000..292c7c89d --- /dev/null +++ b/packages/docs-sidenav/utils/validate-route-structure/index.test.js @@ -0,0 +1,196 @@ +import validateRouteStructure from './' + +describe('<DocsSidenav /> - validate-file-paths', () => { + it("throws an error if a NavLeaf's path is an empty string", () => { + const navData = [ + { + title: 'Whoops I Left The Path Empty', + path: '', + }, + ] + const emptyPathError = `Empty path value on NavLeaf. Path values must be non-empty strings. Node: ${JSON.stringify( + navData[0] + )}.` + expect(() => validateRouteStructure(navData)).toThrow(emptyPathError) + }) + + it("throws an error if a NavLeaf's path is nested at the wrong depth", () => { + const navData = [ + { + title: 'Directory', + routes: [ + { + title: 'Overview', + path: 'directory', + }, + { + title: 'Valid Depth', + path: 'directory/some-file', + }, + { + title: 'Invalid Depth', + path: 'directory/some-nested-dir/some-file', + }, + ], + }, + ] + const depthError = `Invalid path depth. At depth 1, found path "directory/some-nested-dir/some-file". Please move this path to the correct depth of 2.` + expect(() => validateRouteStructure(navData)).toThrow(depthError) + }) + + it('throws an error if an empty array is passed', () => { + const emptyRoutesError = `Found empty array of navNodes at depth 0. There must be more than one route.` + expect(() => validateRouteStructure([])).toThrow(emptyRoutesError) + }) + + it('throws an error if a NavBranch has has an empty array of routes', () => { + const navData = [ + { + title: 'Directory', + routes: [], + }, + ] + const emptyRoutesError = `Found empty array of navNodes at depth 1. There must be more than one route.` + expect(() => validateRouteStructure(navData)).toThrow(emptyRoutesError) + }) + + it('throws an error if sibling routes have different parent routes', () => { + const navData = [ + { + title: 'Directory', + routes: [ + { + title: 'Overview', + path: 'directory', + }, + { + title: 'Valid Parent', + path: 'directory/some-file', + }, + { + title: 'Invalid Parent', + path: 'another-directory/another-file', + }, + ], + }, + ] + const siblingError = `Found mismatched paths at depth 1: ["directory","another-directory"].` + expect(() => validateRouteStructure(navData)).toThrow(siblingError) + }) + + it('throws an error if there are duplicate routes', () => { + const navData = [ + { + title: 'Directory Dupe', + path: 'directory', + }, + { + title: 'Directory', + routes: [ + { + title: 'Overview', + path: 'directory', + }, + { + title: 'Some File', + path: 'directory/some-file', + }, + ], + }, + ] + const duplicateError = `Duplicate routes found for "directory". Please resolve duplicates.` + expect(() => validateRouteStructure(navData)).toThrow(duplicateError) + }) + + it('throws an error if a NavLeaf has a missing title', () => { + const noTitleNode = { path: 'no-title' } + const noTitleError = `Missing nav-data title. Please add a non-empty title to the node with the path "no-title".` + expect(() => validateRouteStructure([noTitleNode])).toThrow(noTitleError) + const emptyTitleNode = { title: '', path: 'empty-title' } + const emptyTitleError = `Missing nav-data title. Please add a non-empty title to the node with the path "empty-title".` + expect(() => validateRouteStructure([emptyTitleNode])).toThrow( + emptyTitleError + ) + }) + + it('throws an error if a NavBranch has a missing title', () => { + const navData = [ + { + routes: [ + { + title: 'Overview', + path: 'some-directory', + }, + { + title: 'Some File', + path: 'some-directory/some-file', + }, + ], + }, + ] + const noTitleError = `Missing nav-data title on NavBranch. Please add a title to the node with the inferred path "some-directory".` + expect(() => validateRouteStructure(navData)).toThrow(noTitleError) + }) + + it('throws an error if a NavDirectLink has a missing title', () => { + const navData = [ + { + href: '/some-direct-link', + }, + ] + const noTitleError = `Missing nav-data title on NavDirectLink. Please add a title to the node with href "/some-direct-link".` + expect(() => validateRouteStructure(navData)).toThrow(noTitleError) + }) + + it('throws an error if a NavDirectLink has an empty href', () => { + const navData = [ + { + title: 'Empty Href Link', + href: '', + }, + ] + const emptyHrefError = `Empty href value on NavDirectLink. href values must be non-empty strings. Node: ${JSON.stringify( + navData[0] + )}.` + expect(() => validateRouteStructure(navData)).toThrow(emptyHrefError) + }) + + it('throws an error for unrecognized nodes', () => { + const navData = [ + { + foo: 'bar', + }, + ] + const emptyHrefError = `Unrecognized nav-data node. Please ensure all nav-data nodes are either NavLeaf, NavBranch, NavDirectLink, or NavDivider types. Invalid node: ${JSON.stringify( + navData[0] + )}.` + expect(() => validateRouteStructure(navData)).toThrow(emptyHrefError) + }) + + it('does not throw an error for a valid nav-data tree with a direct-links-only branch', () => { + const navData = [ + { + title: 'Why Use Packer?', + path: 'why', + }, + { + title: 'Direct Link', + href: 'https://www.hashicorp.com', + }, + { + title: 'Direct Links Only Branch', + routes: [ + { + title: 'Install', + href: '/intro/getting-started/install', + }, + { + title: 'Build An Image', + href: '/intro/getting-started/build-image', + }, + ], + }, + ] + expect(() => validateRouteStructure(navData)).not.toThrow() + }) +}) diff --git a/packages/glossary-page/LICENSE b/packages/glossary-page/LICENSE new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/packages/glossary-page/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/packages/glossary-page/docs.mdx b/packages/glossary-page/docs.mdx index 9d7f5596a..ddfe6c39a 100644 --- a/packages/glossary-page/docs.mdx +++ b/packages/glossary-page/docs.mdx @@ -7,7 +7,7 @@ This is a specialized view built on top of `DocsPage` to render a glossary page <LiveComponent components={{ staticPropsResult: { - content: { + mdxSource: { compiledSource: '"use strict";\n' + '\n' + @@ -150,25 +150,14 @@ bandwidth. `, }, ], - docsPageData: [ - { - __resourcePath: 'docs/test.mdx', - page_title: 'Testing Page', - sidebar_title: 'Testing Page', - }, - { - __resourcePath: 'docs/test2.mdx', - page_title: 'Other Testing Page', - sidebar_title: 'Other Testing Page', - }, - ], + navData: componentProps.staticProps.properties.navData.testValue, }, }} > - {`<GlossaryPage product={{ name: 'Consul', slug: 'consul' }} order={['test', 'test2']} staticProps={staticPropsResult} />`} + {`<GlossaryPage product={{ name: 'Consul', slug: 'consul' }} staticProps={staticPropsResult} />`} </LiveComponent> -<UsageDetails packageName="@hashicorp/react-glossary-page" /> +<UsageDetails packageJson={packageJson} /> ### Props diff --git a/packages/glossary-page/index.js b/packages/glossary-page/index.js index 0f8e9e246..208153544 100644 --- a/packages/glossary-page/index.js +++ b/packages/glossary-page/index.js @@ -17,23 +17,20 @@ function GlossaryTableOfContents({ terms }) { export default function GlossaryPage({ additionalComponents, - mainBranch, - order, product, showEditPage, - staticProps: { content, terms, docsPageData }, + staticProps: { mdxSource, terms, navData, githubFileUrl }, }) { return ( <DocsPageWrapper - allPageData={docsPageData} + navData={navData} description="Glossary" filePath="glossary" - mainBranch={mainBranch} - order={order} - pagePath="/docs/glossary" + githubFileUrl={githubFileUrl} + currentPath="glossary" pageTitle="Glossary" product={{ name: product.name, slug: product.slug }} - subpath="docs" + baseRoute="docs" showEditPage={showEditPage} > <> @@ -45,7 +42,7 @@ export default function GlossaryPage({ community. </p> <GlossaryTableOfContents terms={terms} /> - {hydrate(content, { + {hydrate(mdxSource, { components: generateComponents(product.name, additionalComponents), })} </> diff --git a/packages/glossary-page/package-lock.json b/packages/glossary-page/package-lock.json index fc340318a..44a2fb35f 100644 --- a/packages/glossary-page/package-lock.json +++ b/packages/glossary-page/package-lock.json @@ -4,6 +4,192 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@algolia/cache-browser-local-storage": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.8.6.tgz", + "integrity": "sha512-Bam7otzjIEgrRXWmk0Amm1+B3ROI5dQnUfJEBjIy0YPM0kMahEoJXCw6160tGKxJLl1g6icoC953nGshQKO7cA==", + "requires": { + "@algolia/cache-common": "4.8.6" + } + }, + "@algolia/cache-common": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.8.6.tgz", + "integrity": "sha512-eGQlsXU5G7n4RvV/K6qe6lRAeL6EKAYPT3yZDBjCW4pAh7JWta+77a7BwUQkTqXN1MEQWZXjex3E4z/vFpzNrg==" + }, + "@algolia/cache-in-memory": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.8.6.tgz", + "integrity": "sha512-kbJrvCFANxL/l5Pq1NFyHLRphKDwmqcD/OJga0IbNKEulRGDPkt1+pC7/q8d2ikP12adBjLLg2CVias9RJpIaw==", + "requires": { + "@algolia/cache-common": "4.8.6" + } + }, + "@algolia/client-account": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.8.6.tgz", + "integrity": "sha512-FQVJE/BgCb78jtG7V0r30sMl9P5JKsrsOacGtGF2YebqI0YF25y8Z1nO39lbdjahxUS3QkDw2d0P2EVMj65g2Q==", + "requires": { + "@algolia/client-common": "4.8.6", + "@algolia/client-search": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "@algolia/client-analytics": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.8.6.tgz", + "integrity": "sha512-ZBYFUlzNaWDFtt0rYHI7xbfVX0lPWU9lcEEXI/BlnkRgEkm247H503tNatPQFA1YGkob52EU18sV1eJ+OFRBLA==", + "requires": { + "@algolia/client-common": "4.8.6", + "@algolia/client-search": "4.8.6", + "@algolia/requester-common": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "@algolia/client-common": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.8.6.tgz", + "integrity": "sha512-8dI+K3Nvbes2YRZm2LY7bdCUD05e60BhacrMLxFuKxnBGuNehME1wbxq/QxcG1iNFJlxLIze5TxIcNN3+pn76g==", + "requires": { + "@algolia/requester-common": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "@algolia/client-recommendation": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/client-recommendation/-/client-recommendation-4.8.6.tgz", + "integrity": "sha512-Kg8DpjwvaWWujNx6sAUrSL+NTHxFe/UNaliCcSKaMhd3+FiPXN+CrSkO0KWR7I+oK2qGBTG/2Y0BhFOJ5/B/RA==", + "requires": { + "@algolia/client-common": "4.8.6", + "@algolia/requester-common": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "@algolia/client-search": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.8.6.tgz", + "integrity": "sha512-vXLS6umL/9G3bwqc6pkrS9K5/s8coq55mpfRARL+bs0NsToOf77WSTdwzlxv/KdbVF7dHjXgUpBvJ6RyR4ZdAw==", + "requires": { + "@algolia/client-common": "4.8.6", + "@algolia/requester-common": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "@algolia/logger-common": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.8.6.tgz", + "integrity": "sha512-FMRxZGdDxSzd0/Mv0R1021FvUt0CcbsQLYeyckvSWX8w+Uk4o0lcV6UtZdERVR5XZsGOqoXLMIYDbR2vkbGbVw==" + }, + "@algolia/logger-console": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.8.6.tgz", + "integrity": "sha512-TYw9lwUCjvApC6Z0zn36T6gkCl7hbfJmnU+Z/D8pFJ3Yp7lz06S3oWGjbdrULrYP1w1VOhjd0X7/yGNsMhzutQ==", + "requires": { + "@algolia/logger-common": "4.8.6" + } + }, + "@algolia/requester-browser-xhr": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.8.6.tgz", + "integrity": "sha512-omh6uJ3CJXOmcrU9M3/KfGg8XkUuGJGIMkqEbkFvIebpBJxfs6TVs0ziNeMFAcAfhi8/CGgpLbDSgJtWdGQa6w==", + "requires": { + "@algolia/requester-common": "4.8.6" + } + }, + "@algolia/requester-common": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.8.6.tgz", + "integrity": "sha512-r5xJqq/D9KACkI5DgRbrysVL5DUUagikpciH0k0zjBbm+cXiYfpmdflo/h6JnY6kmvWgjr/4DoeTjKYb/0deAQ==" + }, + "@algolia/requester-node-http": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.8.6.tgz", + "integrity": "sha512-TB36OqTVOKyHCOtdxhn/IJyI/NXi/BWy8IEbsiWwwZWlL79NWHbetj49jXWFolEYEuu8PgDjjZGpRhypSuO9XQ==", + "requires": { + "@algolia/requester-common": "4.8.6" + } + }, + "@algolia/transporter": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.8.6.tgz", + "integrity": "sha512-NRb31J0TP7EPoVMpXZ4yAtr61d26R8KGaf6qdULknvq5sOVHuuH4PwmF08386ERfIsgnM/OBhl+uzwACdCIjSg==", + "requires": { + "@algolia/cache-common": "4.8.6", + "@algolia/logger-common": "4.8.6", + "@algolia/requester-common": "4.8.6" + } + }, + "@babel/runtime": { + "version": "7.13.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.9.tgz", + "integrity": "sha512-aY2kU+xgJ3dJ1eU6FMB9EH8dIe8dmusF1xEku52joLvw6eAFN0AI+WxCLDnpev2LEejWBAy2sBvBOBAjI3zmvA==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@hashicorp/react-content": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@hashicorp/react-content/-/react-content-6.3.0.tgz", + "integrity": "sha512-B+QMlkMGryeNx3dGON4ExbzNvvll2ZXN3x+TkX80tUGClMI80MKjfSXiXIoVixlp22DMNG6wrnL42LC4WzZOxg==" + }, + "@hashicorp/react-docs-page": { + "version": "10.9.4-alpha.57", + "resolved": "https://registry.npmjs.org/@hashicorp/react-docs-page/-/react-docs-page-10.9.4-alpha.57.tgz", + "integrity": "sha512-gIUnCnVa5Zdied5LMmdZpQuvd4fZI8NijUqqWqF8zCC7kX1mYYeBcmeWjnUM2wgYo/7JBiXnHqwCkeAlfwdoeQ==", + "requires": { + "@hashicorp/react-content": "^6.3.0", + "@hashicorp/react-docs-sidenav": "6.1.1-alpha.60", + "@hashicorp/react-head": "^1.2.0", + "@hashicorp/react-search": "^4.1.0", + "fs-exists-sync": "0.1.0", + "gray-matter": "4.0.2", + "js-yaml": "3.14.0", + "line-reader": "0.4.0", + "moize": "^5.4.7", + "readdirp": "3.5.0" + } + }, + "@hashicorp/react-docs-sidenav": { + "version": "6.1.1-alpha.60", + "resolved": "https://registry.npmjs.org/@hashicorp/react-docs-sidenav/-/react-docs-sidenav-6.1.1-alpha.60.tgz", + "integrity": "sha512-mfR6Wl4R4k5KD7lJpl0l4pn4NecmoqV6HxQ+nvMW/d6nIkb6/xWxynmDkEeiyEVpphLKqJFh6boWuFYaar7PbA==", + "requires": { + "@hashicorp/react-link-wrap": "^2.0.2", + "fuzzysearch": "1.0.3" + } + }, + "@hashicorp/react-head": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@hashicorp/react-head/-/react-head-1.2.0.tgz", + "integrity": "sha512-6BNmhsrzVwJFOAcT3WhSeDlCdtlD3d7vzhXOGfkpPYVnYRaIpLLC6seemAr/wqZhYB87W+KvFilz8vZcpDAZzQ==" + }, + "@hashicorp/react-inline-svg": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@hashicorp/react-inline-svg/-/react-inline-svg-1.0.2.tgz", + "integrity": "sha512-AAFnBslSTgnEr++dTbMn3sybAqvn7myIj88ijGigF6u11eSRiV64zqEcyYLQKWTV6dF4AvYoxiYC6GSOgiM0Yw==" + }, + "@hashicorp/react-link-wrap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@hashicorp/react-link-wrap/-/react-link-wrap-2.0.2.tgz", + "integrity": "sha512-q8s2TTd9Uy3BSYyUe2TTr2Kbc0ViRc7XQga2fZI0bzlFqBTiMXtf6gh2cg3QvimHY42y4YtaO5C109V9ahMUpQ==" + }, + "@hashicorp/react-search": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@hashicorp/react-search/-/react-search-4.1.0.tgz", + "integrity": "sha512-TZChez9q/4bn/flQXRo0h/9B0kDMvin759hd8+vRrt1M3Qhz2C1TKpfZRKrX6dFZI8w4obGm1EzUzR130gdFfQ==", + "requires": { + "@hashicorp/react-inline-svg": "^1.0.2", + "@hashicorp/remark-plugins": "^3.0.0", + "algoliasearch": "^4.8.4", + "dotenv": "^8.2.0", + "glob": "^7.1.6", + "gray-matter": "^4.0.2", + "react-instantsearch-dom": "^6.9.0", + "remark": "^12.0.1", + "search-insights": "^1.6.0", + "unist-util-visit": "^2.0.3" + } + }, "@hashicorp/remark-plugins": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@hashicorp/remark-plugins/-/remark-plugins-3.1.1.tgz", @@ -31,11 +217,62 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==" }, + "algoliasearch": { + "version": "4.8.6", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.8.6.tgz", + "integrity": "sha512-G8IA3lcgaQB4r9HuQ4G+uSFjjz0Wv2OgEPiQ8emA+G2UUlroOfMl064j1bq/G+QTW0LmTQp9JwrFDRWxFM9J7w==", + "requires": { + "@algolia/cache-browser-local-storage": "4.8.6", + "@algolia/cache-common": "4.8.6", + "@algolia/cache-in-memory": "4.8.6", + "@algolia/client-account": "4.8.6", + "@algolia/client-analytics": "4.8.6", + "@algolia/client-common": "4.8.6", + "@algolia/client-recommendation": "4.8.6", + "@algolia/client-search": "4.8.6", + "@algolia/logger-common": "4.8.6", + "@algolia/logger-console": "4.8.6", + "@algolia/requester-browser-xhr": "4.8.6", + "@algolia/requester-common": "4.8.6", + "@algolia/requester-node-http": "4.8.6", + "@algolia/transporter": "4.8.6" + } + }, + "algoliasearch-helper": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.4.4.tgz", + "integrity": "sha512-OjyVLjykaYKCMxxRMZNiwLp8CS310E0qAeIY2NaublcmLAh8/SL19+zYHp7XCLtMem2ZXwl3ywMiA32O9jszuw==", + "requires": { + "events": "^1.1.1" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, "bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==" }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "ccount": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", @@ -61,21 +298,79 @@ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==" }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "collapse-white-space": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz", "integrity": "sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==" }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, "emoji-regex": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.1.1.tgz", "integrity": "sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4=" }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "fast-equals": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-1.6.3.tgz", + "integrity": "sha512-4WKW0AL5+WEqO0zWavAfYGY1qwLsBgE//DN4TTcVEN2UlINgkv9b3vm2iHicoenWKSX9mKWmGOsU/iI5IST7pQ==" + }, + "fast-stringify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-stringify/-/fast-stringify-1.1.2.tgz", + "integrity": "sha512-SfslXjiH8km0WnRiuPfpUKwlZjW5I878qsOm+2x8x3TgqmElOOLh1rgJFb+PolNdNRK3r8urEefqx0wt7vx1dA==" + }, + "fs-exists-sync": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz", + "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fuzzysearch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fuzzysearch/-/fuzzysearch-1.0.3.tgz", + "integrity": "sha1-3/yA9tawQiPyImqnndGUIxCW0Ag=" + }, "github-slugger": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz", @@ -84,6 +379,39 @@ "emoji-regex": ">=6.0.0 <=6.1.1" } }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "gray-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.2.tgz", + "integrity": "sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==", + "requires": { + "js-yaml": "^3.11.0", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -118,6 +446,11 @@ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==" }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, "is-hexadecimal": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", @@ -138,11 +471,43 @@ "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz", "integrity": "sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==" }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + }, + "line-reader": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/line-reader/-/line-reader-0.4.0.tgz", + "integrity": "sha1-F+RIGNoKwzVnW6MAlU+U72cOZv0=" + }, "longest-streak": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==" }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "markdown-escapes": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", @@ -164,11 +529,42 @@ "unist-util-visit": "^2.0.0" } }, + "micro-memoize": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-2.1.2.tgz", + "integrity": "sha512-COjNutiFgnDHXZEIM/jYuZPwq2h8zMUeScf6Sh6so98a+REqdlpaNS7Cb2ffGfK5I+xfgoA3Rx49NGuNJTJq3w==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "moize": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/moize/-/moize-5.4.7.tgz", + "integrity": "sha512-7PZH8QFJ51cIVtDv7wfUREBd3gL59JB0v/ARA3RI9zkSRa9LyGjS1Bdldii2J1/NQXRQ/3OOVOSdnZrCcVaZlw==", + "requires": { + "fast-equals": "^1.6.0", + "fast-stringify": "^1.1.0", + "micro-memoize": "^2.1.1" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, "parse-entities": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", @@ -182,6 +578,73 @@ "is-hexadecimal": "^1.0.0" } }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "react-instantsearch-core": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/react-instantsearch-core/-/react-instantsearch-core-6.10.3.tgz", + "integrity": "sha512-7twp3OJrPGTFpyXwjJNeOTbQw7RTv+0cUyKkXR9njEyLdXKcPWfpeBirXfdQHjYIHEY2b0V2Vom1B9IHSDSUtQ==", + "requires": { + "@babel/runtime": "^7.1.2", + "algoliasearch-helper": "^3.4.3", + "prop-types": "^15.6.2", + "react-fast-compare": "^3.0.0" + } + }, + "react-instantsearch-dom": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/react-instantsearch-dom/-/react-instantsearch-dom-6.10.3.tgz", + "integrity": "sha512-kxc6IEruxJrc7O9lsLV5o4YK/RkGt3l7D1Y51JfmYkgeLuQHApwgcy/TAIoSN7wfR/1DONFbX8Y5VhU9Wqh87Q==", + "requires": { + "@babel/runtime": "^7.1.2", + "algoliasearch-helper": "^3.4.3", + "classnames": "^2.2.5", + "prop-types": "^15.6.2", + "react-fast-compare": "^3.0.0", + "react-instantsearch-core": "^6.10.3" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==" + }, "remark": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-12.0.1.tgz", @@ -241,6 +704,25 @@ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, + "search-insights": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-1.7.1.tgz", + "integrity": "sha512-CSuSKIJp+WcSwYrD9GgIt1e3xmI85uyAefC4/KYGgtvNEm6rt4kBGilhVRmTJXxRE2W1JknvP598Q7SMhm7qKA==" + }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, "state-toggle": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz", @@ -256,6 +738,11 @@ "xtend": "^4.0.0" } }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" + }, "to-vfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/to-vfile/-/to-vfile-6.1.0.tgz", @@ -381,6 +868,11 @@ "unist-util-stringify-position": "^2.0.0" } }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/packages/glossary-page/package.json b/packages/glossary-page/package.json index 1af005677..0e94cf7d8 100644 --- a/packages/glossary-page/package.json +++ b/packages/glossary-page/package.json @@ -7,17 +7,14 @@ "Bryce Kalow" ], "dependencies": { + "@hashicorp/react-docs-page": "10.9.4-alpha.57", "@hashicorp/remark-plugins": "^3.1.1" }, "license": "MPL-2.0", "peerDependencies": { "@hashicorp/nextjs-scripts": "13.0.0", - "@hashicorp/react-docs-page": "^10.4.0", "react": "^16.9.0" }, - "devDependencies": { - "@hashicorp/react-docs-page": "^10.9.3" - }, "publishConfig": { "access": "public" } diff --git a/packages/glossary-page/props.js b/packages/glossary-page/props.js index 18e0712bf..f1dbeaf70 100644 --- a/packages/glossary-page/props.js +++ b/packages/glossary-page/props.js @@ -1,3 +1,5 @@ +const docsPageProps = require('../docs-page/props') + module.exports = { product: { type: 'object', @@ -25,11 +27,6 @@ module.exports = { }, }, }, - order: { - type: 'object', - description: - 'Pass in the export of a `data/xxx-navigation.js` file, this is the user-defined navigation order and structure. Passed directly to the `order` prop to `@hashicorp/react-docs-sidenav` - see that component for details on object structure.', - }, additionalComponents: { type: 'object', description: @@ -41,15 +38,37 @@ module.exports = { 'if true, an "edit this page" link will appear on the bottom right', default: true, }, - mainBranch: { - type: 'string', - description: - 'The default branch of the project being documented, typically either "master" or "main". Used for the `showEditPage` prop', - default: 'main', - }, staticProps: { type: 'object', description: 'Directly pass the return value of `server/generateStaticProps` in here.', + properties: { + terms: { + type: 'array', + description: + 'A list of glossary terms, passed to `<GlossaryTableOfContents />`', + properties: [ + { + type: 'object', + description: 'A glossary term item', + properties: { + slug: { + type: 'string', + description: + "The term's slug, used to construct an anchor link", + }, + title: { + type: 'string', + description: + "The term's title, used to label an anchor link to the term", + }, + }, + }, + ], + }, + githubFileUrl: docsPageProps.staticProps.properties.githubFileUrl, + mdxSource: docsPageProps.staticProps.properties.mdxSource, + navData: docsPageProps.staticProps.properties.navData, + }, }, } diff --git a/packages/glossary-page/server.js b/packages/glossary-page/server.js index c1fc17f7f..698ccc5c1 100644 --- a/packages/glossary-page/server.js +++ b/packages/glossary-page/server.js @@ -2,33 +2,42 @@ import renderToString from 'next-mdx-remote/render-to-string' import matter from 'gray-matter' import fs from 'fs' import path from 'path' -import { fastReadFrontMatter } from '@hashicorp/react-docs-page/server' +import { validateFilePaths } from '@hashicorp/react-docs-page/server' import generateComponents from '@hashicorp/react-docs-page/components' import markdownDefaults from '@hashicorp/nextjs-scripts/markdown' import generateSlug from '@hashicorp/remark-plugins/generate_slug' export default async function generateStaticProps({ + navDataFile, additionalComponents, - productName, + product, + mainBranch = 'main', }) { const docsPath = path.join(process.cwd(), 'content', 'docs') - const docsPageData = (await fastReadFrontMatter(docsPath)).map((p) => { - p.__resourcePath = `docs/${p.__resourcePath}` - return p - }) + // Read in the nav-data.json file + const navDataFilePath = path.join(process.cwd(), navDataFile) + const navDataRaw = JSON.parse(fs.readFileSync(navDataFilePath, 'utf8')) + const navData = await validateFilePaths(navDataRaw, docsPath) + // Construct the mdxSource from the provided terms const { terms, mdxBlob } = await getGlossaryTerms() + const mdxSource = await renderToString(mdxBlob, { + mdxOptions: markdownDefaults({ + resolveIncludes: path.join(process.cwd(), 'content/partials'), + }), + components: generateComponents(product.name, additionalComponents), + }) + + // Construct the githubFileUrl, used for "Edit this page" link + const githubFileUrl = `https://github.com/hashicorp/${product.slug}/blob/${mainBranch}/website/content/glossary` + return { props: { terms, - content: await renderToString(mdxBlob, { - mdxOptions: markdownDefaults({ - resolveIncludes: path.join(process.cwd(), 'content/partials'), - }), - components: generateComponents(productName, additionalComponents), - }), - docsPageData, + mdxSource, + navData, + githubFileUrl, }, } } diff --git a/pages/global.css b/pages/global.css index 9ee5aa1d7..73d718068 100644 --- a/pages/global.css +++ b/pages/global.css @@ -11,7 +11,6 @@ @import '../packages/button/styles/index.css'; @import '../packages/callouts/styles/index.css'; @import '../packages/checkbox-input/style.css'; -@import '../packages/docs-sidenav/style.css'; @import '../packages/accordion/style.css'; @import '../packages/subnav/style.css'; @import '../packages/call-to-action/style.css';