From 3fe5f243c568dc9c8fbb7a4bcc190c989d97d69b Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:21:38 +0000 Subject: [PATCH 1/3] docs: give the three unlinked slack commands their own sections Also drops / from the admin command table: handle_list_incidents_command splits payload["command"], which holds the slash-command name and never the arguments, so that branch is unreachable and the syntax has never worked. handle_report_incident_command reads no arguments at all. Adds onBrokenAnchors: "throw" and a list/section consistency check, run from a new unfiltered docs-checks job so it can later be made a required check. --- .github/workflows/docs-build.yml | 2 + .github/workflows/docs-checks.yml | 39 ++++ .../settings/plugins/configuring-slack.mdx | 4 +- docs/docs/user-guide/incidents/commander.mdx | 25 ++- docs/docusaurus.config.js | 5 + docs/package.json | 4 +- docs/scripts/check-command-links.mjs | 175 +++++++++++++++ docs/scripts/check-command-links.test.mjs | 206 ++++++++++++++++++ 8 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/docs-checks.yml create mode 100644 docs/scripts/check-command-links.mjs create mode 100644 docs/scripts/check-command-links.test.mjs diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index d5ca20165..004f6feef 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -34,4 +34,6 @@ jobs: cache: npm cache-dependency-path: docs/package-lock.json - run: npm ci --no-audit --no-fund + # onBrokenLinks/onBrokenAnchors are set to "throw", so this is also the + # gate on every internal link and anchor across the site. - run: npm run build diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml new file mode 100644 index 000000000..4177de3f9 --- /dev/null +++ b/.github/workflows/docs-checks.yml @@ -0,0 +1,39 @@ +name: Docs checks + +# Cheap docs gates, deliberately NOT path-filtered so this job reports on every +# PR and can therefore be made a required check (a path-filtered required check +# permanently blocks PRs that miss the filter). The expensive Docusaurus build +# stays in docs-build.yml, which is path-filtered and advisory. +# +# What this catches that the build cannot: a Slack command listed on the +# Incident Commander page as a plain code span with no link at all. MDX renders +# that without complaint and there is no anchor for onBrokenAnchors to reject, +# so it shipped unnoticed (issue #82). + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + docs-links: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + # No npm ci: both steps are plain Node with no dependencies, and skipping + # the install is what keeps this cheap enough to run on every PR. + - run: npm run check-command-links + - run: npm test diff --git a/docs/docs/administration/settings/plugins/configuring-slack.mdx b/docs/docs/administration/settings/plugins/configuring-slack.mdx index 099bb3a0b..76fec93a1 100644 --- a/docs/docs/administration/settings/plugins/configuring-slack.mdx +++ b/docs/docs/administration/settings/plugins/configuring-slack.mdx @@ -177,14 +177,14 @@ You can override their values if you wish to do so. Included below are their des | `/dispatch-add-timeline-event` | Opens a modal to add an event to the incident timeline. | | `/dispatch-assign-role` | Opens a modal for assigning a role to a participant. | | `/dispatch-engage-oncall` | Opens a modal to engage an on-call person. | -| `/dispatch-list-incidents ` | Lists current active and stable incidents and closed incidents in the last 24 hours. | +| `/dispatch-list-incidents` | Lists current active and stable incidents and closed incidents in the last 24 hours. | | `/dispatch-list-my-tasks` | Opens a modal with the list of your open and resolved incident tasks. | | `/dispatch-list-participants` | Opens a modal with the list of incident participants. | | `/dispatch-list-signals` | Opens a modal with the list of signal definitions configured for the conversation from where command was ran. | | `/dispatch-list-tasks` | Opens a modal with the list of open and resolved incident tasks. | | `/dispatch-list-workflows` | List workflows previously run during this incident. | | `/dispatch-report-executive` | Opens a modal to write an executive report. | -| `/dispatch-report-incident ` | Opens a modal to report an incident. This command can be run from non-incident channels where the Dispatch bot is a member. | +| `/dispatch-report-incident` | Opens a modal to report an incident. This command can be run from non-incident channels where the Dispatch bot is a member. | | `/dispatch-report-tactical` | Opens a modal to write a tactical report. | | `/dispatch-run-workflow` | Run a workflow and associate artifacts with this incident. | | `/dispatch-update-incident` | Opens a modal to update the incident. | diff --git a/docs/docs/user-guide/incidents/commander.mdx b/docs/docs/user-guide/incidents/commander.mdx index e5c0dc4db..868aa705d 100644 --- a/docs/docs/user-guide/incidents/commander.mdx +++ b/docs/docs/user-guide/incidents/commander.mdx @@ -15,11 +15,11 @@ All Slack commands are listed below, or you may view _groups_ of commands relati - [`/dispatch-add-timeline-event`](#dispatch-add-timeline-event) - [`/dispatch-assign-role`](#dispatch-assign-role) - [`/dispatch-engage-oncall`](#dispatch-engage-oncall) +- [`/dispatch-list-incidents`](#dispatch-list-incidents) - [`/dispatch-list-my-tasks`](#dispatch-list-my-tasks) - [`/dispatch-list-participants`](#dispatch-list-participants) - [`/dispatch-list-tasks`](#dispatch-list-tasks) - [`/dispatch-list-workflows`](#dispatch-list-workflows) -- `/dispatch-list-incidents` - [`/dispatch-notifications-group`](#dispatch-notifications-group) - [`/dispatch-report-executive`](#dispatch-report-executive) - [`/dispatch-report-incident`](#dispatch-report-incident) @@ -29,8 +29,9 @@ All Slack commands are listed below, or you may view _groups_ of commands relati - [`/dispatch-run-workflow`](#dispatch-run-workflow) - [`/dispatch-create-task`](#dispatch-create-task) - [`/dispatch-create-case`](#dispatch-create-case) -- `/dispatch-list-signals` -- `/dispatch-summary` +- [`/dispatch-list-signals`](#dispatch-list-signals) +- [`/dispatch-summary`](#dispatch-summary) + ## People These commands help manage the people helping resolve the incident. @@ -226,3 +227,21 @@ This command will create a case for the current incident.
+ +### /dispatch-list-incidents + +Use `/dispatch-list-incidents` to open a modal listing every incident across every project that is currently active or stable, plus any incident closed in the last 24 hours. Each entry links to the incident in Dispatch and shows its commander, project, status, type, severity, and priority. + +The command takes no arguments and can be run outside an incident channel as well as inside one. Restricted incidents are never listed, and the modal shows at most 49 incidents; use the Dispatch web interface if your deployment has more. + +### /dispatch-list-signals + +Use `/dispatch-list-signals` to see which signal definitions send their cases to the conversation you run it from. It opens a modal listing every definition whose case type targets that conversation, showing each one's name and variant alongside a [Snooze](../../administration/settings/signal/filter.mdx#snooze-filter) button that temporarily stops it creating cases. Longer lists are paged 25 at a time. + +The command takes no arguments. If no case type targets that conversation, the modal tells you so. + +### /dispatch-summary + +Use `/dispatch-summary` in an incident or case channel to get an AI-generated read-in summary of what has happened so far. It is most useful when you are pulled in late or take the incident over partway through. The summary is shown only to you, and Dispatch labels it as AI-generated — check it before you act on it. + +Dispatch declines and tells you why when read-in summaries are not enabled for the incident or case type, when the incident or case is restricted, or when a case has no dedicated channel. If the project has no artificial intelligence plugin enabled, it reports only that the summary is unavailable — ask your administrator to check the project's plugins. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 13df6fec5..485ea6b56 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -25,6 +25,11 @@ const config = { onBrokenLinks: "throw", + // Docusaurus defaults this to "warn", so a `](#missing)` link renders as a + // link that goes nowhere and the build still succeeds. Throwing is what + // makes the build the authority on whether an anchor resolves. + onBrokenAnchors: "throw", + markdown: { hooks: { onBrokenMarkdownLinks: "warn", diff --git a/docs/package.json b/docs/package.json index a946d9c39..c17dd3ae4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,9 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "check-command-links": "node scripts/check-command-links.mjs", + "test": "node --test scripts/check-command-links.test.mjs" }, "dependencies": { "@cmfcmf/docusaurus-search-local": "^2.0.1", diff --git a/docs/scripts/check-command-links.mjs b/docs/scripts/check-command-links.mjs new file mode 100644 index 000000000..41ee87557 --- /dev/null +++ b/docs/scripts/check-command-links.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +// Keeps the "All Slack commands" list on the Incident Commander page in sync +// with the per-command sections below it. +// +// Docusaurus' onBrokenAnchors setting already fails the build on a link whose +// anchor does not resolve, so this deliberately does not reimplement heading +// slugging. It guards the failure the build cannot see: a command listed as a +// plain code span with no link at all renders without complaint. +// +// This is a line parser rather than a remark plugin so the checked function +// stays a pure, dependency-free unit that node:test can exercise directly. The +// trade is that it understands less MDX than the real parser, so the command +// list is held to one uniform shape and anything else in it is an error -- +// failing loudly on an unfamiliar line rather than skipping it. A skipped line +// is how this class of bug hides in the first place. + +import { readFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import path from "node:path" + +const DEFAULT_TARGET = "docs/user-guide/incidents/commander.mdx" + +const LIST_HEADING = "All Slack commands" + +// Entries are `- [`/cmd`](#anchor)`; sections are `### /cmd`. CommonMark allows +// -, * and + as bullet markers, and MDX renders all three identically. +const LINKED_ENTRY = /^\s*[-*+][ \t]+\[`(\/[^`]+)`\]\(([^)]*)\)\s*$/ +const BARE_ENTRY = /^\s*[-*+][ \t]+`(\/[^`]+)`\s*$/ +const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*$/ +const EXPLICIT_ID = /[ \t]*\{#([^}\s]+)\}$/ +const COMMENT_ONLY = /^\s*(|\{\/\*.*\*\/\})\s*$/ +const FENCE = /^\s*(`{3,}|~{3,})/ + +/** Splits `## Heading {#id}` into its text and its explicit id, if any. */ +function parseHeading(line) { + const heading = HEADING.exec(line) + if (!heading) return null + const [, hashes, raw] = heading + const explicit = EXPLICIT_ID.exec(raw) + return { + level: hashes.length, + text: explicit ? raw.slice(0, explicit.index) : raw, + id: explicit ? explicit[1] : null, + } +} + +/** + * @param {string} source raw MDX + * @returns {string[]} human-readable problems; empty means the page is consistent + */ +export function checkCommandLinks(source) { + const problems = [] + const lines = source.split("\n") + + // Fenced code can contain anything that looks like a list entry or heading. + // The closing fence has to use the marker the block opened with, or a ``` in + // a ~~~ block silently flips the rest of the file inside out. + const inFence = [] + let openFence = null + for (const line of lines) { + const fence = FENCE.exec(line) + if (fence && (openFence === null || fence[1][0] === openFence[0])) { + inFence.push(true) + openFence = openFence === null ? fence[1] : null + continue + } + inFence.push(openFence !== null) + } + + // Sections are collected across the whole file; the list references them from + // above, and they are the only h3s on the page. + const sections = new Map() + lines.forEach((line, i) => { + if (inFence[i]) return + const heading = parseHeading(line) + if (!heading || heading.level !== 3 || !heading.text.startsWith("/")) return + if (sections.has(heading.text)) { + problems.push(`duplicate section "### ${heading.text}" (line ${i + 1})`) + } + // Docusaurus slugs a heading to lowercase and drops the leading slash, + // unless the author pinned an explicit {#id}. + sections.set(heading.text, { + line: i + 1, + anchor: heading.id ?? heading.text.slice(1).toLowerCase(), + }) + }) + + const start = lines.findIndex((line, i) => { + if (inFence[i]) return false + const heading = parseHeading(line) + return heading?.level === 2 && heading.text === LIST_HEADING + }) + if (start === -1) { + return [`no "## ${LIST_HEADING}" heading found`] + } + + const listed = new Map() + for (let i = start + 1; i < lines.length; i++) { + if (inFence[i]) continue + const line = lines[i] + if (parseHeading(line)?.level <= 2) break // next section of the page + if (line.trim() === "" || COMMENT_ONLY.test(line)) continue + + const at = `line ${i + 1}` + const bare = BARE_ENTRY.exec(line) + const linked = LINKED_ENTRY.exec(line) + const command = bare?.[1] ?? linked?.[1] + + if (!command) { + problems.push( + `${at}: every line in the ${LIST_HEADING} list must be \`- [\`/command\`](#anchor)\`, ` + + `got: ${line.trim()}` + ) + continue + } + if (listed.has(command)) { + problems.push(`${at}: \`${command}\` is listed twice (also line ${listed.get(command)})`) + } + listed.set(command, i + 1) + + const section = sections.get(command) + if (bare) { + const anchor = section ? section.anchor : command.slice(1) + problems.push( + `${at}: \`${command}\` is listed but not linked; ` + + `use [\`${command}\`](#${anchor})` + + (section ? "" : ` and add a "### ${command}" section`) + ) + continue + } + + if (!section) { + problems.push(`${at}: \`${command}\` links to ${linked[2]} but has no "### ${command}" section`) + } else if (linked[2] !== `#${section.anchor}`) { + problems.push(`${at}: \`${command}\` links to ${linked[2]}, expected #${section.anchor}`) + } + } + + for (const [command, section] of sections) { + if (!listed.has(command)) { + problems.push(`line ${section.line}: "### ${command}" is not in the ${LIST_HEADING} list`) + } + } + + return problems +} + +async function main(targets) { + let failed = false + for (const target of targets) { + let source + try { + source = await readFile(target, "utf8") + } catch (error) { + console.error(`FAIL ${target}\n cannot be read: ${error.code ?? error.message}`) + failed = true + continue + } + const problems = checkCommandLinks(source) + if (problems.length === 0) { + console.log(`ok ${target}`) + continue + } + failed = true + console.error(`FAIL ${target}`) + for (const problem of problems) console.error(` ${problem}`) + } + if (failed) process.exitCode = 1 +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2) + const here = path.dirname(fileURLToPath(import.meta.url)) + await main(args.length ? args : [path.join(here, "..", DEFAULT_TARGET)]) +} diff --git a/docs/scripts/check-command-links.test.mjs b/docs/scripts/check-command-links.test.mjs new file mode 100644 index 000000000..47fd71daf --- /dev/null +++ b/docs/scripts/check-command-links.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { test } from "node:test" +import { fileURLToPath } from "node:url" +import path from "node:path" + +import { checkCommandLinks } from "./check-command-links.mjs" + +const here = path.dirname(fileURLToPath(import.meta.url)) +const commanderPage = path.join(here, "..", "docs/user-guide/incidents/commander.mdx") + +/** A minimal page with the same shape as the Incident Commander page. */ +function page(entries, sections) { + return [ + "# Commander", + "", + "## All Slack commands", + "", + ...entries, + "", + "## People", + "", + ...sections.flatMap((s) => [`### ${s}`, "", "Description.", ""]), + ].join("\n") +} + +const only = (source) => { + const problems = checkCommandLinks(source) + assert.equal(problems.length, 1, `expected exactly one problem, got ${problems.length}`) + return problems[0] +} + +test("the real Incident Commander page is consistent", async () => { + assert.deepEqual(checkCommandLinks(await readFile(commanderPage, "utf8")), []) +}) + +test("a linked command with a matching section passes", () => { + assert.deepEqual( + checkCommandLinks(page(["- [`/dispatch-summary`](#dispatch-summary)"], ["/dispatch-summary"])), + [] + ) +}) + +// The regression this exists for: issue #82 shipped three commands as plain +// code spans, which MDX renders happily and the docs build never questions. +test("a command listed without a link fails", () => { + assert.match(only(page(["- `/dispatch-list-signals`"], [])), /listed but not linked/) +}) + +// CommonMark accepts -, * and + interchangeably. Recognizing only "- " let the +// exact #82 defect through with a green gate. +for (const marker of ["-", "*", "+"]) { + test(`an unlinked command using the "${marker}" bullet marker fails`, () => { + assert.match(only(page([`${marker} \`/dispatch-summary\``], [])), /listed but not linked/) + }) +} + +test("a tab after the bullet marker is still recognized", () => { + assert.match(only(page(["-\t`/dispatch-summary`"], [])), /listed but not linked/) +}) + +test("a linked command with no section fails", () => { + assert.deepEqual(checkCommandLinks(page(["- [`/dispatch-summary`](#dispatch-summary)"], [])), [ + 'line 5: `/dispatch-summary` links to #dispatch-summary but has no "### /dispatch-summary" section', + ]) +}) + +test("a link pointing at the wrong anchor fails", () => { + assert.match( + only(page(["- [`/dispatch-summary`](#dispatch-sumary)"], ["/dispatch-summary"])), + /links to #dispatch-sumary, expected #dispatch-summary/ + ) +}) + +test("a section missing from the list fails", () => { + assert.match(only(page([], ["/dispatch-summary"])), /is not in the All Slack commands list/) +}) + +test("a command listed twice fails", () => { + const source = page( + ["- [`/dispatch-summary`](#dispatch-summary)", "- [`/dispatch-summary`](#dispatch-summary)"], + ["/dispatch-summary"] + ) + assert.match(only(source), /is listed twice/) +}) + +// Docusaurus lowercases generated heading ids, and matches the link fragment +// against them case-sensitively -- so must this. +test("a heading is expected to resolve to its lowercased anchor", () => { + assert.deepEqual( + checkCommandLinks(page(["- [`/dispatch-Summary`](#dispatch-summary)"], ["/dispatch-Summary"])), + [] + ) +}) + +test("a link matching the heading's case rather than the generated id fails", () => { + assert.match( + only(page(["- [`/dispatch-Summary`](#dispatch-Summary)"], ["/dispatch-Summary"])), + /expected #dispatch-summary/ + ) +}) + +// `npm run write-heading-ids` rewrites every heading this way; the checker has +// to keep working afterwards rather than reporting the whole page as missing. +test("an explicit heading id is honoured", () => { + const source = [ + "## All Slack commands {#all-slack-commands}", + "", + "- [`/dispatch-summary`](#read-in-summary)", + "", + "## People", + "", + "### /dispatch-summary {#read-in-summary}", + "", + "Description.", + ].join("\n") + assert.deepEqual(checkCommandLinks(source), []) +}) + +test("an explicit heading id makes the default anchor wrong", () => { + const source = [ + "## All Slack commands", + "", + "- [`/dispatch-summary`](#dispatch-summary)", + "", + "## People", + "", + "### /dispatch-summary {#read-in-summary}", + ].join("\n") + assert.match(only(source), /expected #read-in-summary/) +}) + +// Anything in the list that is not a uniform entry is an error rather than a +// skipped line, because a skipped line is how #82 stayed invisible. +for (const [name, entry] of [ + ["trailing prose", "- [`/dispatch-summary`](#dispatch-summary) — AI generated"], + ["a reference-style link", "- [`/dispatch-summary`][summary]"], + ["a nested sub-bullet", " - only in incident channels"], +]) { + test(`${name} in the command list is reported, not skipped`, () => { + const problems = checkCommandLinks(page([entry], [])) + assert.ok( + problems.some((p) => /must be `- \[`\/command`\]\(#anchor\)`/.test(p)), + `expected a shape complaint, got ${JSON.stringify(problems)}` + ) + }) +} + +test("blank lines and comments in the command list are allowed", () => { + const source = page( + [ + "- [`/dispatch-summary`](#dispatch-summary)", + "", + "{/* a note to editors */}", + "", + ], + ["/dispatch-summary"] + ) + assert.deepEqual(checkCommandLinks(source), []) +}) + +test("fenced code blocks are not parsed as entries or sections", () => { + const source = [ + "## All Slack commands", + "", + "- [`/dispatch-summary`](#dispatch-summary)", + "", + "```markdown", + "- `/dispatch-not-a-real-command`", + "### /dispatch-also-not-real", + "```", + "", + "## People", + "", + "### /dispatch-summary", + "", + "Description.", + ].join("\n") + assert.deepEqual(checkCommandLinks(source), []) +}) + +test("a ``` inside a ~~~ fence does not flip fence tracking", () => { + const source = [ + "## All Slack commands", + "", + "- [`/dispatch-summary`](#dispatch-summary)", + "", + "~~~markdown", + "```", + "- `/dispatch-not-a-real-command`", + "~~~", + "", + "## People", + "", + "### /dispatch-summary", + "", + "Description.", + ].join("\n") + assert.deepEqual(checkCommandLinks(source), []) +}) + +test("a page without the command list is reported, not silently skipped", () => { + assert.deepEqual(checkCommandLinks("# Some other page\n"), [ + 'no "## All Slack commands" heading found', + ]) +}) From 2159db87d1cfccd4b98b0b36797adad1032566dd Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:05:56 +0000 Subject: [PATCH 2/3] docs: note that the three new command sections still need screenshots --- docs/docs/user-guide/incidents/commander.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/user-guide/incidents/commander.mdx b/docs/docs/user-guide/incidents/commander.mdx index 868aa705d..99d928885 100644 --- a/docs/docs/user-guide/incidents/commander.mdx +++ b/docs/docs/user-guide/incidents/commander.mdx @@ -228,6 +228,8 @@ This command will create a case for the current incident. +{/* The three sections below have no screenshot yet; capturing one needs a live Slack workspace. See issue #135. */} + ### /dispatch-list-incidents Use `/dispatch-list-incidents` to open a modal listing every incident across every project that is currently active or stable, plus any incident closed in the last 24 hours. Each entry links to the incident in Dispatch and shows its commander, project, status, type, severity, and priority. From 6de52318df9fee997041156907b2713c2e9dbe5a Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:09:12 +0000 Subject: [PATCH 3/3] fix: track multi-line comments in the command link checker --- docs/scripts/check-command-links.mjs | 51 ++++++++++++++++++++--- docs/scripts/check-command-links.test.mjs | 31 ++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/scripts/check-command-links.mjs b/docs/scripts/check-command-links.mjs index 41ee87557..9feaa57ce 100644 --- a/docs/scripts/check-command-links.mjs +++ b/docs/scripts/check-command-links.mjs @@ -28,9 +28,48 @@ const LINKED_ENTRY = /^\s*[-*+][ \t]+\[`(\/[^`]+)`\]\(([^)]*)\)\s*$/ const BARE_ENTRY = /^\s*[-*+][ \t]+`(\/[^`]+)`\s*$/ const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*$/ const EXPLICIT_ID = /[ \t]*\{#([^}\s]+)\}$/ -const COMMENT_ONLY = /^\s*(|\{\/\*.*\*\/\})\s*$/ const FENCE = /^\s*(`{3,}|~{3,})/ +const COMMENTS = [ + [""], + ["{/*", "*/}"], +] + +/** + * Blanks out comment spans so a commented-out heading or list entry is not + * read as a real one. Tracked across lines rather than matched by a + * single-line regex, because both comment syntaxes may span newlines. + */ +function stripComments(lines, inFence) { + let closing = null + return lines.map((line, i) => { + if (inFence[i]) return line + let rest = line + let kept = "" + while (rest !== "") { + if (closing) { + const end = rest.indexOf(closing) + if (end === -1) break + rest = rest.slice(end + closing.length) + closing = null + continue + } + const next = COMMENTS.map(([open, close]) => [rest.indexOf(open), open, close]) + .filter(([at]) => at !== -1) + .sort((a, b) => a[0] - b[0])[0] + if (!next) { + kept += rest + break + } + const [at, open, close] = next + kept += rest.slice(0, at) + rest = rest.slice(at + open.length) + closing = close + } + return kept + }) +} + /** Splits `## Heading {#id}` into its text and its explicit id, if any. */ function parseHeading(line) { const heading = HEADING.exec(line) @@ -67,10 +106,12 @@ export function checkCommandLinks(source) { inFence.push(openFence !== null) } + const content = stripComments(lines, inFence) + // Sections are collected across the whole file; the list references them from // above, and they are the only h3s on the page. const sections = new Map() - lines.forEach((line, i) => { + content.forEach((line, i) => { if (inFence[i]) return const heading = parseHeading(line) if (!heading || heading.level !== 3 || !heading.text.startsWith("/")) return @@ -85,7 +126,7 @@ export function checkCommandLinks(source) { }) }) - const start = lines.findIndex((line, i) => { + const start = content.findIndex((line, i) => { if (inFence[i]) return false const heading = parseHeading(line) return heading?.level === 2 && heading.text === LIST_HEADING @@ -97,9 +138,9 @@ export function checkCommandLinks(source) { const listed = new Map() for (let i = start + 1; i < lines.length; i++) { if (inFence[i]) continue - const line = lines[i] + const line = content[i] if (parseHeading(line)?.level <= 2) break // next section of the page - if (line.trim() === "" || COMMENT_ONLY.test(line)) continue + if (line.trim() === "") continue const at = `line ${i + 1}` const bare = BARE_ENTRY.exec(line) diff --git a/docs/scripts/check-command-links.test.mjs b/docs/scripts/check-command-links.test.mjs index 47fd71daf..c4b2e7738 100644 --- a/docs/scripts/check-command-links.test.mjs +++ b/docs/scripts/check-command-links.test.mjs @@ -159,6 +159,37 @@ test("blank lines and comments in the command list are allowed", () => { assert.deepEqual(checkCommandLinks(source), []) }) +// A single-line regex cannot see these, which is what CodeQL's js/bad-tag-filter +// caught: the entries would have been reported as malformed list lines. +test("a comment spanning several lines in the command list is allowed", () => { + const source = page( + [ + "- [`/dispatch-summary`](#dispatch-summary)", + "{/* a note to editors", + " continued onto a second line */}", + "", + ], + ["/dispatch-summary"] + ) + assert.deepEqual(checkCommandLinks(source), []) +}) + +test("a commented-out section does not count as a section", () => { + const source = [ + "## All Slack commands", + "", + "- [`/dispatch-summary`](#dispatch-summary)", + "", + "## People", + "", + "{/*", + "### /dispatch-summary", + "*/}", + ].join("\n") + assert.match(only(source), /has no "### \/dispatch-summary" section/) +}) + test("fenced code blocks are not parsed as entries or sections", () => { const source = [ "## All Slack commands",