diff --git a/.github/workflows/check-unlinked-content.js b/.github/workflows/check-unlinked-content.js new file mode 100644 index 000000000..22b5c97ea --- /dev/null +++ b/.github/workflows/check-unlinked-content.js @@ -0,0 +1,141 @@ +var fs = require("fs"); +var path = require("path"); + +const COLOR_RESET = "\x1b[0m"; +const COLOR_GREEN = "\x1b[32m"; +const COLOR_RED = "\x1b[31m"; + +runCheck([ + { + contentDir: "website/content/docs", + navDataFiles: [ + "website/data/docs-nav-data.json", + "website/data/docs-nav-data-hidden.json", + ], + }, + { + contentDir: "website/content/intro", + navDataFiles: ["website/data/intro-nav-data.json"], + }, + { + contentDir: "website/content/vmware", + navDataFiles: ["website/data/vmware-nav-data.json"], + }, +]); + +async function runCheck(baseRoutes) { + const validatedBaseRoutes = await Promise.all( + baseRoutes.map(async ({ contentDir, navDataFiles }) => { + const missingRoutes = await validateMissingRoutes( + contentDir, + navDataFiles + ); + return { contentDir, navDataFiles, missingRoutes }; + }) + ); + const allMissingRoutes = validatedBaseRoutes.reduce((acc, baseRoute) => { + return acc.concat(baseRoute.missingRoutes); + }, []); + if (allMissingRoutes.length == 0) { + console.log( + `\n${COLOR_GREEN}✓ All content files have routes, and are included in navigation data.${COLOR_RESET}\n` + ); + } else { + validatedBaseRoutes.forEach( + ({ contentDir, navDataFiles, missingRoutes }) => { + if (missingRoutes.length == 0) return true; + console.log( + `\n${COLOR_RED}Error: Missing pages found in the ${contentDir} directory.\n\nPlease add these paths to ${navDataFiles.join( + " or " + )}, or remove the .mdx files.\n\n${JSON.stringify( + missingRoutes, + null, + 2 + )}${COLOR_RESET}\n\n` + ); + } + ); + process.exit(1); + } +} + +async function validateMissingRoutes(contentDir, navDataFiles) { + // Read in nav-data.json, and make a flattened array of nodes + const navDataFlat = navDataFiles.reduce((acc, navDataFile) => { + const navDataPath = path.join(process.cwd(), navDataFile); + const navData = JSON.parse(fs.readFileSync(navDataPath)); + return acc.concat(flattenNodes(navData)); + }, []); + // Read all files in the content directory + const files = await walkAsync(contentDir); + // Filter out content files that are already + // included in nav-data.json + const missingPages = files + // Ignore non-.mdx files + .filter((filePath) => { + return path.extname(filePath) == ".mdx"; + }) + // Transform the filePath into an expected route + .map((filePath) => { + // Get the relative filepath, that's what we'll see in the route + const contentDirPath = path.join(process.cwd(), contentDir); + const relativePath = path.relative(contentDirPath, filePath); + // Remove extensions, these will not be in routes + const pathNoExt = relativePath.replace(/\.mdx$/, ""); + // Resolve /index routes, these will not have /index in their path + const routePath = pathNoExt.replace(/\/?index$/, ""); + return routePath; + }) + // Determine if there is a match in nav-data. + // If there is no match, then this is an unlinked content file. + .filter((pathToMatch) => { + // If it's the root path index page, we know + // it'll be rendered (hard-coded into docs-page/server.js) + const isIndexPage = pathToMatch === ""; + if (isIndexPage) return false; + // Otherwise, needs a path match in nav-data + const matches = navDataFlat.filter(({ path }) => path == pathToMatch); + return matches.length == 0; + }); + return missingPages; +} + +function flattenNodes(nodes) { + return nodes.reduce((acc, n) => { + if (!n.routes) return acc.concat(n); + return acc.concat(flattenNodes(n.routes)); + }, []); +} + +function walkAsync(relativeDir) { + const dirPath = path.join(process.cwd(), relativeDir); + return new Promise((resolve, reject) => { + walk(dirPath, function (err, result) { + if (err) reject(err); + resolve(result); + }); + }); +} + +function walk(dir, done) { + var results = []; + fs.readdir(dir, function (err, list) { + if (err) return done(err); + var pending = list.length; + if (!pending) return done(null, results); + list.forEach(function (file) { + file = path.resolve(dir, file); + fs.stat(file, function (err, stat) { + if (stat && stat.isDirectory()) { + walk(file, function (err, res) { + results = results.concat(res); + if (!--pending) done(null, results); + }); + } else { + results.push(file); + if (!--pending) done(null, results); + } + }); + }); + }); +} diff --git a/.github/workflows/check-unlinked-content.yml b/.github/workflows/check-unlinked-content.yml new file mode 100644 index 000000000..b90b02a10 --- /dev/null +++ b/.github/workflows/check-unlinked-content.yml @@ -0,0 +1,26 @@ +# +# This GitHub action checks that all .mdx files in the +# the website/content directory are being published. +# It fails if any of these files are not included +# in the expected nav-data.json file. +# +# To resolve failed checks, add the listed paths +# to the corresponding nav-data.json file +# in website/data. + +name: "website: Check unlinked content" +on: + pull_request: + paths: + - "website/**" + +jobs: + check-unlinked-content: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node + uses: actions/setup-node@v1 + - name: Check that all content files are included in navigation + run: node .github/workflows/check-unlinked-content.js diff --git a/website/components/_temp-enable-hidden-pages/index.js b/website/components/_temp-enable-hidden-pages/index.js new file mode 100644 index 000000000..6a4e2fba2 --- /dev/null +++ b/website/components/_temp-enable-hidden-pages/index.js @@ -0,0 +1,87 @@ +// Imports below are used in server-side only +import fs from 'fs' +import path from 'path' +import { + generateStaticPaths as docsPageStaticPaths, + generateStaticProps as docsPageStaticProps, +} from '@hashicorp/react-docs-page/server' + +/** + * DEBT + * This is a short term hotfix for "hidden" docs-sidenav items. + * + * We likely do NOT want to support this in docs-page/server, + * instead, a simple "hidden" attribute supported on docs-sidenav + * nodes would do the trick, ensuring the "path" is "registered" + * in the appropriate nav-data file, and located in the correct spot + * in the nav-data tree, while also hiding that item in the sidebar. + * + * We can remove this hack with once support lands for "hidden" items, + * currently this is somewhat blocked by branding rollout: + * Asana task that will resolve this debt: + * https://app.asana.com/0/1100423001970639/1200197752405255/f + * Draft PR to support "hidden" nav items: + * https://github.com/hashicorp/react-components/pull/220 + **/ + +const DEFAULT_PARAM_ID = 'page' + +export async function generateStaticPaths({ + navDataFile, + navDataFileHidden, + localContentDir, +}) { + const visiblePaths = await docsPageStaticPaths({ + navDataFile, + localContentDir, + }) + const hiddenPaths = await docsPageStaticPaths({ + navDataFile: navDataFileHidden, + localContentDir, + }) + return visiblePaths.concat(hiddenPaths) +} + +export async function generateStaticProps({ + navDataFile, + navDataFileHidden, + localContentDir, + product, + params, + paramId = DEFAULT_PARAM_ID, + additionalComponents, + scope, +}) { + // Read in the "hidden" nav data, and flatten it + const navDataVisible = readNavData(navDataFile) + const navDataHidden = readNavData(navDataFileHidden) + // Check if this is a "hidden" page, if so, use the navDataHidden + // to generate static props. + const currentPath = params[paramId] ? params[paramId].join('/') : '' + const hiddenPaths = flattenNavData(navDataHidden).map((n) => n.path) + const isHiddenPage = hiddenPaths.filter((p) => p == currentPath).length > 0 + // Return the static props, but always pass the navDataVisible + // as the navData to be displayed. + const staticProps = await docsPageStaticProps({ + navDataFile: isHiddenPage ? navDataFileHidden : navDataFile, + localContentDir, + product, + params, + paramId, + additionalComponents, + scope, + }) + return { ...staticProps, navData: navDataVisible } +} + +function readNavData(navDataFile) { + const filePath = path.join(process.cwd(), navDataFile) + return JSON.parse(fs.readFileSync(filePath)) +} + +function flattenNavData(nodes) { + return nodes.reduce((acc, n) => { + if (!n.routes) return acc.concat(n) + return acc.concat(flattenNavData(n.routes)) + }, []) +} diff --git a/website/data/docs-nav-data-hidden.json b/website/data/docs-nav-data-hidden.json new file mode 100644 index 000000000..51f0a3b5a --- /dev/null +++ b/website/data/docs-nav-data-hidden.json @@ -0,0 +1,32 @@ +[ + { + "title": "CLI", + "routes": [ + { + "title": "rsync", + "path": "cli/rsync" + }, + { + "title": "rsync-auto", + "path": "cli/rsync-auto" + }, + { + "title": "winrm", + "path": "cli/winrm" + }, + { + "title": "winrm_config", + "path": "cli/winrm_config" + } + ] + }, + { + "title": "Other", + "routes": [ + { + "title": "macOS Catalina", + "path": "other/macos-catalina" + } + ] + } +] diff --git a/website/pages/docs/[[...page]].jsx b/website/pages/docs/[[...page]].jsx index 2b2155211..75df63181 100644 --- a/website/pages/docs/[[...page]].jsx +++ b/website/pages/docs/[[...page]].jsx @@ -3,10 +3,18 @@ import DocsPage from '@hashicorp/react-docs-page' import { generateStaticPaths, generateStaticProps, -} from '@hashicorp/react-docs-page/server' +} from 'components/_temp-enable-hidden-pages' import { VMWARE_UTILITY_VERSION } from 'data/version.json' import Button from '@hashicorp/react-button' +/** + * DEBT: short term patch for "hidden" docs-sidenav items. + * See components/_temp-enable-hidden-pages for details. + * Revert to importing from @hashicorp/react-docs-page/server + * once https://app.asana.com/0/1100423001970639/1200197752405255/f + * is complete. + **/ +const NAV_DATA_FILE_HIDDEN = 'data/docs-nav-data-hidden.json' const NAV_DATA_FILE = 'data/docs-nav-data.json' const CONTENT_DIR = 'content/docs' const basePath = 'docs' @@ -28,6 +36,7 @@ export async function getStaticPaths() { fallback: false, paths: await generateStaticPaths({ navDataFile: NAV_DATA_FILE, + navDataFileHidden: NAV_DATA_FILE_HIDDEN, localContentDir: CONTENT_DIR, }), } @@ -37,6 +46,7 @@ export async function getStaticProps({ params }) { return { props: await generateStaticProps({ navDataFile: NAV_DATA_FILE, + navDataFileHidden: NAV_DATA_FILE_HIDDEN, localContentDir: CONTENT_DIR, product: { name: productName, slug: productSlug }, params,