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("