From 17c27f2cabd17a57ceb32bbbd9f173552181c620 Mon Sep 17 00:00:00 2001
From: Thomas Decaux
Date: Mon, 6 Jul 2026 23:59:06 +0200
Subject: [PATCH 01/10] docs: design for configurable markdownRenderChain
option
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Single markdownRenderChain plugin option replacing the configurable prefix
of the render pipeline, defaulting to the current chain. Full user control;
a build-time warning fires when the supplied chain omits rehype-sanitize.
π€ Generated with Claude Code (claude.ai/code)
---
...2026-07-06-markdown-render-chain-design.md | 165 ++++++++++++++++++
1 file changed, 165 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-06-markdown-render-chain-design.md
diff --git a/docs/superpowers/specs/2026-07-06-markdown-render-chain-design.md b/docs/superpowers/specs/2026-07-06-markdown-render-chain-design.md
new file mode 100644
index 0000000..65e6fa3
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-06-markdown-render-chain-design.md
@@ -0,0 +1,165 @@
+# Configurable markdown render chain β design
+
+- **Status:** approved (brainstorm), pending implementation plan
+- **Date:** 2026-07-06
+- **Package:** `@ebuildy/docusaurus-plugin-gitlab`
+
+## Problem
+
+All GitLab markdown (project descriptions, release notes, READMEs, and markdown
+files) is rendered at build time by a single hardcoded `unified()` pipeline in
+`src/gitlab/markdown.ts`:
+
+```text
+remarkParse β remarkGemoji β remarkGfm β remarkRehype({ allowDangerousHtml })
+ β rehypeRaw β rehypeSanitize
+ β rehypeGitlabToc β rehypeGitlabAlerts β collect β rehypeStringify
+```
+
+Users cannot influence this chain. They cannot add remark/rehype plugins they
+need β syntax highlighting (`rehype-highlight`), math (`remark-math` +
+`rehype-katex`), custom directives, etc. β nor swap the defaults. The chain is
+closed.
+
+We want to let users configure the markdown-rendering plugin chain via a single
+plugin option, defaulting to the current chain.
+
+## Scope
+
+**In scope:** a single `markdownRenderChain` plugin option that replaces the
+**configurable prefix** of the pipeline (`remarkParse β¦ rehypeSanitize`),
+defaulting to the current six plugins. Applies to every `renderMarkdown` call
+site (description, releases, README, file).
+
+**Out of scope:** the internal stages that run *after* the configurable prefix β
+`rehypeGitlabToc`, `rehypeGitlabAlerts`, the image/link `collect` visitor, and
+`rehypeStringify`. These consume runtime values (`tocMode`, the collected TOC
+array, the per-fetch asset-transform closures) and cannot be expressed as static
+configuration, so they remain appended by the plugin and are not user-facing.
+
+## Design
+
+### Config surface β `src/options.ts`
+
+Add one optional option to both `PluginOptions` and `ResolvedOptions`:
+
+```ts
+import type { PluggableList } from "unified"; // type-only import
+
+markdownRenderChain?: PluggableList;
+```
+
+`PluggableList` means each entry is a plugin function or a `[plugin, options]`
+tuple β the same shape unified's `.use()` accepts. Joi validation mirrors the
+existing `outProcessors` precedent:
+
+```ts
+markdownRenderChain: Joi.array()
+ .items(Joi.alternatives(Joi.function(), Joi.array()))
+ .optional(),
+```
+
+`resolveOptions` passes the value through **as-is** (it may be `undefined`); the
+default is *not* materialized here. This keeps `options.ts` free of runtime
+plugin imports β it only needs the type-only `PluggableList` import.
+
+### Chain ownership β `src/gitlab/markdown.ts`
+
+`markdown.ts` owns the default chain and the fallback:
+
+- Export the default so users can spread it:
+
+ ```ts
+ export const defaultMarkdownRenderChain: PluggableList = [
+ remarkParse,
+ remarkGemoji,
+ remarkGfm,
+ [remarkRehype, { allowDangerousHtml: true }],
+ rehypeRaw,
+ rehypeSanitize,
+ ];
+ ```
+
+ Usage: `markdownRenderChain: [...defaultMarkdownRenderChain, myPlugin]`.
+
+- `RenderOptions` gains `renderChain?: PluggableList`.
+
+- The processor is built from the configured chain (or the default), then the
+ fixed internal stages are appended:
+
+ ```ts
+ const processor = unified()
+ .use(opts.renderChain ?? defaultMarkdownRenderChain)
+ .use(rehypeGitlabToc, { mode: opts.tocMode ?? "auto", collect: opts.collectToc })
+ .use(rehypeGitlabAlerts)
+ .use(collect)
+ .use(rehypeStringify);
+ ```
+
+- Export a helper for the sanitize check:
+
+ ```ts
+ export function chainHasSanitize(chain: PluggableList): boolean;
+ ```
+
+ It returns true when any entry (function, or `entry[0]` of a tuple) is
+ `rehypeSanitize` by reference **or** by function name `"rehypeSanitize"`.
+
+### Threading β `src/gitlab/context.ts` + `src/gitlab/fetchers.ts`
+
+`buildContext` copies `options.markdownRenderChain` onto `ctx.options`. The
+`GitLabContext.options` type gains an optional `markdownRenderChain?:
+PluggableList`. Each of the four `renderMarkdown` calls in `fetchers.ts` passes
+`renderChain: ctx.options.markdownRenderChain` alongside their existing options.
+
+### Sanitize warning β `src/gitlab/context.ts`
+
+The security check runs **once per build**, in `buildContext` (not per document,
+which would spam). When `markdownRenderChain` is set and
+`!chainHasSanitize(markdownRenderChain)`:
+
+```ts
+logger.warn(
+ "@ebuildy/docusaurus-plugin-gitlab: markdownRenderChain has no rehype-sanitize β " +
+ "untrusted GitLab content will be rendered without sanitization."
+);
+```
+
+via `@docusaurus/logger`. The chain is used exactly as given (full control); the
+warning is advisory only and does not abort the build.
+
+## Security invariant
+
+The default chain is unchanged, so the default behavior still runs
+`rehype-raw` **before** `rehype-sanitize`. The existing XSS regression test in
+`src/gitlab/markdown.test.ts` exercises the default chain and stays green,
+untouched.
+
+A user who overrides `markdownRenderChain` takes ownership of sanitization.
+Omitting `rehype-sanitize` is permitted (per product decision) but produces a
+build-time warning. This is a deliberate, documented relaxation of the
+CLAUDE.md "rehype-raw before rehype-sanitize" rule: the rule remains the default
+and is enforced by the shipped default chain; only an explicit user override can
+step outside it, and doing so is surfaced loudly.
+
+## Testing
+
+- **Existing XSS regression test** β unchanged; exercises the default chain.
+- **Custom chain transforms output** β a `renderChain` with an extra plugin
+ produces the expected transformed HTML.
+- **Full override works** β a `renderChain` omitting `rehype-sanitize` lets raw
+ HTML through (proves the chain is used verbatim, not merely appended to).
+- **`chainHasSanitize`** β true for chains containing `rehypeSanitize` (bare and
+ as a `[plugin, opts]` tuple), false otherwise.
+- **`buildContext` warns** β when the configured chain lacks sanitize, the logger
+ is called; no warning when sanitize is present or when the option is unset
+ (mock `@docusaurus/logger`).
+- **Options** β Joi accepts `markdownRenderChain`; `resolveOptions` round-trips
+ it (including `undefined` β passed through).
+
+## Docs
+
+README gains a `markdownRenderChain` section: the default chain, the
+`[...defaultMarkdownRenderChain, β¦]` spread pattern, an example (e.g. adding
+`rehype-highlight`), and the security caveat that omitting `rehype-sanitize`
+disables sanitization of untrusted GitLab content.
From 2aa34b216be081c4317cad6a2d558f081c0d3c3e Mon Sep 17 00:00:00 2001
From: Thomas Decaux
Date: Tue, 7 Jul 2026 00:08:06 +0200
Subject: [PATCH 02/10] docs: implementation plan for markdownRenderChain
option
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
π€ Generated with Claude Code (claude.ai/code)
---
.../plans/2026-07-07-markdown-render-chain.md | 577 ++++++++++++++++++
1 file changed, 577 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-07-markdown-render-chain.md
diff --git a/docs/superpowers/plans/2026-07-07-markdown-render-chain.md b/docs/superpowers/plans/2026-07-07-markdown-render-chain.md
new file mode 100644
index 0000000..7112216
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-07-markdown-render-chain.md
@@ -0,0 +1,577 @@
+# Configurable markdown render chain β 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:** Add a single `markdownRenderChain` plugin option that lets users replace the configurable prefix of the build-time markdown pipeline, defaulting to the current chain.
+
+**Architecture:** `markdown.ts` owns the default chain (`defaultMarkdownRenderChain`) and a `chainHasSanitize` guard; `renderMarkdown` applies `opts.renderChain ?? defaultMarkdownRenderChain` then the fixed internal stages. The option is validated in `options.ts` (passed through, default materialized in `markdown.ts`), threaded via `ctx.options` to the four `renderMarkdown` call sites in `fetchers.ts`, and `buildContext` emits a one-time build warning (via lazily-imported `@docusaurus/logger`) when a user-supplied chain omits `rehype-sanitize`.
+
+**Tech Stack:** TypeScript (ESM), unified/remark/rehype, Joi, Vitest, `@docusaurus/logger`.
+
+**Spec:** `docs/superpowers/specs/2026-07-06-markdown-render-chain-design.md`
+
+---
+
+## File Structure
+
+- `src/gitlab/markdown.ts` β **modify.** Export `defaultMarkdownRenderChain` + `chainHasSanitize`; add `renderChain` to `RenderOptions`; build the processor from the configured/default chain.
+- `src/gitlab/markdown.test.ts` β **modify.** Tests for custom chain + `chainHasSanitize`.
+- `src/options.ts` β **modify.** Add `markdownRenderChain` to `PluginOptions`/`ResolvedOptions`, Joi validation, pass-through in `resolveOptions`.
+- `src/options.test.ts` β **modify.** Tests for the new option.
+- `src/gitlab/context.ts` β **modify.** Add `warnIfChainMissingSanitize`; copy option onto `ctx.options`; fire warning once.
+- `src/gitlab/context.test.ts` β **modify.** Tests for the warning helper.
+- `src/gitlab/fetchers.ts` β **modify.** Add `markdownRenderChain` to `GitLabContext.options`; pass `renderChain` at the four `renderMarkdown` call sites.
+- `src/gitlab/fetchers.test.ts` β **modify.** Test a configured chain reaches rendered output.
+- `README.md` β **modify.** Document the option.
+
+---
+
+## Task 1: Configurable chain + `defaultMarkdownRenderChain` in `markdown.ts`
+
+**Files:**
+- Modify: `src/gitlab/markdown.ts`
+- Test: `src/gitlab/markdown.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/gitlab/markdown.test.ts`. Add these imports at the top (after the existing `import { renderMarkdown } from "./markdown";`):
+
+```typescript
+import remarkParse from "remark-parse";
+import remarkRehype from "remark-rehype";
+import rehypeRaw from "rehype-raw";
+import { renderMarkdown, defaultMarkdownRenderChain } from "./markdown";
+```
+
+(Replace the existing `import { renderMarkdown } from "./markdown";` line with the combined import above.)
+
+Add this test inside the `describe("renderMarkdown", β¦)` block:
+
+```typescript
+ it("uses a custom renderChain verbatim (omitting sanitize lets raw html through)", async () => {
+ const html = await renderMarkdown('hi', {
+ renderChain: [remarkParse, [remarkRehype, { allowDangerousHtml: true }], rehypeRaw],
+ });
+ expect(html).toContain("onclick");
+ expect(html).toContain("hi");
+ });
+
+ it("exports the default chain used when no renderChain is given", async () => {
+ expect(defaultMarkdownRenderChain.length).toBe(6);
+ const html = await renderMarkdown('hi', {});
+ expect(html).not.toContain("onclick");
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/gitlab/markdown.test.ts -t "renderChain"`
+Expected: FAIL β `defaultMarkdownRenderChain` is not exported / `renderChain` has no effect.
+
+- [ ] **Step 3: Implement in `src/gitlab/markdown.ts`**
+
+Change the `unified` import to also import the `PluggableList` type:
+
+```typescript
+import { unified, type PluggableList } from "unified";
+```
+
+Add the exported default chain immediately above `export interface RenderOptions {`:
+
+```typescript
+/**
+ * The default configurable prefix of the render pipeline: markdown β sanitized
+ * hast. Users can spread it (`[...defaultMarkdownRenderChain, myPlugin]`) or
+ * replace it wholesale via the `markdownRenderChain` plugin option. The internal
+ * stages (TOC, alerts, asset collector, stringify) are appended by
+ * `renderMarkdown` and are not part of this list.
+ */
+export const defaultMarkdownRenderChain: PluggableList = [
+ remarkParse,
+ remarkGemoji,
+ remarkGfm,
+ [remarkRehype, { allowDangerousHtml: true }],
+ rehypeRaw,
+ rehypeSanitize,
+];
+```
+
+Add `renderChain` to `RenderOptions`:
+
+```typescript
+export interface RenderOptions {
+ transformImageSrc?: (src: string) => Promise;
+ transformLinkHref?: (href: string) => Promise;
+ tocMode?: TocMode;
+ collectToc?: TocEntry[];
+ /** Overrides the default markdownβsanitized-hast plugin chain. */
+ renderChain?: PluggableList;
+}
+```
+
+Replace the processor construction (the six `.use(...)` calls from `remarkParse` through `rehypeSanitize`) with a single `.use()` of the chain, leaving the internal stages untouched:
+
+```typescript
+ const processor = unified()
+ .use(opts.renderChain ?? defaultMarkdownRenderChain)
+ .use(rehypeGitlabToc, { mode: opts.tocMode ?? "auto", collect: opts.collectToc })
+ .use(rehypeGitlabAlerts)
+ .use(collect)
+ .use(rehypeStringify);
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run src/gitlab/markdown.test.ts`
+Expected: PASS β all tests including the existing XSS regression test.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/gitlab/markdown.ts src/gitlab/markdown.test.ts
+git commit -m "feat: make markdown render chain configurable via renderChain"
+```
+
+---
+
+## Task 2: `chainHasSanitize` guard in `markdown.ts`
+
+**Files:**
+- Modify: `src/gitlab/markdown.ts`
+- Test: `src/gitlab/markdown.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to the imports in `src/gitlab/markdown.test.ts`:
+
+```typescript
+import rehypeSanitize from "rehype-sanitize";
+import { renderMarkdown, defaultMarkdownRenderChain, chainHasSanitize } from "./markdown";
+```
+
+(Merge `chainHasSanitize` into the existing combined `./markdown` import.)
+
+Add a new `describe` block at the end of the file:
+
+```typescript
+describe("chainHasSanitize", () => {
+ it("is true when rehype-sanitize is present (bare, tuple, or default chain)", () => {
+ expect(chainHasSanitize(defaultMarkdownRenderChain)).toBe(true);
+ expect(chainHasSanitize([rehypeSanitize])).toBe(true);
+ expect(chainHasSanitize([[rehypeSanitize, {}]])).toBe(true);
+ });
+
+ it("is false when rehype-sanitize is absent", () => {
+ expect(chainHasSanitize([remarkParse])).toBe(false);
+ expect(chainHasSanitize([])).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/gitlab/markdown.test.ts -t "chainHasSanitize"`
+Expected: FAIL β `chainHasSanitize` is not exported.
+
+- [ ] **Step 3: Implement in `src/gitlab/markdown.ts`**
+
+Add below the `defaultMarkdownRenderChain` export:
+
+```typescript
+/**
+ * True when `chain` contains `rehype-sanitize` (as a bare plugin or a
+ * `[plugin, options]` tuple), matched by reference or by function name. Used to
+ * warn when a user-supplied chain would render untrusted GitLab content without
+ * sanitization.
+ */
+export function chainHasSanitize(chain: PluggableList): boolean {
+ return chain.some((entry) => {
+ const plugin = Array.isArray(entry) ? entry[0] : entry;
+ return plugin === rehypeSanitize || (typeof plugin === "function" && plugin.name === "rehypeSanitize");
+ });
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run src/gitlab/markdown.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/gitlab/markdown.ts src/gitlab/markdown.test.ts
+git commit -m "feat: add chainHasSanitize guard for render chains"
+```
+
+---
+
+## Task 3: `markdownRenderChain` option in `options.ts`
+
+**Files:**
+- Modify: `src/options.ts`
+- Test: `src/options.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/options.test.ts` inside `describe("resolveOptions", β¦)`:
+
+```typescript
+ it("passes markdownRenderChain through unchanged", () => {
+ const chain = [function myPlugin() {}];
+ const o = resolveOptions(
+ { host: "https://gitlab.com", markdownRenderChain: chain as any },
+ "production",
+ );
+ expect(o.markdownRenderChain).toBe(chain);
+ });
+
+ it("leaves markdownRenderChain undefined when not given", () => {
+ const o = resolveOptions({ host: "https://gitlab.com" }, "production");
+ expect(o.markdownRenderChain).toBeUndefined();
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/options.test.ts -t "markdownRenderChain"`
+Expected: FAIL β Joi rejects the unknown option (`markdownRenderChain`) / property missing.
+
+- [ ] **Step 3: Implement in `src/options.ts`**
+
+Add a type-only import at the top (below the existing `OutProcessor` import):
+
+```typescript
+import type { PluggableList } from "unified";
+```
+
+Add to `PluginOptions` (after `outProcessors?`):
+
+```typescript
+ /** Replaces the default markdownβsanitized-hast plugin chain used to render
+ * fetched GitLab markdown (descriptions, release notes, READMEs, files).
+ * Defaults to `defaultMarkdownRenderChain`. Omitting `rehype-sanitize`
+ * disables sanitization of untrusted content (a build warning is emitted). */
+ markdownRenderChain?: PluggableList;
+```
+
+Add to `ResolvedOptions` (after `debug: boolean;`):
+
+```typescript
+ markdownRenderChain?: PluggableList;
+```
+
+Add to the Joi `schema` object (after the `outProcessors` line):
+
+```typescript
+ markdownRenderChain: Joi.array().items(Joi.alternatives(Joi.function(), Joi.array())).optional(),
+```
+
+Add to the object returned by `resolveOptions` (after `debug: opts.debug ?? false,`):
+
+```typescript
+ markdownRenderChain: opts.markdownRenderChain,
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run src/options.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/options.ts src/options.test.ts
+git commit -m "feat: add markdownRenderChain plugin option"
+```
+
+---
+
+## Task 4: `warnIfChainMissingSanitize` helper in `context.ts`
+
+**Files:**
+- Modify: `src/gitlab/context.ts`
+- Test: `src/gitlab/context.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+At the very top of `src/gitlab/context.test.ts` (before other imports), add a mock and import the helper + default chain. Replace the existing import block with:
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { resolveOptions } from "../options.js";
+import { buildContext, CACHE_DIR, warnIfChainMissingSanitize } from "./context.js";
+import { defaultMarkdownRenderChain } from "./markdown.js";
+import remarkParse from "remark-parse";
+
+// `vi.hoisted` is required: a vi.mock factory may not reference an out-of-scope
+// variable unless it was created with vi.hoisted.
+const warn = vi.hoisted(() => vi.fn());
+vi.mock("@docusaurus/logger", () => ({ default: { warn } }));
+
+beforeEach(() => warn.mockClear());
+```
+
+Add a new `describe` block:
+
+```typescript
+describe("warnIfChainMissingSanitize", () => {
+ it("warns when the chain has no rehype-sanitize", async () => {
+ await warnIfChainMissingSanitize([remarkParse]);
+ expect(warn).toHaveBeenCalledOnce();
+ expect(warn.mock.calls[0][0]).toContain("rehype-sanitize");
+ });
+
+ it("does not warn when the chain contains rehype-sanitize", async () => {
+ await warnIfChainMissingSanitize(defaultMarkdownRenderChain);
+ expect(warn).not.toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/gitlab/context.test.ts -t "warnIfChainMissingSanitize"`
+Expected: FAIL β `warnIfChainMissingSanitize` is not exported.
+
+- [ ] **Step 3: Implement in `src/gitlab/context.ts`**
+
+Add imports at the top:
+
+```typescript
+import type { PluggableList } from "unified";
+import { chainHasSanitize } from "./markdown.js";
+```
+
+Add the helper (below the `CACHE_DIR` export, above `buildContext`):
+
+```typescript
+type WarnLogger = { warn: (message: string) => void };
+
+/**
+ * Emit a build-time warning when a user-supplied `markdownRenderChain` omits
+ * `rehype-sanitize`, so untrusted GitLab content rendered without sanitization
+ * is surfaced loudly. `@docusaurus/logger` is imported lazily (optional peer),
+ * matching `src/include/logger.ts`.
+ */
+export async function warnIfChainMissingSanitize(chain: PluggableList): Promise {
+ if (chainHasSanitize(chain)) return;
+ const imported = (await import("@docusaurus/logger")).default as unknown as WarnLogger & {
+ default?: WarnLogger;
+ };
+ const logger: WarnLogger = imported.default ?? imported;
+ logger.warn(
+ "@ebuildy/docusaurus-plugin-gitlab: markdownRenderChain has no rehype-sanitize β " +
+ "untrusted GitLab content will be rendered without sanitization.",
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run src/gitlab/context.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/gitlab/context.ts src/gitlab/context.test.ts
+git commit -m "feat: warn when markdownRenderChain omits rehype-sanitize"
+```
+
+---
+
+## Task 5: Thread `markdownRenderChain` through context + fetchers
+
+**Files:**
+- Modify: `src/gitlab/context.ts`
+- Modify: `src/gitlab/fetchers.ts`
+- Test: `src/gitlab/fetchers.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add plugin imports to the top of `src/gitlab/fetchers.test.ts`:
+
+```typescript
+import remarkParse from "remark-parse";
+import remarkRehype from "remark-rehype";
+import rehypeRaw from "rehype-raw";
+```
+
+Add this test inside `describe("fetchReleases", β¦)`:
+
+```typescript
+ it("applies a configured markdownRenderChain to release notes", async () => {
+ const client = {
+ getReleases: vi.fn(async () => [
+ { name: "v1", tag_name: "v1", released_at: "x", description: 'n',
+ upcoming_release: false, assets: { links: [] } },
+ ]),
+ };
+ const c = ctx(client);
+ c.options.markdownRenderChain = [remarkParse, [remarkRehype, { allowDangerousHtml: true }], rehypeRaw];
+ const data = await fetchReleases(c, { project: "g/r", includePrereleases: true });
+ expect(data[0].descriptionHtml).toContain("onclick");
+ });
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run src/gitlab/fetchers.test.ts -t "configured markdownRenderChain"`
+Expected: FAIL β `renderChain` isn't threaded, so sanitize still strips `onclick`.
+
+- [ ] **Step 3: Implement the threading**
+
+In `src/gitlab/fetchers.ts`, add a type-only import at the top:
+
+```typescript
+import type { PluggableList } from "unified";
+```
+
+Add `markdownRenderChain` to the `GitLabContext.options` type (inside the `options: { β¦ }` block, after `debug?: boolean;`):
+
+```typescript
+ markdownRenderChain?: PluggableList;
+```
+
+Pass `renderChain` at each of the four `renderMarkdown` call sites:
+
+`fetchProjectInfo` (description):
+```typescript
+ descriptionHtml: await renderMarkdown(p.description ?? "", { renderChain: ctx.options.markdownRenderChain }),
+```
+
+`fetchReleases` (release notes):
+```typescript
+ descriptionHtml: await renderMarkdown(r.description ?? "", { renderChain: ctx.options.markdownRenderChain }),
+```
+
+`fetchReadme` (README) β add `renderChain` to the existing options object:
+```typescript
+ const html = await renderMarkdown(md, {
+ tocMode,
+ collectToc,
+ transformImageSrc: (src) => ctx.assets.localize(src, ref, project),
+ renderChain: ctx.options.markdownRenderChain,
+ });
+```
+
+`fetchFile` (markdown file) β add `renderChain` to the existing options object:
+```typescript
+ const html = await renderMarkdown(expanded, {
+ transformImageSrc: (src) => ctx.assets.localize(src, ref, String(project)),
+ renderChain: ctx.options.markdownRenderChain,
+ });
+```
+
+In `src/gitlab/context.ts`, thread the option onto `ctx.options` and fire the warning. Update the returned `options` object in `buildContext`:
+
+```typescript
+ options: {
+ host: options.host,
+ strict: options.strict,
+ allowedHosts: options.includeAllowedHosts,
+ debug: options.debug,
+ markdownRenderChain: options.markdownRenderChain,
+ },
+```
+
+Immediately before the `return { β¦ }` in `buildContext`, add the one-time warning (fire-and-forget; the helper is fully covered by its own test):
+
+```typescript
+ if (options.markdownRenderChain) void warnIfChainMissingSanitize(options.markdownRenderChain);
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run src/gitlab/fetchers.test.ts src/gitlab/context.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Full suite + typecheck**
+
+Run: `npx vitest run && npm run typecheck`
+Expected: PASS β all tests green, no type errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts src/gitlab/context.ts
+git commit -m "feat: thread markdownRenderChain to all render sites"
+```
+
+---
+
+## Task 6: Document `markdownRenderChain` in README
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Add documentation**
+
+Locate the plugin-options documentation in `README.md` (the section listing options such as `host`, `token`, `strict`, `cache`). Add this subsection after the options list/table:
+
+````markdown
+### Customizing the markdown render chain
+
+Fetched GitLab markdown (project descriptions, release notes, READMEs, and
+markdown files) is rendered at build time by a `unified` plugin chain. By default
+it is:
+
+```text
+remarkParse β remarkGemoji β remarkGfm β remarkRehype({ allowDangerousHtml })
+ β rehypeRaw β rehypeSanitize
+```
+
+Override or extend it with the `markdownRenderChain` option. Spread the exported
+default to add plugins:
+
+```ts
+import { defaultMarkdownRenderChain } from "@ebuildy/docusaurus-plugin-gitlab";
+import rehypeHighlight from "rehype-highlight";
+
+// docusaurus.config.ts β plugin options
+{
+ host: "https://gitlab.com",
+ markdownRenderChain: [...defaultMarkdownRenderChain, rehypeHighlight],
+}
+```
+
+Internal stages (heading anchors/TOC, GitLab alert admonitions, asset
+localization, HTML serialization) always run **after** your chain and are not
+configurable.
+
+> **Security:** GitLab content is untrusted. The default chain runs
+> `rehype-sanitize`. If your custom `markdownRenderChain` omits it, that content
+> is rendered **without sanitization** (XSS risk); the plugin emits a build-time
+> warning when this is detected. Keep `rehype-sanitize` in the chain unless you
+> fully control the GitLab source.
+````
+
+- [ ] **Step 2: Verify the export exists**
+
+Confirm `defaultMarkdownRenderChain` is exported from the package entry so the README import works:
+
+Run: `grep -n "defaultMarkdownRenderChain\|markdown" src/index.ts`
+Expected: if not already re-exported, add to `src/index.ts`:
+
+```typescript
+export { defaultMarkdownRenderChain } from "./gitlab/markdown.js";
+```
+
+Then run `npm run typecheck` β Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md src/index.ts
+git commit -m "docs: document markdownRenderChain option"
+```
+
+---
+
+## Final verification
+
+- [ ] Run the full suite: `npx vitest run` β all green.
+- [ ] Typecheck: `npm run typecheck` β no errors.
+- [ ] Build: `npm run build` β compiles to `dist/`.
+- [ ] E2E (pipeline touched): `npx vitest run test/e2e/build.test.ts` β the real Docusaurus site builds.
From 040b39ecb3df00d1e53c99d22067c7c90b640bba Mon Sep 17 00:00:00 2001
From: Thomas Decaux
Date: Tue, 7 Jul 2026 00:09:35 +0200
Subject: [PATCH 03/10] feat: render emoji shortcodes and markdown in project
descriptions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add remark-gemoji to the shared renderMarkdown chain so :emoji: shortcodes
render across releases, README, and files. Render GitlabProjectInfo
descriptions through renderMarkdown (new descriptionHtml field) instead of
emitting raw text, so markdown and emoji display.
π€ Generated with Claude Code (claude.ai/code)
---
package-lock.json | 32 +++++++++++++++++++++++
package.json | 1 +
src/components/GitlabProjectInfo.test.tsx | 13 ++++++++-
src/components/GitlabProjectInfo.tsx | 7 ++++-
src/gitlab/fetchers.test.ts | 4 ++-
src/gitlab/fetchers.ts | 2 +-
src/gitlab/markdown.test.ts | 6 +++++
src/gitlab/markdown.ts | 2 ++
src/gitlab/types.ts | 3 ++-
9 files changed, 65 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 38607d3..94cef75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
+ "remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
@@ -60,8 +61,14 @@
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
+ "@docusaurus/logger": "^3.0.0",
"react": ">=18",
"react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@docusaurus/logger": {
+ "optional": true
+ }
}
},
"node_modules/@adobe/css-tools": {
@@ -4051,6 +4058,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gemoji": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/gemoji/-/gemoji-8.1.0.tgz",
+ "integrity": "sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -7685,6 +7702,21 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-gemoji": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/remark-gemoji/-/remark-gemoji-8.0.0.tgz",
+ "integrity": "sha512-/fL9rc72FYwFGtOKcT+QeQdx9Q9t5v4N6KLXSDOTEgaedzK85I9judBqB2eqz+g4b0ERMejlwSOuPK+wket6aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "gemoji": "^8.0.0",
+ "mdast-util-find-and-replace": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
diff --git a/package.json b/package.json
index dceec62..23554de 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
+ "remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
diff --git a/src/components/GitlabProjectInfo.test.tsx b/src/components/GitlabProjectInfo.test.tsx
index c9e98c2..4c22dab 100644
--- a/src/components/GitlabProjectInfo.test.tsx
+++ b/src/components/GitlabProjectInfo.test.tsx
@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
import { GitlabProjectInfo } from "./GitlabProjectInfo";
const data = {
- id: 1, path: "g/r", name: "My Repo", description: "A thing", webUrl: "https://gitlab.com/g/r",
+ id: 1, path: "g/r", name: "My Repo", descriptionHtml: "A thing
", webUrl: "https://gitlab.com/g/r",
starCount: 12, forksCount: 3, topics: ["docs", "tooling"], lastActivityAt: "2026-01-01T00:00:00Z", avatarUrl: null,
};
@@ -16,6 +16,17 @@ describe("GitlabProjectInfo", () => {
expect(screen.getByText(/12/)).toBeInTheDocument();
});
+ it("renders markdown and emoji from the description html", () => {
+ render(A bold thing π
" } as any} />);
+ expect(screen.getByText("bold")).toBeInTheDocument();
+ expect(screen.getByText(/π/)).toBeInTheDocument();
+ });
+
+ it("renders no description block when descriptionHtml is empty", () => {
+ const { container } = render();
+ expect(container.querySelector(".gitlab-description")).toBeNull();
+ });
+
it("humanizes large star and fork counts", () => {
render();
expect(screen.getByText(/6k/)).toBeInTheDocument();
diff --git a/src/components/GitlabProjectInfo.tsx b/src/components/GitlabProjectInfo.tsx
index ed8b375..f741faf 100644
--- a/src/components/GitlabProjectInfo.tsx
+++ b/src/components/GitlabProjectInfo.tsx
@@ -22,7 +22,12 @@ export function GitlabProjectInfo({ data, error, showStats = true }: ComponentPa
{data.name}
- {data.description && {data.description}
}
+ {data.descriptionHtml && (
+
+ )}
{data.topics.length > 0 && (
{data.topics.map((t) => (
diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts
index 072dc74..79bc249 100644
--- a/src/gitlab/fetchers.test.ts
+++ b/src/gitlab/fetchers.test.ts
@@ -19,13 +19,15 @@ describe("fetchProjectInfo", () => {
it("normalizes the project payload", async () => {
const client = {
getProject: vi.fn(async () => ({
- id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r",
+ id: 7, path_with_namespace: "g/r", name: "r", description: "ship **it** :rocket:", web_url: "https://gitlab.com/g/r",
star_count: 3, forks_count: 1, topics: ["x"], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null,
})),
};
const c = ctx(client);
const data = await fetchProjectInfo(c, { project: "g/r" });
expect(data).toMatchObject({ id: 7, path: "g/r", starCount: 3, topics: ["x"] });
+ expect(data.descriptionHtml).toContain("it");
+ expect(data.descriptionHtml).toContain("π");
expect(data.avatarUrl).toBeNull();
expect(c.assets.localize).not.toHaveBeenCalled();
expect(client.getProject).toHaveBeenCalledWith("g/r");
diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts
index 3adb096..279c0fe 100644
--- a/src/gitlab/fetchers.ts
+++ b/src/gitlab/fetchers.ts
@@ -79,7 +79,7 @@ export async function fetchProjectInfo(ctx: GitLabContext, attrs: Attrs): Promis
id: p.id,
path: p.path_with_namespace,
name: p.name,
- description: p.description ?? null,
+ descriptionHtml: await renderMarkdown(p.description ?? "", {}),
webUrl: p.web_url,
starCount: p.star_count,
forksCount: p.forks_count,
diff --git a/src/gitlab/markdown.test.ts b/src/gitlab/markdown.test.ts
index 09e8553..1b50772 100644
--- a/src/gitlab/markdown.test.ts
+++ b/src/gitlab/markdown.test.ts
@@ -8,6 +8,12 @@ describe("renderMarkdown", () => {
expect(html).toContain("
a");
});
+ it("converts :emoji: shortcodes to unicode emoji", async () => {
+ const html = await renderMarkdown("Ship it :rocket:", {});
+ expect(html).toContain("π");
+ expect(html).not.toContain(":rocket:");
+ });
+
it("strips dangerous html", async () => {
const html = await renderMarkdown("ok", {});
expect(html).not.toContain("