diff --git a/.gitignore b/.gitignore
index 8b780e4e2e..7a5746c1df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ coverage/
/assets/images/early-access
/content/early-access
/data/early-access
+/script/dev-toc/static
.next
.eslintcache
*.tsbuildinfo
diff --git a/script/dev-toc/generate.js b/script/dev-toc/generate.js
new file mode 100755
index 0000000000..3f27157680
--- /dev/null
+++ b/script/dev-toc/generate.js
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+
+import fs from 'fs'
+import path from 'path'
+import { execSync } from 'child_process'
+import { program } from 'commander'
+import fpt from '../../lib/non-enterprise-default-version.js'
+import { allVersionKeys } from '../../lib/all-versions.js'
+import { liquid } from '../../lib/render-content/index.js'
+import contextualize from '../../middleware/context.js'
+
+const layoutFilename = path.posix.join(process.cwd(), 'script/dev-toc/layout.html')
+const layout = fs.readFileSync(layoutFilename, 'utf8')
+
+const staticDirName = 'script/dev-toc/static'
+const staticDir = path.posix.join(process.cwd(), staticDirName)
+if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir)
+
+program
+ .description('Generate a local TOC of the docs website and open it in your browser')
+ .option(
+ '-o, --openSections [product-ids...]',
+ 'open sections for one or more product IDs by default (e.g., "-o codespaces pull-requests")'
+ )
+ .parse(process.argv)
+
+const options = program.opts()
+
+const openSections = options.openSections || ''
+const defaultOpenSections = Array.isArray(openSections) ? openSections : [openSections]
+
+main()
+
+async function main() {
+ const next = () => {}
+ const res = {}
+ const req = { language: 'en', cookies: {} }
+
+ for (const version of allVersionKeys) {
+ req.pagePath = version === fpt ? '/' : `/${version}`
+
+ // Create a subdir for the version if one doesn't exist yet.
+ const versionStaticDir = path.posix.join(staticDir, version)
+ if (!fs.existsSync(versionStaticDir)) fs.mkdirSync(versionStaticDir)
+
+ // Create a versioned filename.
+ const filename = path.posix.join(versionStaticDir, 'index.html')
+
+ // Create a minimal context object.
+ await contextualize(req, res, next)
+
+ // Add the tree to the req.context.
+ req.context.currentEnglishTree = req.context.siteTree.en[req.context.currentVersion]
+
+ // Add any defaultOpenSections to the context.
+ req.context.defaultOpenSections = defaultOpenSections
+
+ // Parse the layout in script/dev-toc/layout.html with the context we created above.
+ const outputHtml = await liquid.parseAndRender(layout, Object.assign({}, req.context))
+
+ // Write a static file for each version.
+ fs.writeFileSync(filename, outputHtml)
+ }
+
+ // Default to FPT for the file to open.
+ const fptFile = path.posix.join(staticDirName, fpt, 'index.html')
+
+ execSync(`open ${fptFile}`)
+
+ console.log(`\nCreated the TOC! If it doesn't open automatically, open the following file in your browser to view it:\n
+${fptFile}`)
+}
diff --git a/script/dev-toc/index.js b/script/dev-toc/index.js
new file mode 100644
index 0000000000..2502cf21b3
--- /dev/null
+++ b/script/dev-toc/index.js
@@ -0,0 +1,41 @@
+const expandText = 'Expand All'
+const closeText = 'Close All'
+
+document.addEventListener('DOMContentLoaded', async () => {
+ devToc()
+})
+
+function devToc() {
+ const expandButton = document.querySelector('.js-expand')
+ if (!expandButton) return
+
+ const detailsElements = document.querySelectorAll('details')
+
+ expandButton.addEventListener('click', () => {
+ // on click, toggle all the details elements open or closed
+ const anyDetailsOpen = Array.from(detailsElements).find((details) => details.open)
+
+ for (const detailsElement of detailsElements) {
+ anyDetailsOpen ? detailsElement.removeAttribute('open') : (detailsElement.open = true)
+ }
+
+ // toggle the button text on click
+ anyDetailsOpen
+ ? (expandButton.textContent = expandText)
+ : (expandButton.textContent = closeText)
+ })
+
+ // also toggle the button text on clicking any of the details elements
+ for (const detailsElement of detailsElements) {
+ detailsElement.addEventListener('click', () => {
+ expandButton.textContent = closeText
+
+ // we can only get an accurate count of the open details elements if we wait a fraction after click
+ setTimeout(() => {
+ if (!Array.from(detailsElements).find((details) => details.open)) {
+ expandButton.textContent = expandText
+ }
+ }, 50)
+ })
+ }
+}
diff --git a/script/dev-toc/layout.html b/script/dev-toc/layout.html
new file mode 100644
index 0000000000..0aa79e1971
--- /dev/null
+++ b/script/dev-toc/layout.html
@@ -0,0 +1,67 @@
+
+
+
+
+Docs TOC
+
+
+
+
+
+
+Versions
+
+
+{% assign docsRoot = "https://docs.github.com" %}
+
+{% if allVersions[currentVersion] %}
+TOC for {{ allVersions[currentVersion].versionTitle }}
+
+
+
+
+{% for productPage in currentEnglishTree.childPages %}
+{% assign productId = productPage.page.relativePath | replace: "/index.md", "" %}
+{% if defaultOpenSections contains productId %}
+{{productPage.renderedFullTitle}}
+{% else %}
+{{productPage.renderedFullTitle}}
+{% endif %}
+
+
+{% endfor %}
+{% endif %}
+
+
+
+
+