From 74195d95d85064f197fd60d3e469ee00b39c6c71 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Mon, 29 Jun 2026 07:52:39 +0200 Subject: [PATCH 1/8] docs: design spec for GitLab markdown alerts Co-Authored-By: Claude Opus 4.8 Signed-off-by: Thomas Decaux --- .../specs/2026-06-29-gitlab-alerts-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-gitlab-alerts-design.md diff --git a/docs/superpowers/specs/2026-06-29-gitlab-alerts-design.md b/docs/superpowers/specs/2026-06-29-gitlab-alerts-design.md new file mode 100644 index 0000000..a7ffd7f --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-gitlab-alerts-design.md @@ -0,0 +1,162 @@ +# GitLab Markdown Alerts — Design + +**Status:** Approved +**Date:** 2026-06-29 + +## Summary + +Add support for GitLab's [Markdown *alerts*](https://docs.gitlab.com/user/markdown/#alerts) to +the markdown rendering pipeline, mirroring how the `[[_TOC_]]` marker is already handled. An alert +is a blockquote whose first line is a type marker — `> [!note]`, `> [!tip]`, `> [!important]`, +`> [!caution]`, `> [!warning]` — optionally followed by a custom title on the same line. The +rendered output is a styled callout box that reuses the Docusaurus/Infima theme `alert` classes so +it inherits theme colors with no bundled CSS. + +This applies everywhere `renderMarkdown` is used (README, file markdown, release notes). + +## Scope + +**In scope** + +- The five GitLab alert types via the `> [!type]` single-blockquote syntax. +- Case-insensitive type matching (`[!note]`, `[!NOTE]`, `[!Note]` all map to the same alert). +- Custom title override on the marker line (`> [!warning] Data deletion`). +- Default title = capitalized type name when no custom title is given. + +**Out of scope (deferred)** + +- GitLab's `>>>` multiline blockquote fence syntax. This is not standard Markdown — `remark-parse` + does not recognize `>>>` as a blockquote — so it would require a pre-parse text transform with its + own edge cases (nesting, lists inside the fence). Deferred to a later iteration; every alert form + in this spec is a normal Markdown blockquote detectable on the tree. +- Bundled CSS / SVG icons. Styling is left to the consumer's theme (see Class mapping). + +## Approach + +A custom **rehype** plugin, `rehypeGitlabAlerts`, in a new file `src/gitlab/alerts.ts`, wired into +`renderMarkdown` immediately **after** `rehype-sanitize` — the same pipeline slot +`rehypeGitlabToc` occupies. Running post-sanitize means: + +- the alert body is already sanitized; +- the classes, `role`, and title node we inject cannot be stripped by the sanitize schema (no schema + changes needed); +- the security posture matches the TOC feature exactly. + +Alternatives considered and rejected: + +- **`remark-github-blockquote-alert`** (off-the-shelf): GitHub-flavored (uppercase only), no + custom-title support, emits non-Infima classes, runs pre-sanitize. Does not match GitLab semantics + or the Infima-class goal. +- **Custom remark (mdast) plugin, pre-sanitize**: would require allow-listing every injected class + and attribute in the sanitize schema — the exact fragility the TOC plugin avoided by going + post-sanitize. + +## Class mapping & output structure + +Each GitLab type maps to an Infima theme variant (so it picks up theme colors for free) and also +carries a stable `gitlab-md-alert--` hook class for consumers that want to target GitLab +semantics directly. + +| GitLab type | Default title | Infima variant class | +|---|---|---| +| `note` | Note | `alert--secondary` | +| `tip` | Tip | `alert--success` | +| `important` | Important | `alert--info` | +| `caution` | Caution | `alert--warning` | +| `warning` | Warning | `alert--danger` | + +The matched `
` is rewritten **in place** to a `
` (Infima `.alert` is a div), +preserving its already-sanitized body. Example for `> [!warning] Data deletion`: + +```html + +``` + +Title text = custom title if present, else the capitalized default from the table. Both the GitLab +hook classes and the Infima classes are emitted. + +## Detection & rewrite logic (`src/gitlab/alerts.ts`) + +`rehypeGitlabAlerts()` returns a transform that visits `blockquote` elements: + +1. Find the first child that is a `

`. Read its leading text — the concatenation of leading text + nodes up to the first newline / `
`. +2. Match `^\s*\[!(note|tip|important|caution|warning)\]([^\n]*)` **case-insensitively** against that + leading text. + - No match, or an unknown type → **leave the blockquote untouched** (stays a plain blockquote). +3. On match: + - `type` = lowercased capture; `customTitle` = trimmed remainder (may be empty). + - **Strip the marker** (plus any custom-title text and its trailing newline / `
`) from the + paragraph's leading content, leaving the real body. If that leaves the first paragraph empty, + drop it. + - Mutate the node: `tagName = "div"`, set `className` to the four classes from the table, set + `role="alert"`. + - Prepend `

{title}

`. + +Pure, unit-testable helpers mirror `toc.ts`: + +- `ALERT_TYPES`: a map of `type → { defaultTitle, infimaClass }`. +- a small builder for the title node. + +### Edge cases + +- Marker-only blockquote (no body) → alert with just a title; no stray empty paragraph. +- Custom title containing inline markdown → reduced to plain text. +- Multiple blockquotes in one document → each handled independently. +- Marker not at the paragraph start (text before `[!note]`) → no transform. +- Unknown type (`[!foo]`) → no transform. + +## Pipeline wiring & security + +In `src/gitlab/markdown.ts`, add one step after the sanitize/TOC steps: + +```js +.use(rehypeRaw) +.use(rehypeSanitize) +.use(rehypeGitlabToc) +.use(rehypeGitlabAlerts) // new, post-sanitize +.use(collect) +.use(rehypeStringify) +``` + +**Security:** post-sanitize, the body is already clean. The plugin only (a) restructures existing +nodes, (b) adds static, hard-coded classes and `role`, and (c) inserts the title as a hast **text +node**, which is escaped on stringify. A title such as `> [!warning] ` +becomes inert escaped text. No schema changes and no new `dangerouslySetInnerHTML` paths. The +existing XSS regression test in `markdown.test.ts` stays green, and an alert-specific XSS test is +added. + +## Testing (`src/gitlab/alerts.test.ts`) — TDD, write tests first + +Behavior-level tests through `renderMarkdown`, plus direct unit tests of the plugin and helpers. +Thorough coverage is a priority: + +- All five types → correct `gitlab-md-alert--` + Infima class + default title (one test each). +- Case-insensitivity: `[!NOTE]`, `[!Note]`, `[!note]` all produce the note alert. +- Custom title: `> [!warning] Data deletion` → title "Data deletion", danger variant. +- Empty custom title: `> [!tip]` alone → default "Tip" title. +- Marker-only blockquote (no body) → alert with title, no empty stray paragraph. +- Body markdown preserved: bold / links / lists inside the alert survive and render. +- Unknown type `[!foo]` → untouched plain `
`, no alert classes. +- Non-alert blockquote (plain quote) → untouched. +- Marker not at start (text before `[!note]`) → no transform. +- Multiple alerts in one document → each transformed independently. +- XSS in custom title → escaped, no executable HTML. +- TOC + alert coexistence → both transforms apply in the same document. +- `role="alert"` present on output. +- Pure-helper unit tests for the `ALERT_TYPES` mapping and the title-node builder. + +## Documentation + +- Add a GitLab alerts section to `README.md` documenting the syntax, the five types, custom titles, + and the emitted classes; add `gitlab-md-alert`, `gitlab-md-alert--`, and + `gitlab-md-alert-title` rows to the README class table. +- Add an example page under `examples/site/docs/` demonstrating each alert type. + +## Verification + +After implementation: `npx vitest run` and `npm run typecheck` (per CLAUDE.md). The e2e build is not +required for this change but may be run if the pipeline wiring is touched in unexpected ways. From 80e5aa363f9c9e7100fcabd6c150dcfef37a2aa3 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Mon, 29 Jun 2026 07:58:15 +0200 Subject: [PATCH 2/8] docs: implementation plan for GitLab markdown alerts Co-Authored-By: Claude Opus 4.8 Signed-off-by: Thomas Decaux --- .../plans/2026-06-29-gitlab-alerts.md | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-gitlab-alerts.md diff --git a/docs/superpowers/plans/2026-06-29-gitlab-alerts.md b/docs/superpowers/plans/2026-06-29-gitlab-alerts.md new file mode 100644 index 0000000..40c09a3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-gitlab-alerts.md @@ -0,0 +1,514 @@ +# GitLab Markdown Alerts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Render GitLab Markdown alerts (`> [!note]`, `[!tip]`, `[!important]`, `[!caution]`, `[!warning]`) as themed callout boxes everywhere `renderMarkdown` is used. + +**Architecture:** A new rehype plugin `rehypeGitlabAlerts` (in `src/gitlab/alerts.ts`) runs **after** `rehype-sanitize` in the `renderMarkdown` pipeline — the same slot as `rehypeGitlabToc`. It walks `blockquote` nodes, detects a leading `[!type]` marker (case-insensitive, with optional same-line custom title), and rewrites the blockquote into a `
` carrying both stable `gitlab-md-alert*` hook classes and Docusaurus/Infima `alert alert--` classes, with a title paragraph prepended. Post-sanitize execution means injected classes survive and the body is already safe. + +**Tech Stack:** TypeScript (ESM, explicit `.js` imports), unified/rehype, `unist-util-visit`, Vitest. No new dependencies. + +--- + +## File Structure + +- **Create** `src/gitlab/alerts.ts` — the `rehypeGitlabAlerts` plugin plus pure helpers `ALERT_TYPES` and `buildAlertTitle`. +- **Create** `src/gitlab/alerts.test.ts` — unit/behavior tests via `renderMarkdown`. +- **Modify** `src/gitlab/markdown.ts` — import and `.use(rehypeGitlabAlerts)` after `rehypeGitlabToc`. +- **Modify** `README.md` — document the syntax and add class-table rows. +- **Create** `examples/site/docs/components/alerts.mdx` — example page (rendered via ``/`` content; here a static doc page demonstrating the markdown is sufficient). + +Reference pattern: `src/gitlab/toc.ts` and `src/gitlab/toc.test.ts`. + +--- + +### Task 1: Plugin scaffold + first passing alert (note) + +**Files:** +- Create: `src/gitlab/alerts.ts` +- Create: `src/gitlab/alerts.test.ts` +- Modify: `src/gitlab/markdown.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/gitlab/alerts.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renderMarkdown } from "./markdown"; + +describe("rehypeGitlabAlerts", () => { + it("renders a [!note] blockquote as an Infima alert div", async () => { + const md = "> [!note]\n> The following information is useful.\n"; + const html = await renderMarkdown(md, {}); + + expect(html).toContain( + 'class="gitlab-md-alert gitlab-md-alert--note alert alert--secondary"', + ); + expect(html).toContain('role="alert"'); + expect(html).toContain('

Note

'); + expect(html).toContain("

The following information is useful.

"); + expect(html).not.toContain("[!note]"); + expect(html).not.toContain("
"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: FAIL — `renderMarkdown` does not yet transform alerts (output still contains `
` / `[!note]`). + +- [ ] **Step 3: Create the plugin** + +Create `src/gitlab/alerts.ts`: + +```ts +import type { Root, Element, ElementContent } from "hast"; +import { visit } from "unist-util-visit"; + +interface AlertType { + defaultTitle: string; + infimaClass: string; +} + +/** GitLab alert type → default title + Docusaurus/Infima theme variant class. */ +export const ALERT_TYPES: Record = { + note: { defaultTitle: "Note", infimaClass: "alert--secondary" }, + tip: { defaultTitle: "Tip", infimaClass: "alert--success" }, + important: { defaultTitle: "Important", infimaClass: "alert--info" }, + caution: { defaultTitle: "Caution", infimaClass: "alert--warning" }, + warning: { defaultTitle: "Warning", infimaClass: "alert--danger" }, +}; + +// Leading `[!type]` marker plus an optional same-line custom title. +// `[^\S\r\n]` = horizontal whitespace only (so we never cross into the body line). +const MARKER_RE = + /^[^\S\r\n]*\[!(note|tip|important|caution|warning)\][^\S\r\n]*([^\r\n]*)/i; + +/** Build the `

` title node. */ +export function buildAlertTitle(title: string): Element { + return { + type: "element", + tagName: "p", + properties: { className: ["gitlab-md-alert-title"] }, + children: [{ type: "text", value: title }], + }; +} + +/** + * Rehype plugin. Must run AFTER rehype-sanitize so the classes/structure it + * injects are not stripped and the alert body is already sanitized. + */ +export function rehypeGitlabAlerts() { + return (tree: Root) => { + visit(tree, "element", (node: Element) => { + if (node.tagName !== "blockquote") return; + + const para = node.children.find( + (c): c is Element => c.type === "element" && c.tagName === "p", + ); + if (!para) return; + + const first = para.children[0]; + if (!first || first.type !== "text") return; + + const match = MARKER_RE.exec(first.value); + if (!match) return; + + const type = match[1].toLowerCase(); + const spec = ALERT_TYPES[type]; + if (!spec) return; + + const customTitle = match[2].trim(); + const title = customTitle.length > 0 ? customTitle : spec.defaultTitle; + + // Strip the marker (+ same-line title + the trailing newline) from the body. + first.value = first.value.slice(match[0].length).replace(/^\r?\n/, ""); + if (first.value.length === 0) para.children.shift(); + if (para.children.length === 0) { + node.children = node.children.filter((c) => c !== para); + } + + node.tagName = "div"; + node.properties = { + className: [ + "gitlab-md-alert", + `gitlab-md-alert--${type}`, + "alert", + spec.infimaClass, + ], + role: "alert", + }; + node.children.unshift(buildAlertTitle(title) as ElementContent); + }); + }; +} +``` + +- [ ] **Step 4: Wire the plugin into the pipeline** + +In `src/gitlab/markdown.ts`, add the import near the existing toc import (line ~10): + +```ts +import { rehypeGitlabAlerts } from "./alerts.js"; +``` + +And add the `.use` immediately after `.use(rehypeGitlabToc)` (line ~42): + +```ts + .use(rehypeGitlabToc) + .use(rehypeGitlabAlerts) + .use(collect) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/alerts.ts src/gitlab/alerts.test.ts src/gitlab/markdown.ts +git commit -m "feat: render GitLab [!type] blockquote alerts" +``` + +--- + +### Task 2: All five types map to correct classes and default titles + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block in `src/gitlab/alerts.test.ts`: + +```ts + it.each([ + ["note", "alert--secondary", "Note"], + ["tip", "alert--success", "Tip"], + ["important", "alert--info", "Important"], + ["caution", "alert--warning", "Caution"], + ["warning", "alert--danger", "Warning"], + ])("maps [!%s] to %s with default title %s", async (type, infima, title) => { + const html = await renderMarkdown(`> [!${type}]\n> Body text.\n`, {}); + expect(html).toContain( + `class="gitlab-md-alert gitlab-md-alert--${type} alert ${infima}"`, + ); + expect(html).toContain(`

${title}

`); + expect(html).toContain("

Body text.

"); + }); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS (the Task 1 implementation already handles all five types). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/alerts.test.ts +git commit -m "test: cover all five alert types and Infima class mapping" +``` + +--- + +### Task 3: Custom title override + empty custom title + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block: + +```ts + it("uses a same-line custom title when present", async () => { + const md = "> [!warning] Data deletion\n> This is destructive.\n"; + const html = await renderMarkdown(md, {}); + expect(html).toContain('class="gitlab-md-alert gitlab-md-alert--warning alert alert--danger"'); + expect(html).toContain('

Data deletion

'); + expect(html).toContain("

This is destructive.

"); + expect(html).not.toContain("Data deletion

Data deletion"); + }); + + it("falls back to the default title when the custom title is blank", async () => { + const html = await renderMarkdown("> [!tip] \n> Tip body.\n", {}); + expect(html).toContain('

Tip

'); + expect(html).toContain("

Tip body.

"); + }); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS (the regex captures the custom title; blank trims to the default). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/alerts.test.ts +git commit -m "test: cover custom and blank alert titles" +``` + +--- + +### Task 4: Case-insensitive marker matching + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block: + +```ts + it.each(["[!NOTE]", "[!Note]", "[!nOtE]"])( + "matches %s case-insensitively and normalizes to note", + async (marker) => { + const html = await renderMarkdown(`> ${marker}\n> Body.\n`, {}); + expect(html).toContain( + 'class="gitlab-md-alert gitlab-md-alert--note alert alert--secondary"', + ); + expect(html).toContain('

Note

'); + }, + ); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS (regex has the `i` flag; `type` is lowercased). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/alerts.test.ts +git commit -m "test: cover case-insensitive alert markers" +``` + +--- + +### Task 5: Non-transform cases stay plain blockquotes + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block: + +```ts + it("leaves an unknown alert type as a plain blockquote", async () => { + const html = await renderMarkdown("> [!foo]\n> Body.\n", {}); + expect(html).toContain("
"); + expect(html).not.toContain("gitlab-md-alert"); + }); + + it("leaves an ordinary blockquote untouched", async () => { + const html = await renderMarkdown("> Just a quote.\n", {}); + expect(html).toContain("
"); + expect(html).not.toContain("gitlab-md-alert"); + }); + + it("does not transform when the marker is not at the line start", async () => { + const html = await renderMarkdown("> see [!note] here\n", {}); + expect(html).toContain("
"); + expect(html).not.toContain("gitlab-md-alert"); + }); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS (`[!foo]` fails the regex; plain quote has no marker; anchored `^` rejects mid-line markers). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/alerts.test.ts +git commit -m "test: leave non-alert blockquotes untouched" +``` + +--- + +### Task 6: Edge cases — empty body, rich body, multiple alerts + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block: + +```ts + it("renders a marker-only alert with no stray empty paragraph", async () => { + const html = await renderMarkdown("> [!important]\n", {}); + expect(html).toContain('class="gitlab-md-alert gitlab-md-alert--important alert alert--info"'); + expect(html).toContain('

Important

'); + expect(html).not.toMatch(/

<\/p>/); + }); + + it("preserves inline markdown inside the alert body", async () => { + const md = "> [!note]\n> Read **this** and [docs](https://x.test).\n"; + const html = await renderMarkdown(md, {}); + expect(html).toContain("this"); + expect(html).toContain('href="https://x.test"'); + }); + + it("transforms multiple alerts in one document independently", async () => { + const md = "> [!tip]\n> First.\n\n> [!warning]\n> Second.\n"; + const html = await renderMarkdown(md, {}); + expect(html).toContain("gitlab-md-alert--tip"); + expect(html).toContain("gitlab-md-alert--warning"); + expect(html).toContain("

First.

"); + expect(html).toContain("

Second.

"); + }); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run src/gitlab/alerts.test.ts` +Expected: PASS (empty paragraph is dropped; body nodes are preserved; `visit` handles every blockquote). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/alerts.test.ts +git commit -m "test: cover empty-body, rich-body, and multiple alerts" +``` + +--- + +### Task 7: Security — XSS in title escaped + coexistence with TOC + +**Files:** +- Modify: `src/gitlab/alerts.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add inside the `describe` block: + +```ts + it("escapes HTML in a custom title and runs no handlers", async () => { + const md = '> [!warning] \n> Body.\n'; + const html = await renderMarkdown(md, {}); + expect(html).not.toContain("onerror"); + expect(html).not.toContain("alert(1)"); + // Title text is inserted as an escaped text node. + expect(html).toContain("gitlab-md-alert-title"); + }); + + it("applies both the TOC and alert transforms in one document", async () => { + const md = "[[_TOC_]]\n\n## Heading\n\n> [!note]\n> Note body.\n"; + const html = await renderMarkdown(md, {}); + expect(html).toContain('