diff --git a/TODO.md b/TODO.md index 095dd5a1..97a76816 100644 --- a/TODO.md +++ b/TODO.md @@ -7,3 +7,4 @@ - appointment.spec.js:62 (medical history and symptom) fails under parallel load, passes solo - the modal Save click times out - `returnTo` treats `//evil.example` as a local path, so a posted form can redirect off-site (app/routes/reading.js, the startsWith('/') checks) - Reading history rows and the case-index pagination render `href="#"` stand-ins that research participants will click and get nothing +- Section navigation is vendored from nhsuk-frontend PR #1886 (app/views/_components/section-navigation, app/assets/sass/components/_section-navigation.scss) — when the PR ships, delete both and swap `appSectionNavigation(` for `sectionNavigation(` diff --git a/app.js b/app.js index b238c8c9..fc879f28 100755 --- a/app.js +++ b/app.js @@ -30,12 +30,20 @@ const entryPoints = [ 'app/data/generated/**/*.json' ] +// Headless instances - the route sweep, or a server started just to check a +// page - share this checkout's public/ directory with any dev server already +// running. The kit's esbuild build empties public/ before writing to it and +// then watches it, so building from a second instance strips the running +// server's assets. Those instances only need HTML, so they skip +// the build. Meant for `PROXY=true` runs: without the build, the entry points +// no longer reach nodemon's ignore list, so `npm start` with this set would +// restart on every seed data regeneration. +const skipAssetBuild = process.env.SKIP_ASSET_BUILD === 'true' + async function init() { const prototype = await NHSPrototypeKit.init({ serviceName: config.serviceName, - buildOptions: { - entryPoints - }, + buildOptions: skipAssetBuild ? undefined : { entryPoints }, viewsPath, routes, locals, diff --git a/app/assets/sass/_app-styles.scss b/app/assets/sass/_app-styles.scss index 69be4a44..716d4d95 100644 --- a/app/assets/sass/_app-styles.scss +++ b/app/assets/sass/_app-styles.scss @@ -10,6 +10,8 @@ @forward "components/list-border"; @forward "components/modal"; @forward "components/related-nav"; +@forward "components/support"; +@forward "components/section-navigation"; @forward "components/secondary-navigation"; @forward "components/secondary-navigation-overrides"; @forward "components/count"; diff --git a/app/assets/sass/components/_section-navigation.scss b/app/assets/sass/components/_section-navigation.scss new file mode 100644 index 00000000..351ed794 --- /dev/null +++ b/app/assets/sass/components/_section-navigation.scss @@ -0,0 +1,266 @@ +// Section navigation - vendored ahead of release from nhsuk-frontend PR #1886 +// https://github.com/nhsuk/nhsuk-frontend/pull/1886 +// +// Kept as close to upstream as possible: only the @use lines differ, so when +// the PR is released this file can be deleted and its forward removed. + +@use "nhsuk-frontend/dist/nhsuk/core" as *; + +//// +/// Section navigation component +/// +/// Stacks vertically by default and renders as a horizontal list on tablet, +/// or when the layout is changed by JavaScript using modifier classes +/// +/// @group components/section-navigation +//// + +$nhsuk-section-navigation-shadow: inset 0 -1px 0 $nhsuk-border-colour; +$nhsuk-section-navigation-shadow-reverse: inset 0 -1px 0 rgba($nhsuk-reverse-text-colour, 0.4); +$nhsuk-section-navigation-indicator: inset 0 ($nhsuk-border-width * -1) nhsuk-colour("blue"); +$nhsuk-section-navigation-indicator-reverse: inset 0 ($nhsuk-border-width * -1) $nhsuk-reverse-text-colour; + +/// Section navigation horizontal styles +/// +/// @access private + +@mixin _section-navigation-horizontal { + // Items fit side-by-side: remove the mobile bleed to match horizontal layout + margin-right: auto; + margin-left: auto; + + .nhsuk-section-navigation__list { + flex-flow: row wrap; + gap: nhsuk-spacing(2) nhsuk-spacing(3); + box-shadow: $nhsuk-section-navigation-shadow; + + @include nhsuk-frontend-supported { + flex-wrap: nowrap; + } + } + + .nhsuk-section-navigation__item { + padding: 0; + white-space: nowrap; + } + + .nhsuk-section-navigation__link[aria-current]:not(:focus), + .nhsuk-section-navigation__text[aria-current] { + box-shadow: $nhsuk-section-navigation-indicator; + } + + .nhsuk-section-navigation__link[aria-current]::before, + .nhsuk-section-navigation__text[aria-current]::before { + content: none; + } + + // Increase padding unless small + &:not(.nhsuk-section-navigation--small) { + .nhsuk-section-navigation__link, + .nhsuk-section-navigation__text { + padding: nhsuk-spacing(3) 2px; + } + } +} + +/// Section navigation horizontal styles (desktop) +/// +/// @access private + +@mixin _section-navigation-horizontal-desktop { + .nhsuk-section-navigation__list { + // Larger gap on desktop + gap: nhsuk-spacing(2) nhsuk-spacing(5); + } +} + +/// Section navigation horizontal styles (reverse) +/// +/// @access private + +@mixin _section-navigation-horizontal-reverse { + .nhsuk-section-navigation__list { + box-shadow: $nhsuk-section-navigation-shadow-reverse; + } + + .nhsuk-section-navigation__link[aria-current]:not(:focus), + .nhsuk-section-navigation__text[aria-current] { + box-shadow: $nhsuk-section-navigation-indicator-reverse; + } +} + +@include nhsuk-exports("nhsuk/components/section-navigation") { + .nhsuk-section-navigation { + // Bleed to full width on mobile by negating the page gutter + margin-right: #{$nhsuk-gutter-half * -1}; + margin-left: #{$nhsuk-gutter-half * -1}; + + @include nhsuk-responsive-margin(5, "bottom"); + } + + .nhsuk-section-navigation__list { + display: flex; + + flex-flow: column; + + gap: 0; + + width: 100%; + margin: 0; + padding: 0; + + list-style: none; + } + + .nhsuk-section-navigation__item { + display: flex; + align-items: stretch; + margin-bottom: 0; + padding: 0 $nhsuk-gutter-half; + + .nhsuk-badge, + .nhsuk-tag { + margin-left: nhsuk-spacing(2); + } + } + + // This is a element used as a fallback mechanism for visually + // indicating current page in scenarios where CSS isn’t available. + // We don’t actually want it to be bold normally, so inherit parent font-weight. + .nhsuk-section-navigation__item-current-fallback { + font-weight: inherit; + } + + .nhsuk-section-navigation__link, + .nhsuk-section-navigation__text { + position: relative; + padding: nhsuk-spacing(2) 0; + + @include nhsuk-font(19); + } + + // Text colour is explicitly set to prevent link styles overriding it. + .nhsuk-section-navigation__link[aria-current]:not(:focus), + .nhsuk-section-navigation__link[aria-current]:not(:focus):visited, + .nhsuk-section-navigation__text[aria-current] { + color: $nhsuk-text-colour; + } + + // Stacked layout: absolutely-positioned pseudo-element sits in the item's + // gutter, outdented from the link text. + .nhsuk-section-navigation__link[aria-current]::before, + .nhsuk-section-navigation__text[aria-current]::before { + content: ""; + + position: absolute; + top: 0; + bottom: 0; + left: -$nhsuk-gutter-half; + + width: 0; + + border-left: $nhsuk-border-width solid nhsuk-colour("blue"); + } + + .nhsuk-section-navigation__link:not(:focus) { + @include nhsuk-link-style-default; + @include nhsuk-link-style-no-visited-state; + } + + // Use custom focus styles to keep focus and active indicators aligned + .nhsuk-section-navigation__link:focus { + box-shadow: inset 0 ($nhsuk-focus-width * -1) $nhsuk-focus-text-colour; + } + + // ========================================================= + // Horizontal section navigation + // ========================================================= + + .nhsuk-section-navigation:not(.nhsuk-section-navigation--vertical) { + @media #{nhsuk-from-breakpoint(tablet)} { + @include _section-navigation-horizontal; + } + + @media #{nhsuk-from-breakpoint(desktop)} { + @include _section-navigation-horizontal-desktop; + } + } + + .nhsuk-section-navigation--horizontal { + @media #{nhsuk-until-breakpoint(tablet)} { + @include _section-navigation-horizontal; + } + } + + // ========================================================= + // Reverse section navigation + // ========================================================= + + .nhsuk-section-navigation--reverse { + color: $nhsuk-reverse-text-colour; + + .nhsuk-section-navigation__text { + color: $nhsuk-reverse-text-colour; + } + + .nhsuk-section-navigation__link:not(:focus) { + @include nhsuk-link-style-reverse; + } + + .nhsuk-section-navigation__link[aria-current]:not(:focus), + .nhsuk-section-navigation__link[aria-current]:not(:focus):visited, + .nhsuk-section-navigation__text[aria-current] { + color: $nhsuk-reverse-text-colour; + } + + .nhsuk-section-navigation__link[aria-current]::before, + .nhsuk-section-navigation__text[aria-current]::before { + border-left-color: $nhsuk-reverse-text-colour; + } + } + + .nhsuk-section-navigation--reverse:not(.nhsuk-section-navigation--vertical) { + @media #{nhsuk-from-breakpoint(tablet)} { + @include _section-navigation-horizontal-reverse; + } + } + + .nhsuk-section-navigation--reverse.nhsuk-section-navigation--horizontal { + @media #{nhsuk-until-breakpoint(tablet)} { + @include _section-navigation-horizontal-reverse; + } + } + + // ========================================================= + // Small section navigation + // ========================================================= + + .nhsuk-section-navigation--small { + .nhsuk-section-navigation__link, + .nhsuk-section-navigation__text { + @include nhsuk-font-size(16); + } + + @media #{nhsuk-until-breakpoint(tablet)} { + .nhsuk-section-navigation__link, + .nhsuk-section-navigation__text { + font-size: inherit; + } + } + } + + .nhsuk-section-navigation--small.nhsuk-section-navigation--vertical { + .nhsuk-section-navigation__link, + .nhsuk-section-navigation__text { + padding: nhsuk-spacing(1, $adjustment: 2px) 0; + } + } + + .nhsuk-section-navigation--small:not(.nhsuk-section-navigation--vertical) { + @media #{nhsuk-from-breakpoint(desktop)} { + .nhsuk-section-navigation__list { + gap: nhsuk-spacing(2) nhsuk-spacing(4); + } + } + } +} diff --git a/app/assets/sass/components/_support.scss b/app/assets/sass/components/_support.scss new file mode 100644 index 00000000..f70bf2db --- /dev/null +++ b/app/assets/sass/components/_support.scss @@ -0,0 +1,21 @@ +@use "nhsuk-frontend/dist/nhsuk/core" as *; + +// ========================================================================== +// COMPONENTS / #SUPPORT +// ========================================================================== + +// Left-hand nav on support pages: a heading per section, with the section +// navigation component listing that section's articles. Generated from the +// markdown content tree. + +.app-support-nav { + @include nhsuk-media-query($until: desktop) { + border-bottom: 1px solid $nhsuk-border-colour; + margin-bottom: nhsuk-spacing(5); + padding-bottom: nhsuk-spacing(3); + } +} + +.app-support-nav__heading { + margin-bottom: nhsuk-spacing(2); +} diff --git a/app/content/support/10-getting-started/10-breast-screening-clinics.md b/app/content/support/10-getting-started/10-breast-screening-clinics.md new file mode 100644 index 00000000..88880a43 --- /dev/null +++ b/app/content/support/10-getting-started/10-breast-screening-clinics.md @@ -0,0 +1,102 @@ +--- +title: Get started with breast screening appointments +subtitle: Find out the various steps involved in running screening appointments +published: 2026-09-08 +updated: 2026-09-14 +showContentsList: true +--- + +This guide provides a walkthrough of the various steps required to begin and complete an appointment, and close a clinic. + +[Read about creating and managing clinics](/support/appointments/article-two) + +## Opening a clinic + +A clinic is a timed session of breast screening appointments at a specified location. + +Selecting 'Clinics' from the main menu will show you any clinics taking place today that you have access to. These will beacross the locations where you work. + +You can also see any upcoming or completed clinics. + +Click the name of the clinic you want to view. This will show you details of all the appointments planned for that clinic organised by appointment time. These can be filtered by status (checked in, in progress, completed, etc). + +## Checking in participants + +Each participant needs to be checked in before their appointment can begin. + +Click the 'Check in' link for the relevant appointment to confirm their identity using the information provided. + +If there is any doubt over their identity, the appointment can be stopped at this point. + +## Starting and running an appointment + +A 'Start appointment' action is available for all participants who have been checked in. + +This will begin a four step process. + +If the appointment needs to be stopped once it has begun, either select the 'Exit appointment' option in the main menu or the 'Appointment cannot proceed' link (which is available prior to any images being taken'). + +### 1. Confirm identity + +As well as the initial identity check usually be done at a clinic reception, a second identity check is required by the radiographer running an appointment once they are in the mammogram room with the participant. + +This is also an opportunity to add their ethnicity if it has not already been recorded. + +Click the 'Confirm identity' button to proceed. + +### 2. Review medical information + +Various forms are available to capture any information disclosed by the participant during the appointment. + +They are organised into the following areas: + +- [**Mammogram history**](/support/appointments/article-one) - see the most recent mammogram and record any that are not on the system +- **Medical history** - such as breast cancer, implants, prior procedures and treatments +- **Symptoms and signs** - including lumps, swelling, rashes or nipple changes +- **Breast features** - such as non-surgical scars or moles +- **Breast density factors** - HRT, pregnancy and breast feeding + +All of these are optional, so if there is nothing to add you can choose to 'Complete all and continue'. + +### 3. Take mammogram images + +Each appointment for a breast screening clinic should automatically be on the worklist of mammogram machines at that location. + +If this is the case, you will see 'On worklist' with a green tick in the appointment information bar. + +When you start taking images, thumbnails of each view will appear on Rubie. If they all appear as expected, click 'Confirm all images received' to continue. + +If there are issues with the images (or you see a red cross and 'Not on worklist' in the information bar) the appointment can still be completed by following some manual steps. + +Read about [raising issues when taking images](/support/appointments/article-two). + +#### Observations during mammogram + +If you notice anything that needs recording during the mammogram, you can add them at this stage. + +This follows the same process as adding details during the previous review medical information step. + +#### Additional details + +Regardless of whether images have been recorded automatically or manually, there is an opportunity to add information. This may influence the next stage in the screening process for this appointment. + +- **Imperfect, but best possible images** - Select this when you are aware that the images taken are not ideal but it wouldn't be possible to take better images at a recall (for example, a participant that was particularly difficult to position). This will suggest to image readers that a technical recall may not be appropriate. +- **Not all mammograms taken** - Provide details on why all the images couldn't be taken and decide whether the participant should be invited back to take more images ('more images due'), or if the image set should be considered complete ('partial mammography') + +### 4. Check information + +The final step of each appointment is confirming that the information collected throughout the appointment is correct. + +This provides an opportunity to check and change any details, as well as adding an appointment note if there's any necessary information to share with image readers. + +Once you're satisfied, click 'Complete screening and return to clinic'. This will make the images available for reading. + +[Read a guide to image reading](/support/getting-started/image-reading) + +## Closing a clinic + +On each clinic screen, a 'Close clinic' link is available which takes you to a summary of the status of all appointments from that clinic. + +If all appointments have a 'completed' status (such as screened, attended not screened, cancelled, etc) then you can 'Confirm and close clinic'. + +If there are any outstanding appointments (participants either in progress, checked in but not started, or not checked in) then you need to take the necessary actions to complete these appointments before the clinic can be marked as closed. \ No newline at end of file diff --git a/app/content/support/10-getting-started/20-image-reading.md b/app/content/support/10-getting-started/20-image-reading.md new file mode 100644 index 00000000..610f13e1 --- /dev/null +++ b/app/content/support/10-getting-started/20-image-reading.md @@ -0,0 +1,90 @@ +--- +title: Get started with image reading +subtitle: How to give an opinion on breast screening mammograms +published: 2026-09-14 +updated: 2026-09-14 +showContentsList: true +--- + +Once each breast screening appointment has been completed, any images taken will be available for reading as a 'case'. + +This guide walks you through giving an opinion on those images and how the reading process works. + +## Starting an image reading session + +The image reading dashboard will show you a live count of how many cases are awaiting an opinion. When you select 'Start now', this will allocate 25 of those cases starting with those that have been waiting the longest since screening. + +You may get less than 25 if there are fewer than that available (or another reader is working through the queue concurrently) + +To ease eye strain when looking at mammograms in a dark environment, a dark mode is available. + +[Read about setting up dark mode](/support/image-reading/article-one) + +### Unavailable cases + +Some cases will not be included in a session. These are: + +- [**Awaiting priors**](/support/image-reading/article-one) - these are cases where another reader has requested to see images from a previous mammogram +- [**Cases with issues**](/support/image-reading/article-two) - any cases where problems have been raised need to be resolved before they can be read + +### Stopping and resuming a session + +Once you have an image reading session in progress you won't be able to start another until either: + +- all reads have been completed, or +- you actively end the session through the session overview link + +If there is an active session, you will see a 'resume reading' button rather than 'start now'. + +## Giving an opinion + +The case opinion page is set up to sync with your PACS viewer. As you give an opinion (or skip) the images will refresh in tandem with your other screen. + +The opinion buttons are temporarily disabled when the next participant loads to prevent double clicking. + +Alongside basic information about the participant and when they were screened, the opinion page contains: + +- Thumbnails of all images taken (which should match what's in the PACS viewer) +- Any relevant information obtained during the mammogram appointment, such as symptoms or breast features +- Buttons for you to give an opinion (with keyboard shortcuts) + +### Normal (N) + +Signifying that there are no identified issues with either breast (equivalent to routine recall) + +#### Normal, but add details + +A link is provided below the normal button should you need to provide additional information to support your decision. + +### Technical recall (T) + +This will launch a short form where you can specify exacty which views need to be retaken and a reason (positioning, exposure, obstruction, etc). + +The reason selected can be different for each view. + +### Recall for assessment (R) + +This will launch a screen which asks you to give an opinion (normal or abnorma, recall for assessment) for each breast. + +For those you deem to be abnormal, you are required to add an annotation (of concern level 3 or higher). Click any of the abnormality buttons to see the views of the affected breast. + +Select a location (one click per view) and add a level of concern (and comment if required) before clicking 'Save'. You can add multiple annotations, including those for normal or benign lesions if you want to indicate things that have been reviewed and dismissed. + +[Read about adding annotations](/support/image-reading/article-one) + +You will be asked to confirm a summary of your opinion before continuing to the next participant + +## Completing a reading session + +Once you have successfully submitted all the required opinions, you will be shown a session overview with statistics on the cases you have just read. + +This includes cases split across two tabs: + +- Your opinion - review any cases again and change your opinion if necessary - opinions are not finialised until 2 hours after you give them (unless you actively choose to finialise them sooner) +- All reads and outcomes - this shows the outcome for any cases with two reads - this may be 'awaiting arbitration' if the other image reader gave a differetn opinion to yours + +[Read about the arbitration process](/support/image-reading/article-one) + +Once an outcome has been reached for each case (either through two reads or arbitration) this will trigger the next step in the episode, which will either be closure (until their next routine appointment is due), an invite for technical repeats, or an invite to an assessment clinic. + +Any details provided during image rading will go to the responsible assessor to inform what diagnostic tests are required. \ No newline at end of file diff --git a/app/content/support/20-appointments/10-article-one.md b/app/content/support/20-appointments/10-article-one.md new file mode 100644 index 00000000..583558c9 --- /dev/null +++ b/app/content/support/20-appointments/10-article-one.md @@ -0,0 +1,19 @@ +--- +title: Article one title +subtitle: Subtitle here +published: 2026-09-08 +updated: 2026-09-08 +--- + +Introduction paragraph here. A sentence or two setting out what this page covers. + +## First heading here + +Content here. Replace this with the real guidance. + +## Second heading here + +More content here. + +- List item here +- Another list item here diff --git a/app/content/support/20-appointments/20-article-two.md b/app/content/support/20-appointments/20-article-two.md new file mode 100644 index 00000000..8f224d0f --- /dev/null +++ b/app/content/support/20-appointments/20-article-two.md @@ -0,0 +1,16 @@ +--- +title: Article two title +subtitle: Subtitle here +published: 2026-09-08 +updated: 2026-09-08 +--- + +Introduction paragraph here. A sentence or two setting out what this page covers. + +## First heading here + +Content here. Replace this with the real guidance. + +## Second heading here + +More content here. diff --git a/app/content/support/30-image-reading/10-article-one.md b/app/content/support/30-image-reading/10-article-one.md new file mode 100644 index 00000000..bf73c4c6 --- /dev/null +++ b/app/content/support/30-image-reading/10-article-one.md @@ -0,0 +1,16 @@ +--- +title: Article one title +subtitle: Subtitle here +published: 2026-09-08 +updated: 2026-09-08 +--- + +Introduction paragraph here. A sentence or two setting out what this page covers. + +## First heading here + +Content here. Replace this with the real guidance. + +## Second heading here + +More content here. diff --git a/app/content/support/30-image-reading/20-article-two.md b/app/content/support/30-image-reading/20-article-two.md new file mode 100644 index 00000000..8f224d0f --- /dev/null +++ b/app/content/support/30-image-reading/20-article-two.md @@ -0,0 +1,16 @@ +--- +title: Article two title +subtitle: Subtitle here +published: 2026-09-08 +updated: 2026-09-08 +--- + +Introduction paragraph here. A sentence or two setting out what this page covers. + +## First heading here + +Content here. Replace this with the real guidance. + +## Second heading here + +More content here. diff --git a/app/filters/markdown.js b/app/filters/markdown.js index 6ce4610a..3b4d864a 100644 --- a/app/filters/markdown.js +++ b/app/filters/markdown.js @@ -9,6 +9,64 @@ const md = new MarkdownIt({ typographer: true // Enable smart quotes and other typographic replacements }) +// Give headings ids derived from their text, so anything on the page can link +// to a section - a table of contents, or a link shared with a colleague +const slugifyHeading = (text) => + text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + +md.renderer.rules.heading_open = (tokens, index, options, env, self) => { + const token = tokens[index] + const inline = tokens[index + 1] + + if (inline && inline.type === 'inline' && !token.attrGet('id')) { + token.attrSet('id', slugifyHeading(inline.content)) + } + + return self.renderToken(tokens, index, options) +} + +/** + * The headings in a markdown document, for building a table of contents + * + * Uses the same parser and slugs as the renderer, so the ids always match the + * ones on the rendered page. + * + * @param {string} content - The markdown content to read + * @param {number} [level] - Heading level to collect, 2 (h2) by default + * @returns {Array<{text: string, id: string}>} Headings in document order + * @example + * {{ content | markdownHeadings }} + */ +const markdownHeadings = (content, level = 2) => { + if (!content) { + return [] + } + + const tag = `h${level}` + const tokens = md.parse(content, {}) + const headings = [] + + // The token stream is flat - a heading's text is in the inline token that + // follows its heading_open + tokens.forEach((token, index) => { + if (token.type !== 'heading_open' || token.tag !== tag) return + + const inline = tokens[index + 1] + if (!inline || inline.type !== 'inline') return + + headings.push({ + text: inline.content, + id: token.attrGet('id') || slugifyHeading(inline.content) + }) + }) + + return headings +} + /** * Convert markdown to HTML * Output is automatically marked as safe, no need for | safe filter @@ -26,4 +84,4 @@ const markdown = (content) => { return nunjucksSafe(md.render(content)) } -module.exports = { markdown } +module.exports = { markdown, markdownHeadings } diff --git a/app/lib/utils/support-content.js b/app/lib/utils/support-content.js new file mode 100644 index 00000000..751d3b0e --- /dev/null +++ b/app/lib/utils/support-content.js @@ -0,0 +1,163 @@ +// app/lib/utils/support-content.js +// +// Builds the support section from markdown files on disk, so content can be +// added by dropping a file into a folder - no route or template changes. +// +// app/content/support/1-getting-started/2-signing-in.md +// -> /support/getting-started/signing-in +// +// Numeric prefixes set the order of both folders and files and are stripped +// from the slug, so articles can be reordered without breaking URLs. +// +// The index is rebuilt on every read outside production: the kit's nodemon +// only watches js/json, so a new markdown file would otherwise need a restart. + +const fs = require('fs') +const path = require('path') +const matter = require('gray-matter') +const { markdownHeadings } = require('../../filters/markdown') +const { sentenceCase, formatWords } = require('./strings') + +const contentRoot = path.join(__dirname, '../../content/support') + +// Leading digits used only for ordering, eg "2-signing-in" -> "signing-in" +const orderPrefix = /^(\d+)[-_.]+/ + +/** + * Split a file or folder name into its sort order and slug + * + * @param {string} name - File or folder name, without extension + * @returns {{order: number, slug: string}} Order (Infinity if unprefixed) and slug + */ +const parseName = (name) => { + const match = name.match(orderPrefix) + return { + order: match ? Number(match[1]) : Infinity, + slug: name.replace(orderPrefix, '') + } +} + +/** + * Human-readable title derived from a slug, used when none is given + * + * @param {string} slug - Kebab-case slug + * @returns {string} Title, eg "getting-started" -> "Getting started" + */ +const titleFromSlug = (slug) => sentenceCase(formatWords(slug, '-')) + +const byOrderThenTitle = (a, b) => + a.order - b.order || a.title.localeCompare(b.title) + +/** + * Read one markdown article + * + * @param {string} sectionSlug - Slug of the section the article belongs to + * @param {string} sectionPath - Absolute path to the section folder + * @param {string} fileName - Markdown file name + * @returns {object} Article with slug, metadata, href and markdown body + */ +const readArticle = (sectionSlug, sectionPath, fileName) => { + const { order, slug } = parseName(path.basename(fileName, '.md')) + const file = matter(fs.readFileSync(path.join(sectionPath, fileName), 'utf8')) + const data = file.data || {} + + // The contents list links to the page's h2s. A single entry is no use as a + // contents list, so it is left empty - as it is when the page turns the list + // off with `showContentsList: false`. + const headings = + data.showContentsList === false + ? [] + : markdownHeadings(file.content, 2).map((heading) => ({ + ...heading, + href: `#${heading.id}` + })) + + return { + slug, + order, + sectionSlug, + section: titleFromSlug(sectionSlug), + title: data.title || titleFromSlug(slug), + subtitle: data.subtitle || null, + // Dates come back as Date objects when unquoted in the frontmatter + published: data.published ? String(data.published) : null, + updated: data.updated ? String(data.updated) : null, + href: `/support/${sectionSlug}/${slug}`, + contents: headings.length > 1 ? headings : [], + body: file.content + } +} + +/** + * Read every section and its articles from disk + * + * @returns {Array} Sections in order, each with an ordered articles array + */ +const readSections = () => { + if (!fs.existsSync(contentRoot)) return [] + + return fs + .readdirSync(contentRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const { order, slug } = parseName(entry.name) + const sectionPath = path.join(contentRoot, entry.name) + + const articles = fs + .readdirSync(sectionPath) + .filter((fileName) => fileName.endsWith('.md')) + .map((fileName) => readArticle(slug, sectionPath, fileName)) + .sort(byOrderThenTitle) + + return { slug, order, title: titleFromSlug(slug), articles } + }) + .filter((section) => section.articles.length > 0) + .sort(byOrderThenTitle) +} + +// Reading a couple of dozen small files is cheap, but there is no reason to do +// it on every request once the content can no longer change under us +let cachedSections = null + +/** + * The support content tree - sections, each with their ordered articles + * + * @returns {Array} Sections in display order + */ +const getSupportSections = () => { + if (process.env.NODE_ENV === 'production') { + cachedSections = cachedSections || readSections() + return cachedSections + } + return readSections() +} + +/** + * Find a single section by its slug + * + * @param {string} sectionSlug - Section slug from the URL + * @returns {object|null} Section, or null if there is no such section + */ +const getSupportSection = (sectionSlug) => + getSupportSections().find((section) => section.slug === sectionSlug) || null + +/** + * Find a single article by its section and article slugs + * + * @param {string} sectionSlug - Section slug from the URL + * @param {string} articleSlug - Article slug from the URL + * @returns {object|null} Article, or null if there is no such page + */ +const getSupportArticle = (sectionSlug, articleSlug) => { + const section = getSupportSection(sectionSlug) + if (!section) return null + + return ( + section.articles.find((article) => article.slug === articleSlug) || null + ) +} + +module.exports = { + getSupportSections, + getSupportArticle +} diff --git a/app/routes.js b/app/routes.js index e130ff3a..ae023c1b 100644 --- a/app/routes.js +++ b/app/routes.js @@ -317,6 +317,7 @@ require('./routes/reading')(router) require('./routes/reading-cases')(router) require('./routes/arbitration')(router) require('./routes/reports')(router) +require('./routes/support')(router) router.get('/modal-examples', (req, res) => { res.render('_components/modal/examples') diff --git a/app/routes/support.js b/app/routes/support.js new file mode 100644 index 00000000..6e598adc --- /dev/null +++ b/app/routes/support.js @@ -0,0 +1,33 @@ +// app/routes/support.js +// +// Support pages are generated from the markdown in app/content/support - see +// docs/support-content.md. There is one route per page shape, not per page. + +const { + getSupportSections, + getSupportArticle +} = require('../lib/utils/support-content') + +module.exports = (router) => { + // The left-hand nav appears on every support page, so it goes in locals + router.use('/support', (req, res, next) => { + res.locals.navActive = 'support' + res.locals.supportSections = getSupportSections() + next() + }) + + router.get('/support', (req, res) => { + res.render('support/index') + }) + + router.get('/support/:sectionSlug/:articleSlug', (req, res, next) => { + const article = getSupportArticle( + req.params.sectionSlug, + req.params.articleSlug + ) + + if (!article) return next() + + res.render('support/article', { article }) + }) +} diff --git a/app/views/_components/section-navigation/macro.njk b/app/views/_components/section-navigation/macro.njk new file mode 100644 index 00000000..c01f6a14 --- /dev/null +++ b/app/views/_components/section-navigation/macro.njk @@ -0,0 +1,3 @@ +{% macro appSectionNavigation(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/views/_components/section-navigation/template.njk b/app/views/_components/section-navigation/template.njk new file mode 100644 index 00000000..c314e9d1 --- /dev/null +++ b/app/views/_components/section-navigation/template.njk @@ -0,0 +1,104 @@ +{# + Section navigation - vendored ahead of release from nhsuk-frontend PR #1886 + https://github.com/nhsuk/nhsuk-frontend/pull/1886 + + Class names and params match the upstream component, so when the PR is + released this whole folder can be deleted and `appSectionNavigation(` swapped + for `sectionNavigation(` at the call sites. + + Differences from upstream: no badge support (the badge component in the same + PR is not vendored), and no JavaScript - the JS only picks between horizontal + and vertical automatically, which the vertical variant never needs. +#} + +{% from "nhsuk/macros/attributes.njk" import nhsukAttributes %} + +{#- Set variant for this component #} +{%- set variant = params.variant -%} + +{#- Set classes for this component #} +{%- set classNames = "nhsuk-section-navigation" -%} + +{%- if variant == "reverse" and (not params.classes or not "nhsuk-section-navigation--reverse" in params.classes) %} + {% set classNames = classNames ~ " nhsuk-section-navigation--" ~ variant %} +{% endif %} + +{%- if params.small and (not params.classes or not "nhsuk-section-navigation--small" in params.classes) %} + {% set classNames = classNames ~ " nhsuk-section-navigation--small" %} +{% endif %} + +{%- if params.direction and + params.direction in ["horizontal", "vertical"] and ( + not params.classes or ( + not "nhsuk-section-navigation--horizontal" in params.classes and + not "nhsuk-section-navigation--vertical" in params.classes + ) + ) +%} + {% set classNames = classNames ~ " nhsuk-section-navigation--" ~ params.direction %} +{% endif %} + +{%- if params.classes %} + {% set classNames = classNames ~ " " ~ params.classes %} +{% endif %} + +{%- set items = params.items | select("mapping") | list if params.items else [] %} + +{%- set attributesHtml %} + {{- nhsukAttributes({ + class: classNames, + id: { + value: params.id, + optional: true + }, + "data-module": "nhsuk-section-navigation", + "aria-label": params.ariaLabel | default("Section information", true) + }) -}} + + {{- nhsukAttributes(params.attributes) -}} +{% endset %} + + diff --git a/app/views/_templates/layout-base.html b/app/views/_templates/layout-base.html index af3908a0..6e7f841c 100644 --- a/app/views/_templates/layout-base.html +++ b/app/views/_templates/layout-base.html @@ -68,9 +68,9 @@ text: "Messages", current: true if navActive == "messages" }, { - href: "#", - text: "Help and support", - current: true if navActive == "help" + href: "/support", + text: "Support", + current: true if navActive == "support" } ] | removeEmpty %} diff --git a/app/views/_templates/layout-support.html b/app/views/_templates/layout-support.html new file mode 100644 index 00000000..bd266292 --- /dev/null +++ b/app/views/_templates/layout-support.html @@ -0,0 +1,56 @@ +{# app/views/_templates/layout-support.html #} +{# Two-column layout for the support section - the left-hand nav and the + breadcrumb are built from the content tree, so support pages get both + without doing anything. Set supportArticle for an article page, and + hideSupportNav on a page that lists the content itself. #} + +{% extends "layout-base.html" %} + +{% set hideBackLink = true %} + +{% block beforeContent %} + {# The trail ends at the current page's parent - the breadcrumb macro does not + include the page itself. Sections have no page of their own, so an article's + parent is the support index. #} + {% set breadcrumbItems = [{ href: "/", text: "Home" }] %} + {% if supportArticle %} + {% set breadcrumbItems = breadcrumbItems.concat([{ href: "/support", text: "Support" }]) %} + {% endif %} + {{ breadcrumb({ items: breadcrumbItems }) }} +{% endblock %} + +{% block pageStructure %} +
+ + {% if not hideSupportNav %} +
+
+ {% for section in supportSections %} +

{{ section.title }}

+ + {% set sectionItems = [] %} + {% for article in section.articles %} + {% set sectionItems = sectionItems.concat([{ + href: article.href, + text: article.title, + current: article.href == currentUrl + }]) %} + {% endfor %} + + {{ appSectionNavigation({ + items: sectionItems, + direction: "vertical", + small: true, + ariaLabel: section.title + }) }} + {% endfor %} +
+
+ {% endif %} + +
+ {% block pageContent %}{% endblock %} +
+ +
+{% endblock pageStructure %} diff --git a/app/views/_templates/layout.html b/app/views/_templates/layout.html index dc506296..a60f5bee 100755 --- a/app/views/_templates/layout.html +++ b/app/views/_templates/layout.html @@ -18,6 +18,7 @@ {%- from '_components/modal/macro.njk' import appModal %} {%- from '_components/modal/macro.njk' import appConfirmationModal %} {%- from '_components/secondary-navigation/macro.njk' import appSecondaryNavigation %} +{%- from '_components/section-navigation/macro.njk' import appSectionNavigation %} {%- from '_components/button-menu/macro.njk' import buttonMenu %} {%- from '_components/summary-card/macro.njk' import appSummaryCard %} {%- from '_components/summary-list/macro.njk' import appSummaryListRow %} diff --git a/app/views/dashboard.html b/app/views/dashboard.html index 998a1688..816024c3 100755 --- a/app/views/dashboard.html +++ b/app/views/dashboard.html @@ -60,6 +60,15 @@

descriptionHtml: "

Download clinic reports and data exports

" }) }} +
  • + {{ card({ + clickable: true, + heading: "Support", + headingClasses: "nhsuk-heading-m", + href: "/support", + descriptionHtml: "

    Find out how to use this service

    " + }) }} +
  • diff --git a/app/views/support/article.html b/app/views/support/article.html new file mode 100644 index 00000000..74a82f19 --- /dev/null +++ b/app/views/support/article.html @@ -0,0 +1,35 @@ +{# app/views/support/article.html #} +{# Renders a single markdown article from app/content/support #} + +{% extends "layout-support.html" %} + +{% set supportArticle = article %} +{% set pageHeading = article.title %} + +{% block pageContent %} + + {{ article.section }} +

    {{ pageHeading }}

    + + {% if article.contents.length %} + {{ contentsList({ + items: article.contents, + landmarkLabel: "Contents" + }) }} + {% endif %} + +
    + {{ article.body | markdown }} +
    + + {% if article.updated or article.published %} +

    + {% if article.updated %} + Last updated: {{ article.updated | formatDate }} + {% else %} + Published: {{ article.published | formatDate }} + {% endif %} +

    + {% endif %} + +{% endblock %} diff --git a/app/views/support/index.html b/app/views/support/index.html new file mode 100644 index 00000000..1b19d626 --- /dev/null +++ b/app/views/support/index.html @@ -0,0 +1,57 @@ +{# app/views/support/index.html #} +{# Dashboard of every support page, generated from the content tree. The first + section leads as cards; the rest are laid out two to a row. #} + +{% extends "layout-support.html" %} + +{% set pageHeading = "Support" %} +{# The index lists every page already, so it does without the left-hand nav #} +{% set hideSupportNav = true %} + +{% block pageContent %} + +

    {{ pageHeading }}

    + +

    Information on using Manage breast screening.

    + + {% set leadSection = supportSections[0] %} + {% set remainingSections = supportSections.slice(1) %} + + {% if leadSection %} +

    {{ leadSection.title }}

    + + {% endif %} + +
    + {% for section in remainingSections %} +
    +

    {{ section.title }}

    +
      + {% for article in section.articles %} +
    • + {{ article.title }} + {% if article.subtitle %} +

      {{ article.subtitle }}

      + {% endif %} +
    • + {% endfor %} +
    +
    + {% endfor %} +
    + +{% endblock %} diff --git a/docs/support-content.md b/docs/support-content.md new file mode 100644 index 00000000..166bcaa7 --- /dev/null +++ b/docs/support-content.md @@ -0,0 +1,73 @@ +# Writing support content + +Support pages are markdown files in `app/content/support`. Add a file, refresh the browser, and the page appears — in the left-hand nav, on the support index and at its own URL. No code changes, and no need to restart the prototype. + +## Folder structure + +One folder per section, one markdown file per page: + +``` +app/content/support/ + 1-getting-started/ + 1-signing-in.md + 2-finding-your-way-around.md + 2-appointments/ + 1-checking-someone-in.md + 3-image-reading/ + 1-reading-a-case.md +``` + +## Ordering + +The number at the start of a folder or file name sets its position in the list. It is stripped out of the web address, so `2-signing-in.md` is served at `/support/getting-started/signing-in`. + +That means you can renumber files to reorder them without breaking any links. Gaps are fine — `10-`, `20-`, `30-` leaves room to slot pages in later. Anything without a number sorts to the end, alphabetically. + +## Frontmatter + +Each file starts with a block between `---` lines: + +```markdown +--- +title: Signing in to Manage +subtitle: How to sign in with your smartcard, and what to do if you cannot +published: 2026-09-08 +updated: 2026-09-10 +--- + +Manage uses your NHS care identity to sign you in. + +## Signing in + +Put your smartcard in the reader before you open Manage. +``` + +| Field | Required | Used for | +| --- | --- | --- | +| `title` | Yes | Page heading, left-hand nav, support index | +| `subtitle` | No | One-line description under the link on the support index | +| `published` | No | Shown at the foot of the page if there is no `updated` date | +| `updated` | No | Shown at the foot of the page | +| `showContentsList` | No | Set to `false` to hide the contents list at the top of the page | + +If you leave `title` out, the file name is used instead. + +## Writing the page + +Everything after the frontmatter is ordinary markdown. Don't add a `#` heading for the page title — the title from the frontmatter is rendered as the `h1` for you. Start your headings at `##`. + +Headings get an id automatically, so `## Signing in` can be linked to as `/support/getting-started/signing-in#signing-in`. + +## Contents list + +Pages with two or more `##` headings get a contents list at the top, linking to each one. It is built from the page, so there is nothing to keep up to date. + +To hide it on a particular page, add `showContentsList: false` to the frontmatter. + +## Section names + +A section's name comes from its folder: `2-appointments` shows as "Appointments", `1-getting-started` as "Getting started". Renaming the folder renames the section and changes the addresses of every page inside it. + +## Adding a section + +Make a new folder with a number prefix and put at least one markdown file in it. Empty sections are ignored. diff --git a/package-lock.json b/package-lock.json index 3a9c94f0..2ff0cd89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "accessible-autocomplete": "^3.0.1", "cookie-parser": "^1.4.7", "dayjs": "^1.11.23", + "gray-matter": "^4.0.3", "idiomorph": "^0.8.0", "lodash": "^4.18.1", "markdown-it": "^15.0.2", @@ -3212,6 +3213,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3382,6 +3396,18 @@ "node": ">=6.6.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3752,6 +3778,21 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3980,6 +4021,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4093,6 +4143,35 @@ "node": ">= 20" } }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4126,6 +4205,15 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6150,6 +6238,19 @@ "node": ">=11.0.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", @@ -6574,6 +6675,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6631,6 +6738,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stylehacks": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-9.0.3.tgz", diff --git a/package.json b/package.json index 7a0bd715..f8929df3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "accessible-autocomplete": "^3.0.1", "cookie-parser": "^1.4.7", "dayjs": "^1.11.23", + "gray-matter": "^4.0.3", "idiomorph": "^0.8.0", "lodash": "^4.18.1", "markdown-it": "^15.0.2", diff --git a/scripts/route-sweep.js b/scripts/route-sweep.js index 4b5339a3..b52064c5 100644 --- a/scripts/route-sweep.js +++ b/scripts/route-sweep.js @@ -15,6 +15,7 @@ const { spawn } = require('child_process') const fs = require('fs') const path = require('path') const dayjs = require('dayjs') +const { getSupportSections } = require('../app/lib/utils/support-content') const port = Number(process.env.SWEEP_PORT || 3011) const baseUrl = `http://localhost:${port}` @@ -58,14 +59,21 @@ const skippedPaths = [ * Start the prototype as a plain Express process and wait for it to answer. * * PROXY=true makes the kit skip its nodemon/browsersync watch wrapper, which - * would otherwise leave a process we cannot cleanly kill. + * would otherwise leave a process we cannot cleanly kill. SKIP_ASSET_BUILD + * leaves public/ alone - the sweep needs no CSS, and building would empty the + * directory a dev server running from this checkout is serving. * * @returns {Promise} The running server */ const startServer = async () => { const server = spawn('node', ['.'], { cwd: rootPath, - env: { ...process.env, PORT: String(port), PROXY: 'true' }, + env: { + ...process.env, + PORT: String(port), + PROXY: 'true', + SKIP_ASSET_BUILD: 'true' + }, stdio: ['ignore', 'ignore', 'pipe'] }) @@ -180,6 +188,14 @@ const collectParams = async (sessionFetch) => { type: 'breast-cancer' } + // Support articles are swept in full below; these just stop the route that + // serves them being reported as unfillable + const firstSection = getSupportSections()[0] + if (firstSection) { + params.sectionSlug = firstSection.slug + params.articleSlug = firstSection.articles[0].slug + } + // Create a reading session so the /reading/session/... routes can be swept const created = await sessionFetch( `${baseUrl}/reading/create-session?type=all_reads&limit=5&lazy=false` @@ -278,6 +294,19 @@ const getTemplateSubPaths = (viewsDirectory, includedTemplates) => { return subPaths.filter(Boolean) } +/** + * Every support article URL, from the markdown content on disk. + * + * One route serves every article, so sweeping the route alone would only check + * a single page. + * + * @returns {Array} URLs to sweep + */ +const getSupportUrls = () => + getSupportSections().flatMap((section) => + section.articles.map((article) => article.href) + ) + /** * URLs for the pages served by the wildcard template routes * @@ -374,7 +403,7 @@ const run = async () => { urls.add(built.url) } - for (const url of getTemplateUrls(params)) { + for (const url of [...getTemplateUrls(params), ...getSupportUrls()]) { urls.add(url) }