diff --git a/README.md b/README.md index bbdb2dd..097a6cb 100644 --- a/README.md +++ b/README.md @@ -285,10 +285,46 @@ markdown: | `outProcessors` | `Array<(md: string) => string \| Promise>` | `[]` | Extra post-processors for included markdown, run after the built-in fixes | | `includeAllowedHosts` | `string[]` | `[]` | Hostnames allowed as remote `::include{file=https://…}` targets | | `debug` | boolean | `false` | Emit build-time debug traces for the include pipeline (resolved placeholders and `::include` directives) via `@docusaurus/logger` | +| `markdownRenderChain` | `PluggableList` | _default chain_ | Override the markdown→sanitized-HTML plugin chain (see below) | The token is read at build time only. Provide it via an environment variable (`GITLAB_TOKEN`) — never commit it. +### 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. + ## How it works A remark plugin walks the MDX syntax tree during `docusaurus build`, finds the 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. 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. 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/context.test.ts b/src/gitlab/context.test.ts index c65e0b5..1c2a86f 100644 --- a/src/gitlab/context.test.ts +++ b/src/gitlab/context.test.ts @@ -1,6 +1,15 @@ -import { describe, it, expect } from "vitest"; +import remarkParse from "remark-parse"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveOptions } from "../options.js"; -import { buildContext, CACHE_DIR } from "./context.js"; +import { buildContext, CACHE_DIR, warnIfChainMissingSanitize } from "./context.js"; +import { defaultMarkdownRenderChain } from "./markdown.js"; + +// 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()); describe("buildContext", () => { it("builds a context with client, cache, assets and host", () => { @@ -16,3 +25,16 @@ describe("buildContext", () => { expect(CACHE_DIR).toContain("node_modules/.cache/@ebuildy/docusaurus-plugin-gitlab"); }); }); + +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(); + }); +}); diff --git a/src/gitlab/context.ts b/src/gitlab/context.ts index e8919a1..e4166d4 100644 --- a/src/gitlab/context.ts +++ b/src/gitlab/context.ts @@ -1,11 +1,33 @@ +import type { PluggableList } from "unified"; import type { ResolvedOptions } from "../options.js"; import { AssetManager } from "./assets.js"; import { FileCache } from "./cache.js"; import { GitLabClient } from "./client.js"; import type { GitLabContext } from "./fetchers.js"; +import { chainHasSanitize } from "./markdown.js"; export const CACHE_DIR = "node_modules/.cache/@ebuildy/docusaurus-plugin-gitlab"; +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.", + ); +} + export function buildContext(options: ResolvedOptions): GitLabContext { const client = new GitLabClient({ host: options.host, token: options.token }); const cache = new FileCache(CACHE_DIR, options.cache); @@ -16,6 +38,11 @@ export function buildContext(options: ResolvedOptions): GitLabContext { assetBaseUrl: options.assetBaseUrl, host: options.host, }); + if (options.markdownRenderChain) { + // Fire-and-forget: the warning lazily imports @docusaurus/logger; swallow a + // rejected import so it never surfaces as an unhandled rejection. + void warnIfChainMissingSanitize(options.markdownRenderChain).catch(() => {}); + } return { client, cache, @@ -25,6 +52,7 @@ export function buildContext(options: ResolvedOptions): GitLabContext { strict: options.strict, allowedHosts: options.includeAllowedHosts, debug: options.debug, + markdownRenderChain: options.markdownRenderChain, }, }; } diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 072dc74..801dc15 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -1,6 +1,9 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import rehypeRaw from "rehype-raw"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; @@ -19,13 +22,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"); @@ -72,6 +77,19 @@ describe("fetchReleases", () => { const data = await fetchReleases(ctx(client), { project: "g/r" }); expect(data.map((r) => r.tagName)).toEqual(["v1"]); }); + + 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"); + }); }); describe("fetchIssues", () => { diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 3adb096..656ff2e 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -1,6 +1,7 @@ // `expandIncludes` lives in the include subsystem, which imports `fetchFileSource` // from this module — a benign call-time-only cycle (both are hoisted function // declarations, used only at runtime, never at module-eval time). +import type { PluggableList } from "unified"; import { expandIncludes } from "../include/expand.js"; import { createIncludeLogger } from "../include/logger.js"; import type { AssetManager } from "./assets"; @@ -29,6 +30,7 @@ export interface GitLabContext { strict?: boolean; allowedHosts?: string[]; debug?: boolean; + markdownRenderChain?: PluggableList; }; } @@ -79,7 +81,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 ?? "", { renderChain: ctx.options.markdownRenderChain }), webUrl: p.web_url, starCount: p.star_count, forksCount: p.forks_count, @@ -102,7 +104,7 @@ export async function fetchReleases(ctx: GitLabContext, attrs: Attrs): Promise ({ name: l.name, url: l.url })), })), @@ -191,6 +193,7 @@ export async function fetchReadme(ctx: GitLabContext, attrs: Attrs): Promise ctx.assets.localize(src, ref, project), + renderChain: ctx.options.markdownRenderChain, }); const result: ReadmeData = { ref, html }; if (tocMode === "sidebar") result.toc = collectToc; @@ -355,6 +358,7 @@ export async function fetchFile(ctx: GitLabContext, attrs: Attrs): Promise ctx.assets.localize(src, ref, String(project)), + renderChain: ctx.options.markdownRenderChain, }); return { kind: "markdown", html, ref, path } satisfies FileData; } diff --git a/src/gitlab/markdown.test.ts b/src/gitlab/markdown.test.ts index 09e8553..f35a32c 100644 --- a/src/gitlab/markdown.test.ts +++ b/src/gitlab/markdown.test.ts @@ -1,5 +1,9 @@ +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; import { describe, it, expect } from "vitest"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, defaultMarkdownRenderChain, chainHasSanitize } from "./markdown"; describe("renderMarkdown", () => { it("renders gfm markdown to html", async () => { @@ -8,6 +12,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("