@defra/docusaurus-theme-govuk 0.0.7-alpha → 0.0.8-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
@@ -103,6 +103,13 @@ module.exports = {
103
103
  { text: 'GitHub', href: 'https://github.com/your-org/your-repo' },
104
104
  ],
105
105
  },
106
+
107
+ homepage: {
108
+ // Path to link the "Get started" button on the homepage masthead
109
+ getStartedHref: '/getting-started',
110
+ // Short description rendered below the heading
111
+ description: 'A short summary of what your service does.',
112
+ },
106
113
  },
107
114
  },
108
115
  };
@@ -110,6 +117,17 @@ module.exports = {
110
117
 
111
118
  ### Configuration Reference
112
119
 
120
+ #### `themeConfig.govuk.homepage`
121
+
122
+ Controls the homepage masthead — a full-width blue banner rendered between the service navigation and the page body on the homepage only. The banner shares the GOV.UK rebrand blue (`#1d70b8`) with the header and service navigation, so all three elements appear as one unified block.
123
+
124
+ The masthead displays `siteConfig.tagline` as the heading and `homepage.description` as the lead paragraph. A GOV.UK "Start" button links to the configured `getStartedHref`.
125
+
126
+ | Property | Type | Default | Description |
127
+ |----------|------|---------|-------------|
128
+ | `getStartedHref` | `string` | `'/getting-started'` | Path the "Get started" button links to |
129
+ | `description` | `string` | — | Short description rendered below the heading |
130
+
113
131
  #### `themeConfig.govuk.header`
114
132
 
115
133
  | Property | Type | Description |
@@ -205,6 +223,62 @@ The sidebar is resolved once at build time and serialised into the site configur
205
223
  - The document must be in the `docs/` directory at the root of your Docusaurus site.
206
224
  - Heading IDs set via the `{#custom-id}` syntax are not yet respected — the generated anchor will use the slugified heading text.
207
225
 
226
+ ## Search
227
+
228
+ The theme includes a built-in search bar rendered in the header. It uses [`@easyops-cn/docusaurus-search-local`](https://github.com/easyops-cn/docusaurus-search-local) to generate a local [lunr](https://lunrjs.com/) index at build time, and [alphagov's `accessible-autocomplete`](https://github.com/alphagov/accessible-autocomplete) as the accessible suggestion UI. No external service or API key is required.
229
+
230
+ ### Dependencies
231
+
232
+ Install both packages in your Docusaurus site:
233
+
234
+ ```bash
235
+ npm install @easyops-cn/docusaurus-search-local accessible-autocomplete
236
+ ```
237
+
238
+ ### Configuration
239
+
240
+ The `@easyops-cn/docusaurus-search-local` theme must appear **before** `docusaurus-theme-govuk` in the `themes` array. This order ensures the search index is generated by the easyops plugin while the GOV.UK theme's `SearchBar` component wins the slot and controls the UI.
241
+
242
+ ```js
243
+ themes: [
244
+ [
245
+ require.resolve('@easyops-cn/docusaurus-search-local'),
246
+ {
247
+ // Match your docs routeBasePath — '/' for docs-only mode
248
+ docsRouteBasePath: '/',
249
+ indexBlog: false,
250
+ indexPages: false,
251
+ // Hashed filenames allow long-term browser caching of the search index
252
+ hashed: 'filename',
253
+ highlightSearchTermsOnTargetPage: true,
254
+ searchResultContextMaxLength: 60,
255
+ },
256
+ ],
257
+ 'docusaurus-theme-govuk',
258
+ ],
259
+ ```
260
+
261
+ You must also add `docs.versionPersistence` to `themeConfig`. Without it, the `@easyops-cn/docusaurus-search-local` plugin throws a runtime error during static site generation (`Cannot read properties of undefined (reading 'versionPersistence')`):
262
+
263
+ ```js
264
+ themeConfig: {
265
+ docs: {
266
+ versionPersistence: 'localStorage',
267
+ },
268
+ // ...
269
+ },
270
+ ```
271
+
272
+ ### Search index coverage
273
+
274
+ By default, all content under `docsRouteBasePath` is indexed. Blog and custom pages are excluded in the example above (`indexBlog: false`, `indexPages: false`). Adjust these to match your site structure.
275
+
276
+ The search index is generated at build time. Run `npm run build` (or `docs:build`) before serving — the search bar will return no results in development mode (`docusaurus start`).
277
+
278
+ ### Result links
279
+
280
+ The search bar deep-links directly to the matching heading within a page using anchor IDs derived from heading text, using the same slugifier as Docusaurus itself. Results show a breadcrumb context trail (`Page › Heading`) to help users identify where a match appears.
281
+
208
282
  ## Overriding Components
209
283
 
210
284
  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:
@@ -225,4 +299,39 @@ Common components to override:
225
299
 
226
300
  - **Docusaurus**: ^3.0.0
227
301
  - **React**: ^18.0.0 || ^19.0.0
228
- - **Node.js**: 18+
302
+ - **Node.js**: 18+
303
+
304
+ ## Open Source Attribution
305
+
306
+ This theme includes a modified version of the [`@not-govuk/header`](https://github.com/daniel-ac-martin/NotGovUK/tree/master/packages/components/header) component from the [NotGovUK](https://github.com/daniel-ac-martin/NotGovUK) project.
307
+
308
+ The modification adds a `children` prop so that additional content (the search bar) can be rendered as a flex sibling inside the header container. All other behaviour is unchanged.
309
+
310
+ **Original licence:**
311
+
312
+ ```
313
+ The MIT License (MIT)
314
+
315
+ Copyright (C) 2019, 2020, 2021 Crown Copyright
316
+ Copyright (C) 2019, 2020, 2021 Daniel A.C. Martin
317
+
318
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
319
+ this software and associated documentation files (the "Software"), to deal in
320
+ the Software without restriction, including without limitation the rights to use,
321
+ copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
322
+ Software, and to permit persons to whom the Software is furnished to do so,
323
+ subject to the following conditions:
324
+
325
+ The above copyright notice and this permission notice shall be included in all
326
+ copies or substantial portions of the Software.
327
+
328
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
329
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
330
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
331
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
332
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
333
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
334
+ SOFTWARE.
335
+ ```
336
+
337
+ The modified source is at [`src/theme/Header/index.js`](src/theme/Header/index.js).
package/index.js CHANGED
@@ -215,6 +215,18 @@ module.exports = function themeGovuk(context, options) {
215
215
  // When installed from npm the theme ships no node_modules of its own,
216
216
  // so we must point webpack at the copy already present in the site.
217
217
  '@mdx-js/react': resolveFromSite('@mdx-js/react'),
218
+ // @not-govuk/header restricts subpath imports via its `exports` field.
219
+ // Our forked Header component imports logos and the SCSS by their dist paths.
220
+ // Alias them to absolute paths to bypass the exports field restriction.
221
+ ...((() => {
222
+ const headerDir = findPkgDir('@not-govuk/header', [siteDir, __dirname]);
223
+ return {
224
+ '@not-govuk/header/dist/CrownLogo$': path.join(headerDir, 'dist/CrownLogo.js'),
225
+ '@not-govuk/header/dist/CrownLogoOld$': path.join(headerDir, 'dist/CrownLogoOld.js'),
226
+ '@not-govuk/header/dist/CoatLogo$': path.join(headerDir, 'dist/CoatLogo.js'),
227
+ '@not-govuk/header/assets/Header.scss$': path.join(headerDir, 'assets/Header.scss'),
228
+ };
229
+ })()),
218
230
  },
219
231
  },
220
232
  plugins: [
@@ -275,7 +287,6 @@ module.exports = function themeGovuk(context, options) {
275
287
  );
276
288
  }
277
289
  ),
278
- // Null out the font SCSS wrappers from @not-govuk/page.
279
290
  // GovUKPage.scss imports gds-transport.css, and NotGovUKPage.scss
280
291
  // imports roboto.css. These contain @font-face rules with relative
281
292
  // URLs that, when inlined by sass and processed by css-loader,
@@ -294,6 +305,11 @@ module.exports = function themeGovuk(context, options) {
294
305
  rules: [
295
306
  {
296
307
  test: /\.m?js$/,
308
+ // `javascript/auto` lets webpack use its default JS pipeline (including
309
+ // Docusaurus's babel-loader rule) instead of treating every .mjs file
310
+ // as strict ESM. Without this, files like tslib.es6.mjs cause
311
+ // "Module parse failed: Unexpected token" because no loader is matched.
312
+ type: 'javascript/auto',
297
313
  resolve: {
298
314
  fullySpecified: false,
299
315
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/docusaurus-theme-govuk",
3
- "version": "0.0.7-alpha",
3
+ "version": "0.0.8-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",
@@ -98,3 +98,195 @@
98
98
  .app-text-secondary {
99
99
  color: #505a5f;
100
100
  }
101
+
102
+ // Ensure govuk-service-navigation--inverse styles apply in our rebranded context.
103
+ // govuk-frontend compiles these inside @include _govuk-rebrand which may not
104
+ // resolve correctly depending on SCSS load order — duplicate the rules explicitly.
105
+ .govuk-template--rebranded .govuk-service-navigation--inverse {
106
+ background-color: #1d70b8;
107
+ border-bottom: none;
108
+ color: #fff;
109
+
110
+ .govuk-width-container {
111
+ border-width: 1px 0;
112
+ border-style: solid;
113
+ border-color: #8eb8dc;
114
+ }
115
+
116
+ .govuk-service-navigation__link {
117
+ color: #fff;
118
+
119
+ &:link,
120
+ &:visited {
121
+ color: #fff;
122
+ }
123
+
124
+ &:hover {
125
+ color: #fff;
126
+ }
127
+ }
128
+
129
+ .govuk-service-navigation__item,
130
+ .govuk-service-navigation__service-name {
131
+ border-color: #fff;
132
+ }
133
+ }
134
+
135
+ // Homepage masthead — a blue welcome band that appears between the service
136
+ // navigation and the main content on the homepage only. The background matches
137
+ // the GOV.UK rebrand header/service-navigation blue (#1d70b8 = $govuk-brand-colour)
138
+ // so that all three elements read as one unified block.
139
+ .app-masthead {
140
+ background-color: #1d70b8;
141
+ // Kill any top border that service-navigation might leave
142
+ border-top: none;
143
+ }
144
+
145
+ .app-masthead__container {
146
+ padding-top: 40px; // govuk-spacing(7)
147
+ }
148
+
149
+ .app-masthead__title {
150
+ color: #fff;
151
+
152
+ // Override the default dark govuk heading colour
153
+ &.govuk-heading-xl {
154
+ color: #fff;
155
+ margin-bottom: 0;
156
+ }
157
+ }
158
+
159
+ .app-masthead__description {
160
+ color: #fff;
161
+ font-size: 1.5rem;
162
+ line-height: 1.25;
163
+ }
164
+
165
+ // Search bar rendered as a flex sibling inside govuk-header__container.
166
+ // govuk-frontend's container uses a clearfix float layout; we switch it to
167
+ // flex so children line up in a row and can be vertically centred.
168
+ // margin-left:auto on .app-header-search pushes it to the right edge.
169
+ .govuk-header__container {
170
+ display: flex;
171
+ align-items: center;
172
+ flex-wrap: nowrap;
173
+ }
174
+
175
+ .app-header-search {
176
+ margin-left: auto;
177
+ flex-shrink: 0;
178
+ display: flex;
179
+ align-items: center;
180
+ z-index: 1;
181
+
182
+ // accessible-autocomplete wrapper
183
+ .autocomplete__wrapper {
184
+ position: relative;
185
+ width: 300px;
186
+ }
187
+
188
+ // Input field — always white background with search icon on the left
189
+ .autocomplete__input {
190
+ width: 100%;
191
+ padding: 4px 8px 4px 40px;
192
+ border: 2px solid #fff;
193
+ border-radius: 0;
194
+ background-color: #fff;
195
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230b0c0c' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
196
+ background-repeat: no-repeat;
197
+ background-position: 10px center;
198
+ background-size: 20px 20px;
199
+ color: #0b0c0c;
200
+ font-family: Helvetica, Arial, sans-serif;
201
+ font-size: 0.875rem;
202
+ box-sizing: border-box;
203
+
204
+ &::placeholder {
205
+ color: #505a5f;
206
+ }
207
+
208
+ &:focus {
209
+ outline: 3px solid #fd0;
210
+ outline-offset: 0;
211
+ }
212
+ }
213
+
214
+ // Dropdown menu
215
+ .autocomplete__menu {
216
+ position: absolute;
217
+ top: 100%;
218
+ left: 0;
219
+ right: 0;
220
+ background: #fff;
221
+ border: 2px solid #0b0c0c;
222
+ border-radius: 0;
223
+ margin: 0;
224
+ padding: 0;
225
+ list-style: none;
226
+ max-height: 342px;
227
+ overflow-y: auto;
228
+ z-index: 10;
229
+ }
230
+
231
+ // Inline menu variant (not used, but scoped just in case)
232
+ .autocomplete__menu--inline {
233
+ position: relative;
234
+ }
235
+
236
+ .autocomplete__option {
237
+ padding: 8px 12px;
238
+ font-family: Helvetica, Arial, sans-serif;
239
+ font-size: 0.875rem;
240
+ color: #0b0c0c;
241
+ cursor: pointer;
242
+ border-bottom: 1px solid #f3f2f1;
243
+
244
+ &:last-child {
245
+ border-bottom: none;
246
+ }
247
+ }
248
+
249
+ .autocomplete__option--focused,
250
+ .autocomplete__option:hover {
251
+ background: #1d70b8;
252
+ color: #fff;
253
+ outline: none;
254
+
255
+ .app-search__context {
256
+ color: rgba(255, 255, 255, 0.8);
257
+ }
258
+ }
259
+
260
+ // Two-line suggestion layout
261
+ .app-search__title {
262
+ display: block;
263
+ }
264
+
265
+ .app-search__context {
266
+ display: block;
267
+ font-size: 0.75rem;
268
+ color: #505a5f;
269
+ margin-top: 2px;
270
+ }
271
+
272
+ .autocomplete__option--no-results {
273
+ padding: 8px 12px;
274
+ font-family: Helvetica, Arial, sans-serif;
275
+ font-size: 0.875rem;
276
+ color: #505a5f;
277
+ }
278
+
279
+ // Status / hint text (visually hidden assistive text)
280
+ .autocomplete__status {
281
+ position: absolute;
282
+ width: 1px;
283
+ height: 1px;
284
+ padding: 0;
285
+ margin: -1px;
286
+ overflow: hidden;
287
+ clip: rect(0, 0, 0, 0);
288
+ white-space: nowrap;
289
+ border: 0;
290
+ }
291
+ }
292
+
@@ -6,7 +6,9 @@ import MDXContent from '@theme/MDXContent';
6
6
  function useSyntheticTitle() {
7
7
  const {metadata, frontMatter, contentTitle} = useDoc();
8
8
  const shouldRender =
9
- !frontMatter.hide_title && typeof contentTitle === 'undefined';
9
+ !frontMatter.hide_title &&
10
+ typeof contentTitle === 'undefined' &&
11
+ metadata.slug !== '/';
10
12
  if (!shouldRender) {
11
13
  return null;
12
14
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Forked from @not-govuk/header (MIT Licence)
3
+ * Copyright (C) 2019–2021 Crown Copyright
4
+ * Copyright (C) 2019–2021 Daniel A.C. Martin
5
+ *
6
+ * Modifications: added `children` prop rendered as a flex sibling inside the
7
+ * header container, allowing content (e.g. a search bar) to be injected into
8
+ * the right-hand side of `govuk-header__container`.
9
+ */
10
+
11
+ import React from 'react';
12
+ import {classBuilder} from '@react-foundry/component-helpers';
13
+ import {Link} from '@not-govuk/link';
14
+ import {WidthContainer} from '@not-govuk/width-container';
15
+ import {CrownLogo} from '@not-govuk/header/dist/CrownLogo';
16
+ import {CrownLogoOld} from '@not-govuk/header/dist/CrownLogoOld';
17
+ import {CoatLogo} from '@not-govuk/header/dist/CoatLogo';
18
+ import '@not-govuk/header/assets/Header.scss';
19
+
20
+ const departmentMap = {
21
+ 'home-office': 'Home Office',
22
+ 'department-for-communities-and-local-government': 'DCLG',
23
+ 'department-for-culture-media-sport': 'DCMS',
24
+ 'department-for-environment-food-rural-affairs': 'DEFRA',
25
+ 'department-for-work-pensions': 'DWP',
26
+ 'foreign-commonwealth-development-office': 'FCDO',
27
+ 'foreign-commonwealth-office': 'FCO',
28
+ 'hm-revenue-customs': 'HMRC',
29
+ 'hm-treasury': 'HM Treasury',
30
+ 'ministry-of-justice': 'MoJ',
31
+ 'office-of-the-leader-of-the-house-of-lords': '',
32
+ 'scotland-office': 'Scotland Office',
33
+ 'wales-office': 'Wales Office',
34
+ };
35
+
36
+ const departmentText = (d) => {
37
+ if (!d) return null;
38
+ return (
39
+ departmentMap[d] ||
40
+ d
41
+ .split('-')
42
+ .map((e) => {
43
+ switch (e) {
44
+ case 'and': return '';
45
+ case 'hm': return 'HM';
46
+ case 'for': return '';
47
+ case 'of': return 'o';
48
+ case 'the': return '';
49
+ default: return e.charAt(0).toUpperCase();
50
+ }
51
+ })
52
+ .join('')
53
+ );
54
+ };
55
+
56
+ const Header = ({
57
+ children,
58
+ classBlock,
59
+ classModifiers: _classModifiers = [],
60
+ className,
61
+ department,
62
+ govUK = false,
63
+ maxContentsWidth,
64
+ navigation = [],
65
+ organisationHref,
66
+ organisationText,
67
+ rebrand = false,
68
+ serviceHref = '/',
69
+ serviceName,
70
+ signOutHref,
71
+ signOutText = 'Sign out',
72
+ logo: _logo,
73
+ ...attrs
74
+ }) => {
75
+ const classModifiers = Array.isArray(_classModifiers)
76
+ ? _classModifiers
77
+ : [_classModifiers];
78
+
79
+ const classes = classBuilder(
80
+ 'govuk-header',
81
+ classBlock,
82
+ [...classModifiers, department],
83
+ className,
84
+ );
85
+
86
+ const A = (props) => (
87
+ <Link classBlock={classes('link')} {...props} />
88
+ );
89
+
90
+ const orgHref = organisationHref || (govUK ? 'https://www.gov.uk/' : '/');
91
+ const orgText =
92
+ organisationText || (govUK ? 'GOV.UK' : departmentText(department));
93
+
94
+ const navLinks = !signOutHref
95
+ ? navigation
96
+ : [...navigation, {href: signOutHref, text: signOutText, forceExternal: true}];
97
+
98
+ const logo =
99
+ _logo !== undefined
100
+ ? _logo
101
+ : govUK
102
+ ? rebrand
103
+ ? <CrownLogo focusable="false" className={classes('logotype')} height="30" width="162" />
104
+ : <CrownLogoOld focusable="false" className={classes('logotype')} height="30" width="148" />
105
+ : <CoatLogo aria-hidden="true" focusable="false" className={classes('logotype', ['coat'])} height="30" width="36" />;
106
+
107
+ return (
108
+ <header {...attrs} className={classes()} data-module="govuk-header">
109
+ <WidthContainer maxWidth={maxContentsWidth} className={classes('container')}>
110
+ <div className={classes('logo')}>
111
+ <A
112
+ href={orgHref}
113
+ classModifiers={[
114
+ 'homepage',
115
+ orgText && orgText.length > 9 ? 'small' : undefined,
116
+ ]}
117
+ >
118
+ {logo}
119
+ {govUK ? null : (
120
+ <span className={classes('logotype-text')}>{orgText}</span>
121
+ )}
122
+ </A>
123
+ </div>
124
+
125
+ {(serviceName || navLinks.length) ? (
126
+ <div className={classes('content')}>
127
+ {serviceName && (
128
+ <A href={serviceHref} className={classes('service-name')}>
129
+ {serviceName}
130
+ </A>
131
+ )}
132
+ {navLinks.length ? (
133
+ <nav className={classes('navigation')} aria-label="Menu">
134
+ <button
135
+ type="button"
136
+ className={classes(
137
+ 'menu-button',
138
+ undefined,
139
+ 'govuk-js-header-toggle',
140
+ )}
141
+ aria-controls="navigation"
142
+ hidden
143
+ >
144
+ Menu
145
+ </button>
146
+ <ul id="navigation" className={classes('navigation-list')}>
147
+ {navLinks.map(({active, text, ...linkAttrs}, i) => (
148
+ <li
149
+ key={i}
150
+ className={classes(
151
+ 'navigation-item',
152
+ active ? 'active' : undefined,
153
+ )}
154
+ >
155
+ <A {...linkAttrs}>{text}</A>
156
+ </li>
157
+ ))}
158
+ </ul>
159
+ </nav>
160
+ ) : null}
161
+ </div>
162
+ ) : null}
163
+
164
+ {children}
165
+ </WidthContainer>
166
+ </header>
167
+ );
168
+ };
169
+
170
+ export default Header;
@@ -1,47 +1,16 @@
1
1
  import React from 'react';
2
2
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
3
- import useBaseUrl from '@docusaurus/useBaseUrl';
4
3
  import Layout from '@theme/Layout';
5
4
 
5
+ // Homepage component for consumer sites that use a custom page (src/pages/index.js)
6
+ // rather than a docs-based root. The masthead is rendered automatically by Layout
7
+ // when the current path is '/' and themeConfig.govuk.homepage is configured.
6
8
  export default function Homepage() {
7
9
  const {siteConfig} = useDocusaurusContext();
8
- const baseUrl = useBaseUrl('/');
9
10
 
10
11
  return (
11
12
  <Layout title={siteConfig.title} description={siteConfig.tagline}>
12
- <div className="govuk-grid-row">
13
- <div className="govuk-grid-column-two-thirds">
14
- <h1 className="govuk-heading-xl govuk-!-margin-top-8">
15
- {siteConfig.title}
16
- </h1>
17
-
18
- {siteConfig.tagline && (
19
- <p className="govuk-body-l">
20
- {siteConfig.tagline}
21
- </p>
22
- )}
23
-
24
- <a
25
- href={baseUrl}
26
- role="button"
27
- draggable="false"
28
- className="govuk-button govuk-button--start govuk-!-margin-top-4"
29
- >
30
- Get started
31
- <svg
32
- className="govuk-button__start-icon"
33
- xmlns="http://www.w3.org/2000/svg"
34
- width="17.5"
35
- height="19"
36
- viewBox="0 0 33 40"
37
- aria-hidden="true"
38
- focusable="false"
39
- >
40
- <path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z" />
41
- </svg>
42
- </a>
43
- </div>
44
- </div>
13
+ {/* Masthead is injected by Layout on the root path. This is because we need to adjust the styling for the header/nav, so it must live in the global Layout. */}
45
14
  </Layout>
46
15
  );
47
16
  }
@@ -1,10 +1,13 @@
1
1
  import React from 'react';
2
2
  import '../../css/theme.scss';
3
- import {SkipLink, Header, Footer, PhaseBanner, ServiceNavigation} from '@not-govuk/simple-components';
3
+ import {SkipLink, Footer, PhaseBanner, ServiceNavigation} from '@not-govuk/simple-components';
4
+ import Header from '../Header';
4
5
  import SidebarNav from '../SidebarNav';
6
+ import SearchBar from '@theme/SearchBar';
5
7
  import {useLocation} from '@docusaurus/router';
6
8
  import Head from '@docusaurus/Head';
7
9
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
10
+ import useBaseUrl from '@docusaurus/useBaseUrl';
8
11
  import LayoutProvider from '@theme/Layout/Provider';
9
12
  import AnnouncementBar from '@theme/AnnouncementBar';
10
13
 
@@ -110,9 +113,15 @@ export default function Layout(props) {
110
113
  const header = govukConfig.header || {};
111
114
  const phaseBanner = govukConfig.phaseBanner;
112
115
  const footer = govukConfig.footer || {};
116
+ const homepageConfig = govukConfig.homepage;
113
117
 
114
118
  // Strip baseUrl so sidebar matching works regardless of deployment path
115
119
  const pathname = stripBaseUrl(location.pathname, siteConfig.baseUrl);
120
+
121
+ const isHomepage = pathname === '/';
122
+ const getStartedHref = useBaseUrl(
123
+ homepageConfig?.getStartedHref || '/getting-started',
124
+ );
116
125
  const baseUrl = siteConfig.baseUrl.endsWith('/')
117
126
  ? siteConfig.baseUrl.slice(0, -1)
118
127
  : siteConfig.baseUrl;
@@ -169,14 +178,57 @@ export default function Layout(props) {
169
178
  rebrand
170
179
  organisationText={header.organisationText}
171
180
  organisationHref={header.organisationHref}
172
- />
181
+ >
182
+ <div className="app-header-search">
183
+ <SearchBar />
184
+ </div>
185
+ </Header>
173
186
 
174
- <ServiceNavigation
187
+ <ServiceNavigation
175
188
  items={serviceNavItems}
176
189
  serviceName={header.serviceName}
177
190
  serviceHref={withBase(header.serviceHref || '/')}
191
+ classModifiers={isHomepage && homepageConfig ? 'inverse' : undefined}
178
192
  />
179
193
 
194
+ {isHomepage && homepageConfig && (
195
+ <div className="app-masthead">
196
+ <div className="govuk-width-container app-masthead__container">
197
+ <div className="govuk-grid-row">
198
+ <div className="govuk-grid-column-two-thirds">
199
+ <h1 className="govuk-heading-xl app-masthead__title">
200
+ {siteConfig.tagline || siteConfig.title}
201
+ </h1>
202
+ {homepageConfig.description && (
203
+ <p className="app-masthead__description">
204
+ {homepageConfig.description}
205
+ </p>
206
+ )}
207
+ <a
208
+ href={getStartedHref}
209
+ role="button"
210
+ draggable="false"
211
+ className="govuk-button govuk-button--start govuk-button--inverse"
212
+ >
213
+ Get started
214
+ <svg
215
+ className="govuk-button__start-icon"
216
+ xmlns="http://www.w3.org/2000/svg"
217
+ width="17.5"
218
+ height="19"
219
+ viewBox="0 0 33 40"
220
+ aria-hidden="true"
221
+ focusable="false"
222
+ >
223
+ <path fill="currentColor" d="M0 0h13l20 20-20 20H0l20-20z" />
224
+ </svg>
225
+ </a>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ )}
231
+
180
232
  <div className="govuk-width-container">
181
233
  {phaseBanner && (
182
234
  <PhaseBanner phase={phaseBanner.phase}>
@@ -0,0 +1,206 @@
1
+ import React, { useState, useCallback, useEffect, useMemo } from 'react';
2
+ import BrowserOnly from '@docusaurus/BrowserOnly';
3
+ import { useHistory } from '@docusaurus/router';
4
+ import useBaseUrl from '@docusaurus/useBaseUrl';
5
+ // github-slugger is Docusaurus's own heading-anchor dependency.
6
+ // Importing it directly ensures our anchor derivation never drifts from
7
+ // the IDs Docusaurus writes into the built HTML.
8
+ import GithubSlugger from 'github-slugger';
9
+ // @generated module emitted by @easyops-cn/docusaurus-search-local at build time;
10
+ // contains the hashed URL of the search index JSON (e.g. "search-index-fa7ba571.json").
11
+ // eslint-disable-next-line import/no-unresolved
12
+ import { searchIndexUrl } from '@generated/@easyops-cn/docusaurus-search-local/default/generated-constants.js';
13
+
14
+ /**
15
+ * Resolve the template URL emitted by easyops.
16
+ * The URL contains a `{dir}` placeholder for the doc root / locale directory.
17
+ * For a single-locale site with docsRouteBasePath '/' the dir is empty.
18
+ */
19
+ function resolveSearchIndexUrl(template) {
20
+ // Strip the {dir} token (single-locale / single-docs-plugin case).
21
+ return template.replace('{dir}', '');
22
+ }
23
+
24
+ /**
25
+ * Fetch the easyops-generated lunr document list.
26
+ *
27
+ * Document schema (all fields present depend on type):
28
+ * b,i,t,u — page root: t = page title, b = breadcrumbs (empty)
29
+ * h,i,p,t,u — heading: t = heading text, h = anchor hash, p = parent page id
30
+ * i,p,s,t,u — paragraph: t = body text, s = section heading / page title
31
+ * h,i,p,s,t,u — para+anchor: t = body text, s = section heading, h = anchor hash
32
+ */
33
+ async function fetchSearchDocs(url) {
34
+ const res = await fetch(url);
35
+ if (!res.ok) throw new Error(`Failed to load search index: ${res.status}`);
36
+ const data = await res.json();
37
+ return Object.values(data).flatMap((bucket) => bucket?.documents ?? []);
38
+ }
39
+
40
+ /** Minimal HTML escaping for values injected via innerHTML in suggestion templates. */
41
+ function escapeHtml(str) {
42
+ return String(str)
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;');
47
+ }
48
+
49
+ function SearchBarInner() {
50
+ const history = useHistory();
51
+ const indexUrl = useBaseUrl(resolveSearchIndexUrl(searchIndexUrl));
52
+ const rootUrl = useBaseUrl('');
53
+ const [docs, setDocs] = useState([]);
54
+
55
+ useEffect(() => {
56
+ fetchSearchDocs(indexUrl)
57
+ .then(setDocs)
58
+ .catch((err) => console.warn('[SearchBar] Could not load search index', err));
59
+ }, [indexUrl]);
60
+
61
+ // Build URL → page title lookup from page-root entries (those with `b` field).
62
+ const pageByUrl = useMemo(() => {
63
+ const map = new Map();
64
+ for (const doc of docs) {
65
+ if ('b' in doc) map.set(doc.u, doc.t);
66
+ }
67
+ return map;
68
+ }, [docs]);
69
+
70
+ // Derive the site root URL and its title directly from the index — the
71
+ // page-root entry with the shortest URL is always the site home page.
72
+ // This is more reliable than useBaseUrl('') which can return '' in some
73
+ // rendering contexts, causing the root title to leak into context trails.
74
+ const { rootPageUrl, rootPageTitle } = useMemo(() => {
75
+ let shortest = null;
76
+ for (const [url, title] of pageByUrl) {
77
+ if (shortest === null || url.length < shortest.url.length) {
78
+ shortest = { url, title };
79
+ }
80
+ }
81
+ return { rootPageUrl: shortest?.url ?? rootUrl, rootPageTitle: shortest?.title ?? null };
82
+ }, [pageByUrl, rootUrl]);
83
+
84
+ /**
85
+ * Walk the URL path upwards collecting ancestor page titles.
86
+ * Stops at (and excludes) the site root so the home-page title is never shown.
87
+ * e.g. "/interactive-map/api/button-definition"
88
+ * → "/interactive-map/api" → "API reference"
89
+ * → "/interactive-map" → skipped (site root)
90
+ */
91
+ const getAncestorPages = useCallback(
92
+ (docUrl, currentPageTitle) => {
93
+ const ancestors = [];
94
+ const normalizedRoot = rootPageUrl.replace(/\/$/, '');
95
+ let url = docUrl.replace(/\/$/, '');
96
+ while (url.lastIndexOf('/') > 0) {
97
+ url = url.substring(0, url.lastIndexOf('/'));
98
+ if (url === normalizedRoot || url + '/' === rootPageUrl) break;
99
+ const title = pageByUrl.get(url) ?? pageByUrl.get(url + '/');
100
+ if (title && title !== currentPageTitle) {
101
+ ancestors.unshift(title);
102
+ }
103
+ }
104
+ return ancestors;
105
+ },
106
+ [pageByUrl, rootPageUrl],
107
+ );
108
+
109
+ const source = useCallback(
110
+ (query, populateResults) => {
111
+ if (!query || query.length < 2) {
112
+ populateResults([]);
113
+ return;
114
+ }
115
+ const q = query.toLowerCase();
116
+ // `s` = the heading/section label; `t` = body text (can be a full paragraph).
117
+ // Match against headings only so body paragraphs don't pollute results.
118
+ // Deduplicate by URL so each page appears at most once.
119
+ const seen = new Set();
120
+ const results = [];
121
+ for (const doc of docs) {
122
+ const label = doc.s ?? doc.t;
123
+ if (!label?.toLowerCase().includes(q)) continue;
124
+ if (seen.has(doc.u)) continue;
125
+ seen.add(doc.u);
126
+ const currentPage = pageByUrl.get(doc.u);
127
+ const ancestors = getAncestorPages(doc.u, currentPage);
128
+ // Build a context trail: ancestor pages → current page.
129
+ // Exclude the result label itself and the site root title (home page
130
+ // title is uninformative — user already knows which site they're on).
131
+ const contextParts = [
132
+ ...ancestors,
133
+ ...(currentPage &&
134
+ currentPage !== label &&
135
+ currentPage !== rootPageTitle
136
+ ? [currentPage]
137
+ : []),
138
+ ];
139
+
140
+ // Derive the anchor using github-slugger — the same library Docusaurus
141
+ // uses when writing heading IDs into the built HTML, so they always match.
142
+ // Page-root entries (have `b`) live at the page URL with no hash.
143
+ // Heading/paragraph entries (have `h` field, even when empty) deep-link.
144
+ const anchor = ('h' in doc && !('b' in doc))
145
+ ? new GithubSlugger().slug(label)
146
+ : null;
147
+
148
+ results.push({
149
+ ...doc,
150
+ _label: label,
151
+ _context: contextParts.length ? contextParts.join(' › ') : null,
152
+ _url: anchor ? `${doc.u}#${anchor}` : doc.u,
153
+ });
154
+ if (results.length === 8) break;
155
+ }
156
+ populateResults(results);
157
+ },
158
+ [docs, pageByUrl, getAncestorPages, rootPageTitle],
159
+ );
160
+
161
+ const onConfirm = useCallback(
162
+ (selected) => {
163
+ if (selected?._url) {
164
+ history.push(selected._url);
165
+ }
166
+ },
167
+ [history],
168
+ );
169
+
170
+ const Autocomplete = require('accessible-autocomplete/react').default;
171
+
172
+ return (
173
+ <Autocomplete
174
+ id="govuk-search"
175
+ source={source}
176
+ templates={{
177
+ inputValue: (result) => (result ? result._label : ''),
178
+ suggestion: (result) => {
179
+ if (!result) return '';
180
+ const title = escapeHtml(result._label);
181
+ const context = result._context
182
+ ? `<span class="app-search__context">${escapeHtml(result._context)}</span>`
183
+ : '';
184
+ return `<span class="app-search__title">${title}</span>${context}`;
185
+ },
186
+ }}
187
+ displayMenu="overlay"
188
+ minLength={2}
189
+ placeholder="Search"
190
+ onConfirm={onConfirm}
191
+ tNoResults={() => 'No results found'}
192
+ tAssistiveHint={() =>
193
+ 'When autocomplete results are available use up and down arrows to review and enter to select.'
194
+ }
195
+ />
196
+ );
197
+ }
198
+
199
+ /**
200
+ * SearchBar rendered inside the GOV.UK header.
201
+ * BrowserOnly ensures accessible-autocomplete (which uses DOM APIs) is never
202
+ * evaluated during server-side generation.
203
+ */
204
+ export default function SearchBar() {
205
+ return <BrowserOnly>{() => <SearchBarInner />}</BrowserOnly>;
206
+ }