@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 +65 -10
- package/index.js +92 -0
- package/package.json +3 -1
- package/src/css/components.scss +13 -3
- package/src/css/theme.scss +19 -2
- package/src/lib/react-foundry-router-shim.js +19 -2
- package/src/theme/Heading/index.js +0 -9
- package/src/theme/Layout/index.js +51 -12
- package/src/theme/SidebarNav/index.js +84 -0
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
|
-
-
|
|
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: '
|
|
77
|
-
href: '/api',
|
|
84
|
+
text: 'Methods',
|
|
85
|
+
href: '/api/methods',
|
|
78
86
|
items: [
|
|
79
|
-
{ text: '
|
|
80
|
-
{ text: '
|
|
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
|
|
130
|
+
| `sidebar` | `array \| 'auto'` | Optional. Sidebar items for this section, or `'auto'` to generate from headings (see [Sidebar Configuration](#sidebar-configuration)) |
|
|
121
131
|
|
|
122
|
-
|
|
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
|
|
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.
|
|
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"
|
package/src/css/components.scss
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
67
|
+
font-family: Helvetica, Arial, sans-serif;
|
|
58
68
|
color: #0b0c0c;
|
|
59
69
|
|
|
60
70
|
&:hover {
|
package/src/css/theme.scss
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|