@defra/docusaurus-theme-govuk 0.0.5-alpha → 0.0.7-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,17 +69,23 @@ module.exports = {
69
69
 
70
70
  navigation: [
71
71
  {
72
+ // Auto sidebar: headings from docs/index.md become sidebar items
72
73
  text: 'Documentation',
73
74
  href: '/',
75
+ sidebar: 'auto',
76
+ },
77
+ {
78
+ // Hardcoded sidebar: full control over labels, ordering, and nesting
79
+ text: 'API Reference',
80
+ href: '/api',
74
81
  sidebar: [
75
- { text: 'Introduction', href: '/' },
76
- { text: 'Getting Started', href: '/getting-started' },
82
+ { text: 'Introduction', href: '/api' },
77
83
  {
78
- text: 'API Reference',
79
- href: '/api',
84
+ text: 'Methods',
85
+ href: '/api/methods',
80
86
  items: [
81
- { text: 'Methods', href: '/methods' },
82
- { text: 'Events', href: '/events' },
87
+ { text: 'Initialise', href: '/api/methods#initialise' },
88
+ { text: 'Destroy', href: '/api/methods#destroy' },
83
89
  ],
84
90
  },
85
91
  ],
@@ -121,14 +127,14 @@ Array of top-level navigation items. Each item appears in the Service Navigation
121
127
  |----------|------|-------------|
122
128
  | `text` | `string` | Navigation link text |
123
129
  | `href` | `string` | Base path for this section |
124
- | `sidebar` | `array` | Optional sidebar items for this section |
130
+ | `sidebar` | `array \| 'auto'` | Optional. Sidebar items for this section, or `'auto'` to generate from headings (see [Sidebar Configuration](#sidebar-configuration)) |
125
131
 
126
- Each `sidebar` item:
132
+ When `sidebar` is an array, each item:
127
133
 
128
134
  | Property | Type | Description |
129
135
  |----------|------|-------------|
130
136
  | `text` | `string` | Sidebar link text |
131
- | `href` | `string` | Path relative to the parent navigation `href` |
137
+ | `href` | `string` | Path for this item (absolute, e.g. `/api/methods`) |
132
138
  | `items` | `array` | Optional nested sidebar items (one level of nesting) |
133
139
 
134
140
  #### `themeConfig.govuk.phaseBanner`
@@ -154,6 +160,51 @@ The sidebar supports up to 3 levels:
154
160
  2. **Level 2**: Sidebar items
155
161
  3. **Level 3**: Nested sidebar items (collapsible groups)
156
162
 
163
+ ### Hardcoded sidebar
164
+
165
+ Pass an array to `sidebar` to define the structure explicitly. This gives full control over labels, ordering, and anchor links:
166
+
167
+ ```js
168
+ sidebar: [
169
+ { text: 'Overview', href: '/api' },
170
+ {
171
+ text: 'Constructor',
172
+ href: '/api#constructor',
173
+ items: [
174
+ { text: 'Options', href: '/api#options' },
175
+ ],
176
+ },
177
+ ]
178
+ ```
179
+
180
+ Nested groups are collapsible. A group is expanded when the current URL hash matches either the group's own `href` anchor or any child anchor.
181
+
182
+ ### Auto sidebar
183
+
184
+ Set `sidebar: 'auto'` on a navigation section to generate the sidebar automatically from the section's corresponding Markdown document at build time:
185
+
186
+ ```js
187
+ {
188
+ text: 'API Reference',
189
+ href: '/api',
190
+ sidebar: 'auto',
191
+ }
192
+ ```
193
+
194
+ The theme reads `docs/<slug>.md` (or `.mdx`) where `<slug>` is derived from `href` (e.g. `href: '/api'` → `docs/api.md`). It parses the document's headings and builds the sidebar as follows:
195
+
196
+ - `##` (h2) headings become top-level sidebar items
197
+ - `###` (h3) headings become nested items under the preceding h2
198
+ - Heading text is stripped of Markdown syntax (bold, italic, inline code, links) to produce plain-text labels
199
+
200
+ The sidebar is resolved once at build time and serialised into the site configuration. No runtime file reads occur.
201
+
202
+ #### Limitations
203
+
204
+ - Only h2 and h3 headings are included; h4 and deeper are ignored.
205
+ - The document must be in the `docs/` directory at the root of your Docusaurus site.
206
+ - Heading IDs set via the `{#custom-id}` syntax are not yet respected — the generated anchor will use the slugified heading text.
207
+
157
208
  ## Overriding Components
158
209
 
159
210
  You can override any theme component by creating a file at the same path in your project's `src/theme/` directory. For example, to override the 404 page:
package/index.js CHANGED
@@ -1,5 +1,57 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ // remove-markdown strips markdown syntax leaving plain text — used for sidebar labels.
4
+ const removeMarkdown = require('remove-markdown');
5
+ // github-slugger is what Docusaurus itself uses internally to generate heading
6
+ // anchor IDs. Using the same library guarantees our sidebar hrefs match the
7
+ // IDs emitted in the built HTML. A stateful GithubSlugger instance also
8
+ // handles deduplication automatically (second "Options" → "options-1", etc.).
9
+ const GithubSlugger = require('github-slugger');
10
+
11
+ // Parse markdown content and build a sidebar config from h2/h3 headings.
12
+ // h2 → top-level items; h3 → nested items under the preceding h2.
13
+ // Items include both the display text and an anchor href (basePath + '#' + anchor).
14
+ function parseHeadingsToSidebar(content, basePath) {
15
+ // Strip YAML front-matter only if the file genuinely starts with ---
16
+ // (do NOT use a greedy regex: api.md and other docs use --- as horizontal rules)
17
+ let stripped = content;
18
+ if (content.startsWith('---\n') || content.startsWith('---\r\n')) {
19
+ const closeIdx = content.indexOf('\n---', 3);
20
+ if (closeIdx !== -1) {
21
+ // Skip past the closing --- and any trailing newline
22
+ stripped = content.slice(closeIdx + 4).replace(/^\n/, '');
23
+ }
24
+ }
25
+ const lines = stripped.split('\n');
26
+
27
+ // One slugger instance per document — its internal counter provides the
28
+ // same -1, -2 deduplication that Docusaurus emits in the built HTML.
29
+ const slugger = new GithubSlugger();
30
+
31
+ const items = [];
32
+ let currentH2 = null;
33
+
34
+ for (const line of lines) {
35
+ const h2 = line.match(/^## (.+)$/);
36
+ const h3 = line.match(/^### (.+)$/);
37
+
38
+ if (h2) {
39
+ const raw = h2[1].trim();
40
+ const text = removeMarkdown(raw);
41
+ const anchor = slugger.slug(raw);
42
+ currentH2 = { text, href: `${basePath}#${anchor}`, _anchor: anchor };
43
+ items.push(currentH2);
44
+ } else if (h3 && currentH2) {
45
+ const raw = h3[1].trim();
46
+ const text = removeMarkdown(raw);
47
+ const anchor = slugger.slug(raw);
48
+ if (!currentH2.items) currentH2.items = [];
49
+ currentH2.items.push({ text, href: `${basePath}#${anchor}` });
50
+ }
51
+ }
52
+
53
+ return items.map(({ _anchor, ...item }) => item);
54
+ }
3
55
 
4
56
  module.exports = function themeGovuk(context, options) {
5
57
  const siteDir = context.siteDir;
@@ -21,6 +73,41 @@ module.exports = function themeGovuk(context, options) {
21
73
  // The base URL for this Docusaurus site (e.g. '/interactive-map/')
22
74
  const baseUrl = context.baseUrl || '/';
23
75
 
76
+ // Pre-resolve sidebar: 'auto' entries in the navigation config by mutating
77
+ // the themeConfig object in-place. This runs synchronously before Docusaurus
78
+ // serialises siteConfig into the client bundle, so useDocusaurusContext()
79
+ // will already see the resolved sidebar arrays — no new client imports needed.
80
+ const govukNav = context.siteConfig.themeConfig?.govuk?.navigation;
81
+ if (Array.isArray(govukNav)) {
82
+ const docsDir = path.join(siteDir, 'docs');
83
+ for (const section of govukNav) {
84
+ if (section.sidebar !== 'auto') continue;
85
+ const href = section.href || '/';
86
+ const slug = href.replace(/^\//, '') || 'index';
87
+ let resolved = false;
88
+ for (const ext of ['.md', '.mdx']) {
89
+ const candidate = path.join(docsDir, `${slug}${ext}`);
90
+ if (fs.existsSync(candidate)) {
91
+ section.sidebar = {
92
+ _auto: true,
93
+ items: parseHeadingsToSidebar(
94
+ fs.readFileSync(candidate, 'utf-8'),
95
+ href
96
+ ),
97
+ };
98
+ resolved = true;
99
+ break;
100
+ }
101
+ }
102
+ if (!resolved) {
103
+ console.warn(
104
+ `[docusaurus-theme-govuk] sidebar: "auto" on "${href}" — could not find markdown file at ${path.join(docsDir, slug)}.(md|mdx)`
105
+ );
106
+ section.sidebar = { _auto: true, items: [] };
107
+ }
108
+ }
109
+ }
110
+
24
111
  return {
25
112
  name: 'docusaurus-theme-govuk',
26
113
 
@@ -108,6 +195,11 @@ module.exports = function themeGovuk(context, options) {
108
195
  resolve: {
109
196
  extensions: ['.mjs', '.js', '.jsx', '.json', '.scss'],
110
197
  fullySpecified: false,
198
+ // When the theme is consumed via a file: symlink, webpack resolves
199
+ // imports from the theme's real directory which has no node_modules.
200
+ // Adding the site's node_modules here ensures @docusaurus/* and any
201
+ // other peer dependency imports are found regardless of symlink depth.
202
+ modules: ['node_modules', path.resolve(siteDir, 'node_modules')],
111
203
  alias: {
112
204
  // Deduplicate React — always use the consumer's copy
113
205
  'react': resolveFromSite('react'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/docusaurus-theme-govuk",
3
- "version": "0.0.5-alpha",
3
+ "version": "0.0.7-alpha",
4
4
  "description": "A Docusaurus theme implementing the GOV.UK Design System for consistent, accessible documentation sites",
5
5
  "main": "index.js",
6
6
  "license": "MIT",
@@ -32,9 +32,11 @@
32
32
  "clsx": "^2.0.0",
33
33
  "copy-webpack-plugin": "^11.0.0",
34
34
  "css-loader": "^7.1.0",
35
+ "github-slugger": "^2.0.0",
35
36
  "govuk-frontend": "^5.14.0",
36
37
  "postcss-loader": "^8.2.0",
37
38
  "prism-react-renderer": "^2.3.0",
39
+ "remove-markdown": "^0.6.3",
38
40
  "sass": "^1.97.0",
39
41
  "sass-loader": "^16.0.0",
40
42
  "style-loader": "^4.0.0"
@@ -9,7 +9,17 @@
9
9
 
10
10
  .app-layout-sidebar__nav {
11
11
  width: 250px;
12
+ // Padding-left gives room for the nav's active indicator (margin-left: -14px)
13
+ // which would otherwise be clipped by the overflow-y containment.
14
+ padding-left: 15px;
12
15
  flex-shrink: 0;
16
+ position: sticky;
17
+ top: 1rem;
18
+ max-height: calc(100vh - 2rem);
19
+ overflow-y: auto;
20
+ // Always reserve the scrollbar gutter so expanding subnav items don't
21
+ // cause a horizontal layout shift when the scrollbar appears.
22
+ scrollbar-gutter: stable;
13
23
  }
14
24
 
15
25
  .app-layout-sidebar__content {
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import '../../css/theme.scss';
3
- import {SkipLink, Header, Footer, PhaseBanner, ServiceNavigation, NavigationMenu} from '@not-govuk/simple-components';
3
+ import {SkipLink, Header, Footer, PhaseBanner, ServiceNavigation} from '@not-govuk/simple-components';
4
+ import SidebarNav from '../SidebarNav';
4
5
  import {useLocation} from '@docusaurus/router';
5
6
  import Head from '@docusaurus/Head';
6
7
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
@@ -37,6 +38,21 @@ function resolvePath(basePath, relativePath) {
37
38
  return `${cleanBase}/${relativePath}`;
38
39
  }
39
40
 
41
+ // Return the effective sidebar descriptor for a navigation section.
42
+ // Auto-generated sidebars are { _auto: true, items: [...] } (a plain object
43
+ // that survives JSON serialisation, unlike array non-index properties).
44
+ // Manual sidebars remain plain arrays and are wrapped here for uniform access.
45
+ function getEffectiveSidebar(section) {
46
+ const s = section.sidebar;
47
+ if (s && typeof s === 'object' && !Array.isArray(s) && s._auto === true) {
48
+ return s; // { _auto: true, items: [...] }
49
+ }
50
+ if (Array.isArray(s)) {
51
+ return { _auto: false, items: s };
52
+ }
53
+ return null;
54
+ }
55
+
40
56
  // Resolve all paths in sidebar items
41
57
  function resolveSidebarPaths(items, basePath) {
42
58
  return items.map(item => {
@@ -54,13 +70,21 @@ function resolveSidebarPaths(items, basePath) {
54
70
  });
55
71
  }
56
72
 
57
- // Find the active navigation section based on current path
73
+ // Find the active navigation section based on current path.
58
74
  function getActiveSection(pathname, navigation) {
59
75
  return navigation.find(section => {
60
- if (!section.sidebar) return false;
61
- // Use the section's configured href as the base path
62
- const basePath = section.href || '/';
63
- const resolvedSidebar = resolveSidebarPaths(section.sidebar, basePath);
76
+ const sidebar = getEffectiveSidebar(section);
77
+ if (!sidebar) return false;
78
+ // First: check if current pathname matches the section's own base page or
79
+ // any sub-page. This handles auto-generated sidebars whose item hrefs
80
+ // include anchors (#...) and would never match a plain pathname.
81
+ const sectionPath = section.href || '/';
82
+ if (pathname === sectionPath || pathname.startsWith(sectionPath + '/')) {
83
+ return true;
84
+ }
85
+ // Fallback: match by exact item href (for array sidebars with sub-pages
86
+ // whose hrefs differ from the section's base href).
87
+ const resolvedSidebar = resolveSidebarPaths(sidebar.items, sectionPath);
64
88
  return resolvedSidebar.some(item => {
65
89
  if (pathname === item.href) return true;
66
90
  if (item.items) {
@@ -99,11 +123,16 @@ export default function Layout(props) {
99
123
  return `${baseUrl}${href.startsWith('/') ? href : `/${href}`}`;
100
124
  }
101
125
 
126
+ // Build-time auto-generated sidebars were resolved into the navigation
127
+ // array directly (sidebar: 'auto' replaced with sidebar: [...]) so we
128
+ // just read the array from siteConfig as normal.
129
+
102
130
  // Get active section for sidebar
103
131
  const activeSection = getActiveSection(pathname, navigation);
104
132
  const basePath = activeSection?.href || '/';
105
- const sidebarItems = activeSection?.sidebar
106
- ? resolveSidebarPaths(activeSection.sidebar, basePath).map(item => ({
133
+ const effectiveSidebar = activeSection ? getEffectiveSidebar(activeSection) : null;
134
+ const sidebarItems = effectiveSidebar
135
+ ? resolveSidebarPaths(effectiveSidebar.items, basePath).map(item => ({
107
136
  ...item,
108
137
  href: withBase(item.href),
109
138
  ...(item.items && {
@@ -164,7 +193,7 @@ export default function Layout(props) {
164
193
  {sidebarItems ? (
165
194
  <div className="app-layout-sidebar">
166
195
  <aside className="app-layout-sidebar__nav">
167
- <NavigationMenu items={sidebarItems} />
196
+ <SidebarNav items={sidebarItems} />
168
197
  </aside>
169
198
  <div className="app-layout-sidebar__content">
170
199
  {children}
@@ -0,0 +1,102 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useLocation } from '@docusaurus/router';
3
+
4
+ /**
5
+ * Hash- and pathname-aware sidebar navigation.
6
+ *
7
+ * Works for both anchor-based (auto-generated) and page-based (manual) sidebars.
8
+ *
9
+ * SSR / no-JS: all groups are rendered expanded so content is accessible.
10
+ * CSR after hydration:
11
+ * - Anchor hrefs (e.g. /api#constructor): active when pathname AND hash both match
12
+ * - Page hrefs (e.g. /building-a-plugin): active when pathname matches
13
+ * - Groups expand when the group itself or any child is active
14
+ *
15
+ * State sentinel:
16
+ * null = not yet hydrated → expand all groups (SSR safe)
17
+ * str = JS loaded (empty string means no hash present)
18
+ */
19
+ export default function SidebarNav({ items }) {
20
+ const [hash, setHash] = useState(null);
21
+ const location = useLocation();
22
+
23
+ useEffect(() => {
24
+ const update = () => setHash(window.location.hash.slice(1));
25
+ update();
26
+ window.addEventListener('hashchange', update);
27
+ return () => window.removeEventListener('hashchange', update);
28
+ }, []);
29
+
30
+ // Split an href into its path and anchor components.
31
+ function parseHref(href) {
32
+ if (!href) return { path: '', anchor: '' };
33
+ const idx = href.indexOf('#');
34
+ if (idx === -1) return { path: href, anchor: '' };
35
+ return { path: href.slice(0, idx) || '/', anchor: href.slice(idx + 1) };
36
+ }
37
+
38
+ // Return true if the given href matches the current browser location.
39
+ // Anchor hrefs: both pathname and hash must match.
40
+ // Page hrefs: pathname match is sufficient (exact or child path).
41
+ function isActive(href) {
42
+ const { path, anchor } = parseHref(href);
43
+ if (anchor) {
44
+ return location.pathname === path && hash === anchor;
45
+ }
46
+ return (
47
+ path !== '' &&
48
+ (location.pathname === path || location.pathname.startsWith(path + '/'))
49
+ );
50
+ }
51
+
52
+ const cls = 'not-govuk-navigation-menu';
53
+ const lCls = `${cls}__list`;
54
+
55
+ return (
56
+ <nav className={cls}>
57
+ <ul className={lCls}>
58
+ {items.map((item, i) => {
59
+ const hasChildren = Array.isArray(item.items) && item.items.length > 0;
60
+
61
+ // Pre-hydration (hash === null): expand everything so content is
62
+ // accessible without JS. After hydration: expand only if this group
63
+ // or one of its children is the active location.
64
+ const expanded =
65
+ hasChildren &&
66
+ (hash === null || isActive(item.href) || item.items.some(sub => isActive(sub.href)));
67
+
68
+ const active = hash !== null && isActive(item.href);
69
+
70
+ return (
71
+ <li
72
+ key={i}
73
+ className={`${lCls}__item${active ? ` ${lCls}__item--active` : ''}`}
74
+ >
75
+ <a href={item.href} className={`${lCls}__link`}>
76
+ {item.text}
77
+ </a>
78
+ {expanded && (
79
+ <ul className={`${lCls}__subitems`}>
80
+ {item.items.map((sub, j) => {
81
+ const subActive = hash !== null && isActive(sub.href);
82
+ return (
83
+ <li
84
+ key={j}
85
+ className={`${lCls}__item${subActive ? ` ${lCls}__item--active` : ''}`}
86
+ >
87
+ <a href={sub.href} className={`${lCls}__link`}>
88
+ {sub.text}
89
+ </a>
90
+ </li>
91
+ );
92
+ })}
93
+ </ul>
94
+ )}
95
+ </li>
96
+ );
97
+ })}
98
+ </ul>
99
+ </nav>
100
+ );
101
+ }
102
+