@defra/docusaurus-theme-govuk 0.0.4-alpha → 0.0.6-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
@@ -10,7 +10,7 @@ A Docusaurus 3 theme that applies the [GOV.UK Design System](https://design-syst
10
10
  - Syntax-highlighted code blocks with copy button
11
11
  - Admonition blocks (:::note, :::warning, etc.) rendered as GOV.UK InsetText / WarningText
12
12
  - 404 page with GOV.UK styling
13
- - Bundled GDS Transport fonts and GOV.UK static assets
13
+ - GOV.UK static assets included (GDS Transport is **not** bundled the theme uses Helvetica/Arial)
14
14
  - Compatible with React 18 and React 19
15
15
 
16
16
 
@@ -63,21 +63,29 @@ module.exports = {
63
63
  header: {
64
64
  serviceName: 'My Service',
65
65
  serviceHref: '/',
66
+ organisationText: 'My Organisation',
67
+ organisationHref: 'https://example.gov.uk',
66
68
  },
67
69
 
68
70
  navigation: [
69
71
  {
72
+ // Auto sidebar: headings from docs/index.md become sidebar items
70
73
  text: 'Documentation',
71
74
  href: '/',
75
+ sidebar: 'auto',
76
+ },
77
+ {
78
+ // Hardcoded sidebar: full control over labels, ordering, and nesting
79
+ text: 'API Reference',
80
+ href: '/api',
72
81
  sidebar: [
73
- { text: 'Introduction', href: '/' },
74
- { text: 'Getting Started', href: '/getting-started' },
82
+ { text: 'Introduction', href: '/api' },
75
83
  {
76
- text: 'API Reference',
77
- href: '/api',
84
+ text: 'Methods',
85
+ href: '/api/methods',
78
86
  items: [
79
- { text: 'Methods', href: '/methods' },
80
- { text: 'Events', href: '/events' },
87
+ { text: 'Initialise', href: '/api/methods#initialise' },
88
+ { text: 'Destroy', href: '/api/methods#destroy' },
81
89
  ],
82
90
  },
83
91
  ],
@@ -108,6 +116,8 @@ module.exports = {
108
116
  |----------|------|-------------|
109
117
  | `serviceName` | `string` | Service name displayed in the GOV.UK header |
110
118
  | `serviceHref` | `string` | Link for the service name (default: `/`) |
119
+ | `organisationText` | `string` | Organisation name displayed in the header crown block |
120
+ | `organisationHref` | `string` | URL the organisation name links to |
111
121
 
112
122
  #### `themeConfig.govuk.navigation`
113
123
 
@@ -117,14 +127,14 @@ Array of top-level navigation items. Each item appears in the Service Navigation
117
127
  |----------|------|-------------|
118
128
  | `text` | `string` | Navigation link text |
119
129
  | `href` | `string` | Base path for this section |
120
- | `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)) |
121
131
 
122
- Each `sidebar` item:
132
+ When `sidebar` is an array, each item:
123
133
 
124
134
  | Property | Type | Description |
125
135
  |----------|------|-------------|
126
136
  | `text` | `string` | Sidebar link text |
127
- | `href` | `string` | Path relative to the parent navigation `href` |
137
+ | `href` | `string` | Path for this item (absolute, e.g. `/api/methods`) |
128
138
  | `items` | `array` | Optional nested sidebar items (one level of nesting) |
129
139
 
130
140
  #### `themeConfig.govuk.phaseBanner`
@@ -150,6 +160,51 @@ The sidebar supports up to 3 levels:
150
160
  2. **Level 2**: Sidebar items
151
161
  3. **Level 3**: Nested sidebar items (collapsible groups)
152
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
+
153
208
  ## Overriding Components
154
209
 
155
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.4-alpha",
3
+ "version": "0.0.6-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 {
@@ -29,7 +39,7 @@
29
39
  border-bottom: 1px solid #b1b4b6;
30
40
  font-size: 0.875rem;
31
41
  font-weight: 700;
32
- font-family: 'GDS Transport', arial, sans-serif;
42
+ font-family: Helvetica, Arial, sans-serif;
33
43
  }
34
44
 
35
45
  .app-code-block__pre {
@@ -38,7 +48,7 @@
38
48
  overflow: auto;
39
49
  font-size: 0.875rem;
40
50
  line-height: 1.6;
41
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
51
+ font-family: Menlo, Consolas, 'Courier New', monospace;
42
52
  position: relative;
43
53
  border: 1px solid #b1b4b6;
44
54
  margin: 0;
@@ -54,7 +64,7 @@
54
64
  border-radius: 0;
55
65
  background: #f3f2f1;
56
66
  cursor: pointer;
57
- font-family: 'GDS Transport', arial, sans-serif;
67
+ font-family: Helvetica, Arial, sans-serif;
58
68
  color: #0b0c0c;
59
69
 
60
70
  &:hover {
@@ -9,10 +9,27 @@
9
9
 
10
10
  /*
11
11
  * Set the default font on the body so that ALL elements
12
- * (header, footer, service-nav, etc.) inherit GDS Transport.
12
+ * (header, footer, service-nav, etc.) inherit the same stack.
13
13
  * The prose-scope only covers content inside .app-prose-scope;
14
14
  * without this, outer chrome inherits the browser default serif.
15
+ * GDS Transport is not bundled — Helvetica/Arial is used instead.
15
16
  */
16
17
  body {
17
- font-family: 'GDS Transport', arial, sans-serif;
18
+ font-family: Helvetica, Arial, sans-serif;
19
+ }
20
+
21
+ /*
22
+ * Sticky footer: Docusaurus inserts a #__docusaurus wrapper between body
23
+ * and our layout, breaking GOV.UK Frontend's flex sticky-footer chain.
24
+ * Rather than patching the chain, own the full-height context here.
25
+ */
26
+ .govuk-template--rebranded {
27
+ display: flex;
28
+ flex-direction: column;
29
+ min-height: 100vh;
30
+ }
31
+
32
+ .govuk-template--rebranded > .govuk-width-container {
33
+ flex: 1 0 auto;
34
+ width: 100%;
18
35
  }
@@ -41,11 +41,28 @@ const includes = (haystack, needle) => {
41
41
  export const useIsActive = () => {
42
42
  const location = useLocation();
43
43
 
44
- return (href, exact = true) => {
44
+ return (href, exact = true, options = {}) => {
45
45
  const target = URI.parse(href, location.pathname);
46
46
  const pathMatch = location.pathname === target.pathname;
47
47
  const queryMatch = includes(location.query, target.query);
48
- return pathMatch && queryMatch;
48
+ // Home link: exact match only (must match exactly, not as a prefix)
49
+ if (
50
+ target.pathname === '/' ||
51
+ /^\/[^/]+\/?$/.test(target.pathname) // e.g. /interactive-map or /interactive-map/
52
+ ) {
53
+ // Only highlight if the current path matches the home link's pathname exactly
54
+ return location.pathname === target.pathname && queryMatch;
55
+ }
56
+ // Overview link: href is a prefix of current path, and next char is '/' or nothing
57
+ const isOverview = location.pathname.startsWith(target.pathname)
58
+ && location.pathname.charAt(target.pathname.length) === '/';
59
+ if (isOverview) {
60
+ return pathMatch && queryMatch;
61
+ }
62
+ // All other links: startsWith logic
63
+ const dir = target.pathname.endsWith('/') ? target.pathname : target.pathname + '/';
64
+ const pathStart = location.pathname === target.pathname || location.pathname.startsWith(dir);
65
+ return pathStart && queryMatch;
49
66
  };
50
67
  };
51
68
 
@@ -15,15 +15,6 @@ export default function Heading({as: Tag = 'h2', id, children, ...props}) {
15
15
  return (
16
16
  <Tag id={id} className={className} {...props}>
17
17
  {children}
18
- {id && (
19
- <a
20
- href={`#${id}`}
21
- className="govuk-link app-heading-anchor"
22
- aria-label={`Direct link to ${typeof children === 'string' ? children : 'heading'}`}
23
- >
24
- #
25
- </a>
26
- )}
27
18
  </Tag>
28
19
  );
29
20
  }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import '../../css/theme.scss';
3
3
  import {SkipLink, Header, Footer, PhaseBanner, ServiceNavigation, NavigationMenu} 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 && {
@@ -112,6 +141,11 @@ export default function Layout(props) {
112
141
  }))
113
142
  : null;
114
143
 
144
+ // Anchor-based sidebars (auto-generated) use a hash-aware component.
145
+ // Page-based sidebars (manually configured arrays) use NavigationMenu which
146
+ // uses React Router's active detection for sub-page expansion.
147
+ const isAnchorSidebar = effectiveSidebar?._auto === true;
148
+
115
149
  // Convert navigation to service navigation format (Level 1 only)
116
150
  const serviceNavItems = navigation.map(item => ({
117
151
  href: withBase(item.href),
@@ -137,14 +171,17 @@ export default function Layout(props) {
137
171
  <SkipLink for="main-content">Skip to main content</SkipLink>
138
172
 
139
173
  <Header
140
- govUK
141
174
  rebrand
175
+ organisationText={header.organisationText}
176
+ organisationHref={header.organisationHref}
177
+ />
178
+
179
+ <ServiceNavigation
180
+ items={serviceNavItems}
142
181
  serviceName={header.serviceName}
143
182
  serviceHref={withBase(header.serviceHref || '/')}
144
183
  />
145
184
 
146
- <ServiceNavigation items={serviceNavItems} />
147
-
148
185
  <div className="govuk-width-container">
149
186
  {phaseBanner && (
150
187
  <PhaseBanner phase={phaseBanner.phase}>
@@ -161,7 +198,9 @@ export default function Layout(props) {
161
198
  {sidebarItems ? (
162
199
  <div className="app-layout-sidebar">
163
200
  <aside className="app-layout-sidebar__nav">
164
- <NavigationMenu items={sidebarItems} />
201
+ {isAnchorSidebar
202
+ ? <SidebarNav items={sidebarItems} />
203
+ : <NavigationMenu items={sidebarItems} />}
165
204
  </aside>
166
205
  <div className="app-layout-sidebar__content">
167
206
  {children}
@@ -174,7 +213,7 @@ export default function Layout(props) {
174
213
  </div>
175
214
 
176
215
  {!noFooter && (
177
- <Footer govUK rebrand meta={footer.meta} />
216
+ <Footer rebrand meta={footer.meta} />
178
217
  )}
179
218
  </div>
180
219
  </LayoutProvider>
@@ -0,0 +1,84 @@
1
+ import React, { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * Hash-aware sidebar navigation for anchor-based (auto-generated) sidebars.
5
+ *
6
+ * SSR / no-JS: all groups are rendered expanded so content is accessible.
7
+ * CSR after hydration: exactly one group expands based on the URL hash.
8
+ * - hash matches the group's h2 anchor → that group expands
9
+ * - hash matches any child's anchor → that group expands
10
+ * - no hash → all groups collapse
11
+ *
12
+ * State sentinel:
13
+ * null = not yet hydrated (SSR or before first useEffect) → expand all
14
+ * '' = JS loaded, no hash → collapse all
15
+ * str = JS loaded, hash present → expand matching group only
16
+ */
17
+ export default function SidebarNav({ items }) {
18
+ const [hash, setHash] = useState(null);
19
+
20
+ useEffect(() => {
21
+ const update = () => setHash(window.location.hash.slice(1));
22
+ update();
23
+ window.addEventListener('hashchange', update);
24
+ return () => window.removeEventListener('hashchange', update);
25
+ }, []);
26
+
27
+ const cls = 'not-govuk-navigation-menu';
28
+ const lCls = `${cls}__list`;
29
+
30
+ return (
31
+ <nav className={cls}>
32
+ <ul className={lCls}>
33
+ {items.map((item, i) => {
34
+ const anchor = item.href ? (item.href.split('#')[1] ?? '') : '';
35
+ const hasChildren = Array.isArray(item.items) && item.items.length > 0;
36
+ const childAnchors = hasChildren
37
+ ? item.items.map(sub => sub.href ? (sub.href.split('#')[1] ?? '') : '')
38
+ : [];
39
+
40
+ // null → not yet hydrated, expand everything
41
+ // '' → no hash, collapse everything
42
+ // value → expand if hash is this group's h2 anchor or any child anchor
43
+ const hashMatchesGroup =
44
+ hash !== '' && (
45
+ hash === anchor ||
46
+ childAnchors.includes(hash)
47
+ );
48
+
49
+ const expanded = hasChildren && (hash === null || hashMatchesGroup);
50
+ const active = hash !== null && hashMatchesGroup;
51
+
52
+ return (
53
+ <li
54
+ key={i}
55
+ className={`${lCls}__item${active ? ` ${lCls}__item--active` : ''}`}
56
+ >
57
+ <a href={item.href} className={`${lCls}__link`}>
58
+ {item.text}
59
+ </a>
60
+ {expanded && (
61
+ <ul className={`${lCls}__subitems`}>
62
+ {item.items.map((sub, j) => {
63
+ const subAnchor = sub.href ? (sub.href.split('#')[1] ?? '') : '';
64
+ const subActive = hash !== null && hash === subAnchor;
65
+ return (
66
+ <li
67
+ key={j}
68
+ className={`${lCls}__item${subActive ? ` ${lCls}__item--active` : ''}`}
69
+ >
70
+ <a href={sub.href} className={`${lCls}__link`}>
71
+ {sub.text}
72
+ </a>
73
+ </li>
74
+ );
75
+ })}
76
+ </ul>
77
+ )}
78
+ </li>
79
+ );
80
+ })}
81
+ </ul>
82
+ </nav>
83
+ );
84
+ }