From c82939130f8c00413020945e40d1c8456579aa25 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:13:18 +0200 Subject: [PATCH 01/16] docs: design for GitlabTopics and GitlabLabels components Co-Authored-By: Claude Opus 4.8 --- .../2026-07-01-gitlab-topics-labels-design.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-gitlab-topics-labels-design.md diff --git a/docs/superpowers/specs/2026-07-01-gitlab-topics-labels-design.md b/docs/superpowers/specs/2026-07-01-gitlab-topics-labels-design.md new file mode 100644 index 0000000..3c96583 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-gitlab-topics-labels-design.md @@ -0,0 +1,280 @@ +# Design: `GitlabTopics` & `GitlabLabels` components + +**Date:** 2026-07-01 +**Status:** Approved (brainstorming) + +## Summary + +Two new build-time MDX components that render GitLab **topics** and **labels** as +lists of links. Each item links to the corresponding search/filter view on GitLab. +Both follow the existing pipeline: `registry → fetcher → inject → pure component`. + +- `` — renders the instance-wide topic catalog, each topic linking to + its projects-by-topic explore page, with a small bubble showing how many projects + use it. +- `` — renders the labels of a project **or** a group, each linking to + the issues list filtered by that label. Two configurable layouts (list / cards). + +Two separate components (rather than one `GitlabList` with a `type` switch) because +the data sources, URLs, and rendering differ enough that separate fetchers/components +are cleaner and match the one-fetcher-per-component convention. + +## Attribute surface + +### `` + +Instance-wide catalog; **no** project/group scope (GitLab has no group-topics +endpoint — the instance `/topics` catalog is the source). + +| attr | type | default | notes | +|---|---|---|---| +| `filter` | string | — | JS regex, case-insensitive, matched against the topic **title** | +| `order` | string | `name` | `name` \| `name:asc` \| `name:desc` | +| `limit` | number | all | cap applied after filter + sort | + +### `` + +Same three attributes, **plus exactly one of** `project` / `group`, **plus** `layout`. + +| attr | type | default | notes | +|---|---|---|---| +| `project` | string \| number | — | e.g. `"group/proj"` or numeric id | +| `group` | string \| number | — | e.g. `"my-group"` or numeric id | +| `filter` | string | — | JS regex, case-insensitive, matched against the label **name** | +| `order` | string | `name` | `name` \| `name:asc` \| `name:desc` | +| `limit` | number | all | cap applied after filter + sort | +| `layout` | string | `list` | `list` \| `cards` | + +**Validation (build-time errors, following the `readTocMode` precedent):** + +- `` requires **exactly one** of `project` / `group`; both or neither + throws a clear error. +- Invalid `order` value throws. +- Invalid `layout` value throws. +- An invalid `filter` regex throws (surfaced via the standard remark error path: + aborts the build in `strict` mode, renders the `Fallback` otherwise). + +All attribute values remain **static literals** (enforced by `parseAttributes`). + +## Domain types (`src/gitlab/types.ts`) + +```ts +export interface TopicData { + name: string; // slug used in the explore URL + title: string; // human-readable display title + totalProjectsCount: number;// rendered in a small bubble + webUrl: string; // /explore/projects/topics/ +} + +export interface LabelData { + name: string; + color: string; // hex, e.g. "#428BCA" + textColor: string; // contrast color from the API (text_color) + description: string | null; + webUrl: string; // issues list filtered by this label +} +``` + +## Data flow + +### Topics + +1. `client.getTopics()` → `api.Topics.all({ perPage: 100 })`. The response includes + `name`, `title`, and `total_projects_count` by default — no extra calls. +2. Fetcher maps to `TopicData`, building + `webUrl = ${host}/explore/projects/topics/${encodeURIComponent(name)}`. +3. Fetcher applies **filter → sort → limit** in memory, then `memo(...)` caches the + normalized result. + +**Perf note:** on very large instances (e.g. gitlab.com) the topic catalog can be +large. The result is cached on disk (`FileCache` TTL) so repeated builds are cheap. +`filter`/`order`/`limit` are applied after fetch. If this proves too heavy in +practice, a future refinement is to pass a plain-prefix `filter` through the API's +`search` param; out of scope here. + +### Labels + +1. `client.getProjectLabels(project)` → `api.ProjectLabels.all(project)` + **or** `client.getGroupLabels(group)` → `api.GroupLabels.all(group)`. +2. **Archived filter:** drop archived labels with `raw.filter(l => l.archived !== true)`. + Using `!== true` (not `=== false`) keeps this a safe no-op on GitLab versions that + don't expose an `archived` field on labels. + **Verification during implementation:** confirm the exact field name/availability + against the live GitLab labels API; adjust if it differs. +3. **Link base URL** is derived from the API's `web_url` so links are correct even + when the scope is a numeric id: + - project scope → `client.getProject(project).web_url` (already cached). + - group scope → new `client.getGroup(group)` (`api.Groups.show`) → `web_url`. + - `webUrl = ${webUrl}/-/issues?label_name[]=${encodeURIComponent(name)}`. +4. Fetcher maps to `LabelData` (`color`, `text_color → textColor`, `description`), + applies **filter → sort → limit**, then `memo(...)` caches. + +`layout` does **not** affect the data payload — `description` is fetched regardless +(it is free in the labels response). Layout is purely presentational. + +### How `layout` reaches the component + +`injectProp` only **pushes** a `data`/`error` attribute onto the JSX node; it never +strips existing attributes. So `layout="cards"` survives remark and arrives at the +component as an ordinary React prop. The fetcher validates the value; the component +reads it directly. + +## Client additions (`src/gitlab/client.ts`) + +```ts +async getTopics(): Promise { + return this.api.Topics.all({ perPage: 100 }); +} +async getProjectLabels(project: ProjectRef): Promise { + return this.api.ProjectLabels.all(project); +} +async getGroupLabels(group: ProjectRef): Promise { + return this.api.GroupLabels.all(group); +} +async getGroup(group: ProjectRef): Promise { + return this.api.Groups.show(group); +} +``` + +gitbeaker resource names verified present: `Topics`, `ProjectLabels`, `GroupLabels`, +`Groups`. Responses are snake_case (`total_projects_count`, `text_color`, `web_url`); +normalize to camelCase in the fetchers per convention. + +## Fetchers (`src/gitlab/fetchers.ts`) + +Two new fetchers plus small shared helpers: + +- `readOrder(value)` → `{ field: "name"; dir: "asc" | "desc" }`; throws on invalid. +- `compileFilter(value)` → `(name: string) => boolean` using `new RegExp(value, "i")`; + throws on an invalid pattern. +- `readLayout(value)` → `"list" | "cards"`; throws on invalid (used by `fetchLabels` + only for validation). + +```ts +export async function fetchTopics(ctx, attrs): Promise +export async function fetchLabels(ctx, attrs): Promise +``` + +Sorting: `localeCompare` on the display field (`title` for topics, `name` for +labels), reversed for `desc`. Cache keys include filter/order/limit (and +project|group for labels); `layout` is excluded (does not affect data). + +## Registry (`src/remark/registry.ts`) + +```ts +GitlabTopics: fetchTopics, +GitlabLabels: fetchLabels, +``` + +## Components (`src/components/`) + +Pure, `error → Fallback; !data → null; else render`. + +### `GitlabTopics.tsx` + +```tsx +export function GitlabTopics({ data, error }: ComponentPayload) { + if (error) return ; + if (!data) return null; + return ( + + ); +} +``` + +### `GitlabLabels.tsx` + +Receives `layout` as a surviving prop (default `list`). + +```tsx +interface GitlabLabelsProps extends ComponentPayload { + layout?: "list" | "cards"; +} + +export function GitlabLabels({ data, error, layout = "list" }: GitlabLabelsProps) { + if (error) return ; + if (!data) return null; + if (layout === "cards") { + return ( + + ); + } + return ( + + ); +} +``` + +**Styling convention:** existing components use **plain global class names** (e.g. +`gitlab-badge`, `gitlab-card`) and do **not** import a CSS module — styling is left +to the consumer's theme. The new classes (`gitlab-topics`, `gitlab-count-bubble`, +`gitlab-labels`, `gitlab-label`, `gitlab-label-cards`, `gitlab-label-card`, +`gitlab-label-card-desc`) follow the same plain-class approach; no CSS module import +is added. + +## Exports + +- `src/components/index.ts` — export `GitlabTopics`, `GitlabLabels`. +- `src/index.ts` — export `TopicData`, `LabelData` types. + +## Error handling + +Unchanged: fetchers throw; the remark transformer centralizes `strict` handling +(throw → abort build, or inject an `error` prop → render `Fallback`). Empty results +render an empty list (consistent with `GitlabIssues`). + +## Testing (TDD) + +**Fetcher tests** (fake/mocked client): +- Topics: normalization incl. `totalProjectsCount`; filter regex; order asc/desc; + limit; webUrl construction. +- Labels: project vs group source; archived labels excluded; link base built from + `web_url` (project and group); filter/order/limit; both/neither scope → error; + invalid `order`/`layout`/`filter` → error. + +**Component tests** (React Testing Library): +- `GitlabTopics`: renders links with correct `href` and count bubble; `error → Fallback`. +- `GitlabLabels`: `list` layout renders colored badge links; `cards` layout renders + title + description; correct `href`; `layout` defaults to `list`; `error → Fallback`. + +## Docs + +- README section for both components. +- `examples/site/docs/components/` page for each (also exercised by the slow e2e + build in `test/e2e/build.test.ts`). + +## Out of scope + +- Ordering by usage count (explicitly deferred — `order` is name-only). +- Displaying label issue/MR counts. +- Passing `filter` through the API `search` param (client-side regex only). +- A merge-request link variant for labels (issues only). From 23226a1288e372d328c0f5ca310263e905034126 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:20:39 +0200 Subject: [PATCH 02/16] docs: implementation plan for GitlabTopics and GitlabLabels Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-01-gitlab-topics-labels.md | 1078 +++++++++++++++++ 1 file changed, 1078 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-gitlab-topics-labels.md diff --git a/docs/superpowers/plans/2026-07-01-gitlab-topics-labels.md b/docs/superpowers/plans/2026-07-01-gitlab-topics-labels.md new file mode 100644 index 0000000..80e90a8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-gitlab-topics-labels.md @@ -0,0 +1,1078 @@ +# GitlabTopics & GitlabLabels 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 two build-time MDX components — `` (instance-wide topic catalog) and `` (project/group labels, list or card layout) — each rendering items as links to the matching GitLab search/filter view. + +**Architecture:** Follow the existing pipeline `registry → fetcher → inject → pure component`. New client methods wrap gitbeaker's `Topics`, `ProjectLabels`, `GroupLabels`, and `Groups` resources. Fetchers normalize snake_case → camelCase, then apply filter → sort → limit in memory (cached via `memo`). Components are pure (`error → Fallback; !data → null; else render`). The `layout` attribute is presentational: it survives remark (`injectProp` only pushes `data`/`error`, never strips attributes) and reaches the component as a prop. + +**Tech Stack:** TypeScript (ESM, `.js` import extensions), `@gitbeaker/rest`, React, Vitest + React Testing Library, Docusaurus 3. + +Spec: `docs/superpowers/specs/2026-07-01-gitlab-topics-labels-design.md` + +## File map + +| File | Change | Responsibility | +|---|---|---| +| `src/gitlab/types.ts` | modify | Add `TopicData`, `LabelData` | +| `src/gitlab/client.ts` | modify | Add `getTopics`, `getProjectLabels`, `getGroupLabels`, `getGroup` | +| `src/gitlab/client.test.ts` | modify | Mock + test the 4 new client methods | +| `src/gitlab/fetchers.ts` | modify | Add `fetchTopics`, `fetchLabels` + helpers `readOrder`, `compileFilter`, `sortByName`, `readLayout` | +| `src/gitlab/fetchers.test.ts` | modify | Test both fetchers + helper behavior | +| `src/components/GitlabTopics.tsx` | create | Pure topics component | +| `src/components/GitlabTopics.test.tsx` | create | Component tests | +| `src/components/GitlabLabels.tsx` | create | Pure labels component (list + cards) | +| `src/components/GitlabLabels.test.tsx` | create | Component tests | +| `src/components/types.ts` | modify | Re-export `TopicData`, `LabelData` | +| `src/components/index.ts` | modify | Export both components + new types | +| `src/index.ts` | modify | Export `TopicData`, `LabelData` | +| `src/remark/registry.ts` | modify | Register `GitlabTopics`, `GitlabLabels` | +| `README.md` | modify | Document both components | +| `examples/site/docs/components/topics.mdx` | create | Illustrative docs page | +| `examples/site/docs/components/labels.mdx` | create | Illustrative docs page | +| `examples/site/docs/intro.mdx` | modify | Live usage for e2e | +| `test/e2e/fixtures.ts` | modify | Stub topics/labels/group endpoints | +| `test/e2e/build.test.ts` | modify | Assert topics/labels baked into HTML | + +--- + +### Task 1: Domain types + client methods + +**Files:** +- Modify: `src/gitlab/types.ts` +- Modify: `src/gitlab/client.ts` +- Modify: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Add domain types** + +Append to `src/gitlab/types.ts`: + +```ts +export interface TopicData { + name: string; + title: string; + totalProjectsCount: number; + webUrl: string; +} + +export interface LabelData { + name: string; + color: string; + textColor: string; + description: string | null; + webUrl: string; +} +``` + +- [ ] **Step 2: Write failing client tests** + +In `src/gitlab/client.test.ts`, add four mock fns near the existing ones (after `showRawMock`): + +```ts +const topicsAllMock = vi.fn(); +const projectLabelsAllMock = vi.fn(); +const groupLabelsAllMock = vi.fn(); +const groupShowMock = vi.fn(); +``` + +Add them to the object returned by the mocked `Gitlab` constructor (inside the `return { ... }`): + +```ts + Topics: { all: topicsAllMock }, + ProjectLabels: { all: projectLabelsAllMock }, + GroupLabels: { all: groupLabelsAllMock }, + Groups: { show: groupShowMock }, +``` + +Add resets inside `beforeEach` (next to the existing `*.mockReset()` calls): + +```ts + topicsAllMock.mockReset(); + projectLabelsAllMock.mockReset(); + groupLabelsAllMock.mockReset(); + groupShowMock.mockReset(); +``` + +Add these tests inside the `describe("GitLabClient", ...)` block: + +```ts + it("getTopics delegates to Topics.all with a 100-per-page request", async () => { + topicsAllMock.mockResolvedValue([{ name: "docs", total_projects_count: 3 }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getTopics(); + expect(data).toEqual([{ name: "docs", total_projects_count: 3 }]); + expect(topicsAllMock).toHaveBeenCalledWith({ perPage: 100 }); + }); + + it("getProjectLabels delegates to ProjectLabels.all", async () => { + projectLabelsAllMock.mockResolvedValue([{ name: "bug" }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getProjectLabels("group/repo"); + expect(data).toEqual([{ name: "bug" }]); + expect(projectLabelsAllMock).toHaveBeenCalledWith("group/repo"); + }); + + it("getGroupLabels delegates to GroupLabels.all", async () => { + groupLabelsAllMock.mockResolvedValue([{ name: "epic" }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getGroupLabels("my-group"); + expect(data).toEqual([{ name: "epic" }]); + expect(groupLabelsAllMock).toHaveBeenCalledWith("my-group"); + }); + + it("getGroup delegates to Groups.show", async () => { + groupShowMock.mockResolvedValue({ id: 9, web_url: "https://x/groups/my-group" }); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getGroup("my-group"); + expect(data).toEqual({ id: 9, web_url: "https://x/groups/my-group" }); + expect(groupShowMock).toHaveBeenCalledWith("my-group"); + }); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: FAIL — `c.getTopics is not a function` (and the three others). + +- [ ] **Step 4: Implement the client methods** + +In `src/gitlab/client.ts`, add these methods to the `GitLabClient` class (after `getFileRaw`): + +```ts + async getTopics(): Promise { + return this.api.Topics.all({ perPage: 100 }); + } + + async getProjectLabels(project: ProjectRef): Promise { + return this.api.ProjectLabels.all(project); + } + + async getGroupLabels(group: ProjectRef): Promise { + return this.api.GroupLabels.all(group); + } + + async getGroup(group: ProjectRef): Promise { + return this.api.Groups.show(group); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: PASS (all client tests, old and new). + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add topic/label domain types and gitbeaker client methods" +``` + +--- + +### Task 2: Topics fetcher + shared helpers + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Modify: `src/gitlab/fetchers.test.ts` + +Note on API shape (gitbeaker `Topics.all`, snake_case): each item has `name` (slug), `title` (display), `total_projects_count`. + +- [ ] **Step 1: Write failing fetcher tests** + +In `src/gitlab/fetchers.test.ts`, add `fetchTopics` to the import from `./fetchers`: + +```ts +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; +``` + +(Importing `fetchLabels` now too; it is implemented in Task 3. The file won't type-check until Task 3, but these `fetchTopics` tests run.) + +Add this block: + +```ts +describe("fetchTopics", () => { + const raw = [ + { name: "docs", title: "Docs", total_projects_count: 3 }, + { name: "api", title: "API", total_projects_count: 10 }, + { name: "internal-tool", title: "Internal Tool", total_projects_count: 1 }, + ]; + + it("normalizes topics and builds the explore URL, sorted by title ascending", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), {}); + expect(data.map((t) => t.title)).toEqual(["API", "Docs", "Internal Tool"]); + expect(data[0]).toEqual({ + name: "api", + title: "API", + totalProjectsCount: 10, + webUrl: "https://gitlab.com/explore/projects/topics/api", + }); + }); + + it("sorts descending when order=name:desc", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { order: "name:desc" }); + expect(data.map((t) => t.title)).toEqual(["Internal Tool", "Docs", "API"]); + }); + + it("filters by case-insensitive regex on the title", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { filter: "^a" }); + expect(data.map((t) => t.title)).toEqual(["API"]); + }); + + it("applies the limit after filtering and sorting", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { limit: 2 }); + expect(data.map((t) => t.title)).toEqual(["API", "Docs"]); + }); + + it("throws on an invalid order value", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await expect(fetchTopics(ctx(client), { order: "count" })).rejects.toThrow(/order/); + }); + + it("throws on an invalid filter regex", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await expect(fetchTopics(ctx(client), { filter: "(" })).rejects.toThrow(/filter/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t fetchTopics` +Expected: FAIL — `fetchTopics is not a function`. + +- [ ] **Step 3: Implement helpers + fetchTopics** + +In `src/gitlab/fetchers.ts`: + +Add `TopicData` and `LabelData` to the type import: + +```ts +import type { + FileData, + IssueData, + LabelData, + ProjectInfoData, + ReadmeData, + ReleaseData, + TopicData, +} from "./types"; +``` + +Add these helpers (place them near the top, after the `memo` helper): + +```ts +interface OrderSpec { + field: "name"; + dir: "asc" | "desc"; +} + +function readOrder(value: unknown): OrderSpec { + if (value === undefined || value === "name" || value === "name:asc") { + return { field: "name", dir: "asc" }; + } + if (value === "name:desc") return { field: "name", dir: "desc" }; + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "order" must be one of "name", "name:asc", ` + + `"name:desc"; got ${JSON.stringify(value)}.`, + ); +} + +function compileFilter(value: unknown): ((text: string) => boolean) | null { + if (value === undefined) return null; + const pattern = String(value); + let re: RegExp; + try { + re = new RegExp(pattern, "i"); + } catch { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "filter" is not a valid regular expression: ${pattern}`, + ); + } + return (text: string) => re.test(text); +} + +function sortByName(items: T[], get: (item: T) => string, dir: "asc" | "desc"): T[] { + const sorted = [...items].sort((a, b) => get(a).localeCompare(get(b))); + return dir === "desc" ? sorted.reverse() : sorted; +} +``` + +Add the fetcher: + +```ts +export async function fetchTopics(ctx: GitLabContext, attrs: Attrs): Promise { + const order = readOrder(attrs.order); + const match = compileFilter(attrs.filter); + const limit = typeof attrs.limit === "number" ? attrs.limit : undefined; + const host = ctx.options.host; + const key = `topics:${String(attrs.filter ?? "")}:${order.dir}:${limit ?? "all"}`; + return memo(ctx, key, async () => { + const raw = await ctx.client.getTopics(); + let items: TopicData[] = raw.map((t: any) => ({ + name: t.name, + title: t.title ?? t.name, + totalProjectsCount: t.total_projects_count ?? 0, + webUrl: `${host}/explore/projects/topics/${encodeURIComponent(t.name)}`, + })); + if (match) items = items.filter((t) => match(t.title)); + items = sortByName(items, (t) => t.title, order.dir); + if (limit !== undefined) items = items.slice(0, limit); + return items; + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t fetchTopics` +Expected: PASS (all six `fetchTopics` tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchTopics with filter/order/limit helpers" +``` + +--- + +### Task 3: Labels fetcher + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Modify: `src/gitlab/fetchers.test.ts` + +Note on API shape (gitbeaker `ProjectLabels.all` / `GroupLabels.all`): each label has `name`, `color`, `text_color`, `description`, and (version-dependent) `archived`. Project/group `web_url` comes from `getProject` / `getGroup`. + +- [ ] **Step 1: Write failing fetcher tests** + +Add this block to `src/gitlab/fetchers.test.ts`: + +```ts +describe("fetchLabels", () => { + const rawLabels = [ + { name: "bug", color: "#d9534f", text_color: "#ffffff", description: "Defect", archived: false }, + { name: "feature", color: "#5cb85c", text_color: "#1a1a1a", description: null, archived: false }, + { name: "old", color: "#cccccc", text_color: "#000000", description: "retired", archived: true }, + ]; + + function labelClient() { + return { + getProjectLabels: vi.fn(async () => rawLabels), + getGroupLabels: vi.fn(async () => rawLabels), + getProject: vi.fn(async () => ({ web_url: "https://gitlab.com/group/repo" })), + getGroup: vi.fn(async () => ({ web_url: "https://gitlab.com/groups/my-group" })), + }; + } + + it("normalizes project labels, drops archived, and builds the issues link", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo" }); + expect(data.map((l) => l.name)).toEqual(["bug", "feature"]); + expect(data[0]).toEqual({ + name: "bug", + color: "#d9534f", + textColor: "#ffffff", + description: "Defect", + webUrl: "https://gitlab.com/group/repo/-/issues?label_name[]=bug", + }); + expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo"); + expect(client.getGroupLabels).not.toHaveBeenCalled(); + }); + + it("uses the group endpoints and group issues link for group scope", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { group: "my-group" }); + expect(client.getGroupLabels).toHaveBeenCalledWith("my-group"); + expect(client.getProjectLabels).not.toHaveBeenCalled(); + expect(data[0].webUrl).toBe("https://gitlab.com/groups/my-group/-/issues?label_name[]=bug"); + }); + + it("filters by case-insensitive regex on the name", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo", filter: "^feat" }); + expect(data.map((l) => l.name)).toEqual(["feature"]); + }); + + it("sorts descending and applies the limit", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo", order: "name:desc", limit: 1 }); + expect(data.map((l) => l.name)).toEqual(["feature"]); + }); + + it("throws when neither project nor group is given", async () => { + const client = labelClient(); + await expect(fetchLabels(ctx(client), {})).rejects.toThrow(/exactly one/); + }); + + it("throws when both project and group are given", async () => { + const client = labelClient(); + await expect( + fetchLabels(ctx(client), { project: "group/repo", group: "my-group" }), + ).rejects.toThrow(/exactly one/); + }); + + it("throws on an invalid layout value", async () => { + const client = labelClient(); + await expect( + fetchLabels(ctx(client), { project: "group/repo", layout: "grid" }), + ).rejects.toThrow(/layout/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t fetchLabels` +Expected: FAIL — `fetchLabels is not a function`. + +- [ ] **Step 3: Implement readLayout + fetchLabels** + +In `src/gitlab/fetchers.ts`, add the `readLayout` helper next to the others: + +```ts +function readLayout(value: unknown): "list" | "cards" { + if (value === undefined || value === "list" || value === "cards") { + return value === undefined ? "list" : value; + } + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "layout" must be "list" or "cards"; ` + + `got ${JSON.stringify(value)}.`, + ); +} +``` + +Add the fetcher: + +```ts +export async function fetchLabels(ctx: GitLabContext, attrs: Attrs): Promise { + const project = attrs.project as string | number | undefined; + const group = attrs.group as string | number | undefined; + if ((project === undefined) === (group === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + readLayout(attrs.layout); // validate only; layout is presentational (read by the component) + const order = readOrder(attrs.order); + const match = compileFilter(attrs.filter); + const limit = typeof attrs.limit === "number" ? attrs.limit : undefined; + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `labels:${scopeKey}:${String(attrs.filter ?? "")}:${order.dir}:${limit ?? "all"}`; + return memo(ctx, key, async () => { + let raw: any[]; + let base: string; + if (project !== undefined) { + raw = await ctx.client.getProjectLabels(project); + base = (await ctx.client.getProject(project)).web_url; + } else { + raw = await ctx.client.getGroupLabels(group as string | number); + base = (await ctx.client.getGroup(group as string | number)).web_url; + } + let items: LabelData[] = raw + .filter((l) => l.archived !== true) + .map((l) => ({ + name: l.name, + color: l.color, + textColor: l.text_color, + description: l.description ?? null, + webUrl: `${base}/-/issues?label_name[]=${encodeURIComponent(l.name)}`, + })); + if (match) items = items.filter((l) => match(l.name)); + items = sortByName(items, (l) => l.name, order.dir); + if (limit !== undefined) items = items.slice(0, limit); + return items; + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (all fetcher tests, including `fetchTopics` and `fetchLabels`). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add fetchLabels with project/group scope and archived filter" +``` + +--- + +### Task 4: GitlabTopics component + +**Files:** +- Create: `src/components/GitlabTopics.tsx` +- Create: `src/components/GitlabTopics.test.tsx` + +- [ ] **Step 1: Write the failing component test** + +Create `src/components/GitlabTopics.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabTopics } from "./GitlabTopics"; + +const topics = [ + { name: "docs", title: "Docs", totalProjectsCount: 3, webUrl: "https://x/explore/projects/topics/docs" }, +]; + +describe("GitlabTopics", () => { + it("renders each topic as a link with its project-count bubble", () => { + render(); + const link = screen.getByRole("link", { name: /Docs/ }); + expect(link).toHaveAttribute("href", "https://x/explore/projects/topics/docs"); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/GitlabTopics.test.tsx` +Expected: FAIL — cannot find module `./GitlabTopics`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/GitlabTopics.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, TopicData } from "./types.js"; + +export function GitlabTopics({ data, error }: ComponentPayload) { + if (error) return ; + if (!data) return null; + return ( + + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/components/GitlabTopics.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GitlabTopics.tsx src/components/GitlabTopics.test.tsx +git commit -m "feat: add GitlabTopics component" +``` + +--- + +### Task 5: GitlabLabels component + +**Files:** +- Create: `src/components/GitlabLabels.tsx` +- Create: `src/components/GitlabLabels.test.tsx` + +- [ ] **Step 1: Write the failing component test** + +Create `src/components/GitlabLabels.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabLabels } from "./GitlabLabels"; + +const labels = [ + { name: "bug", color: "#d9534f", textColor: "#ffffff", description: "Defect", webUrl: "https://x/g/r/-/issues?label_name[]=bug" }, + { name: "feature", color: "#5cb85c", textColor: "#1a1a1a", description: null, webUrl: "https://x/g/r/-/issues?label_name[]=feature" }, +]; + +describe("GitlabLabels", () => { + it("defaults to the list layout: colored badge links, name only", () => { + render(); + const link = screen.getByRole("link", { name: "bug" }); + expect(link).toHaveAttribute("href", "https://x/g/r/-/issues?label_name[]=bug"); + expect(link).toHaveStyle({ backgroundColor: "#d9534f", color: "#ffffff" }); + // description is not rendered as body text in list layout + expect(screen.queryByText("Defect")).not.toBeInTheDocument(); + }); + + it("renders description text in the cards layout", () => { + render(); + expect(screen.getByRole("link", { name: /bug/ })).toHaveAttribute( + "href", + "https://x/g/r/-/issues?label_name[]=bug", + ); + expect(screen.getByText("Defect")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/GitlabLabels.test.tsx` +Expected: FAIL — cannot find module `./GitlabLabels`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/GitlabLabels.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, LabelData } from "./types.js"; + +interface GitlabLabelsProps extends ComponentPayload { + layout?: "list" | "cards"; +} + +export function GitlabLabels({ data, error, layout = "list" }: GitlabLabelsProps) { + if (error) return ; + if (!data) return null; + if (layout === "cards") { + return ( + + ); + } + return ( + + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/components/GitlabLabels.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GitlabLabels.tsx src/components/GitlabLabels.test.tsx +git commit -m "feat: add GitlabLabels component with list and cards layouts" +``` + +--- + +### Task 6: Wire registry + exports + +**Files:** +- Modify: `src/remark/registry.ts` +- Modify: `src/components/types.ts` +- Modify: `src/components/index.ts` +- Modify: `src/index.ts` + +- [ ] **Step 1: Register the fetchers** + +In `src/remark/registry.ts`, add `fetchTopics, fetchLabels` to the import from `../gitlab/fetchers.js`: + +```ts +import { + fetchProjectInfo, + fetchReadme, + fetchReleases, + fetchIssues, + fetchFile, + fetchTopics, + fetchLabels, + type GitLabContext, +} from "../gitlab/fetchers.js"; +``` + +Add two entries to `COMPONENT_REGISTRY`: + +```ts + GitlabTopics: fetchTopics, + GitlabLabels: fetchLabels, +``` + +- [ ] **Step 2: Re-export the new types from the components barrel types** + +In `src/components/types.ts`, add `TopicData` and `LabelData` to the re-exported list: + +```ts +export type { + ProjectInfoData, + ReleaseData, + IssueData, + ReadmeData, + FileData, + TopicData, + LabelData, + FetchError, + ComponentPayload, +} from "../gitlab/types.js"; +``` + +- [ ] **Step 3: Export the components and types from the components index** + +In `src/components/index.ts`, add the two component exports (after the `GitlabFile` line): + +```ts +export { GitlabTopics } from "./GitlabTopics.js"; +export { GitlabLabels } from "./GitlabLabels.js"; +``` + +And add `TopicData, LabelData` to the `export type { ... }` block: + +```ts +export type { + ProjectInfoData, + ReleaseData, + IssueData, + ReadmeData, + FileData, + TopicData, + LabelData, + FetchError, + ComponentPayload, +} from "./types.js"; +``` + +- [ ] **Step 4: Export the domain types from the package root** + +In `src/index.ts`, add `TopicData, LabelData` to the type export block: + +```ts +export type { + ProjectInfoData, + ReleaseData, + IssueData, + ReadmeData, + FileData, + TopicData, + LabelData, + FetchError, +} from "./gitlab/types.js"; +``` + +- [ ] **Step 5: Typecheck and run the full unit suite** + +Run: `npm run typecheck && npx vitest run` +Expected: typecheck passes; all tests PASS (excludes the slow e2e unless invoked directly). + +- [ ] **Step 6: Commit** + +```bash +git add src/remark/registry.ts src/components/types.ts src/components/index.ts src/index.ts +git commit -m "feat: register GitlabTopics/GitlabLabels and export their types" +``` + +--- + +### Task 7: Documentation + +**Files:** +- Create: `examples/site/docs/components/topics.mdx` +- Create: `examples/site/docs/components/labels.mdx` +- Modify: `README.md` + +These example pages are illustrative (component usages inside fenced code blocks), matching every existing page under `examples/site/docs/components/`. Live e2e usage is added in Task 8. + +- [ ] **Step 1: Create the topics docs page** + +Create `examples/site/docs/components/topics.mdx`: + +````mdx +--- +title: GitlabTopics +sidebar_position: 7 +--- + +# `` + +Renders the GitLab instance's topic catalog as a list of links. Each topic links to +its projects-by-topic explore page and shows a bubble with the number of projects +using it. Topics are instance-wide, so this component takes no `project`/`group`. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `filter` | `string` | — | Case-insensitive regular expression matched against the topic title. | +| `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | +| `limit` | `number` | all | Maximum number of topics to show (applied after filter + sort). | + +## Notes + +- Each topic links to `/explore/projects/topics/`. +- The count bubble is the topic's `total_projects_count`. +```` + +- [ ] **Step 2: Create the labels docs page** + +Create `examples/site/docs/components/labels.mdx`: + +````mdx +--- +title: GitlabLabels +sidebar_position: 8 +--- + +# `` + +Renders the labels of a project **or** a group as a list of links. Each label links +to the issues list filtered by that label and keeps its GitLab color. Two layouts are +available. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `project` | `string \| number` | — | Project path or numeric ID. Provide **either** this or `group`. | +| `group` | `string \| number` | — | Group path or numeric ID. Provide **either** this or `project`. | +| `layout` | `string` | `list` | `list` (colored badges) or `cards` (badge + description). | +| `filter` | `string` | — | Case-insensitive regular expression matched against the label name. | +| `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | +| `limit` | `number` | all | Maximum number of labels to show (applied after filter + sort). | + +## Notes + +- Exactly one of `project` / `group` is required; providing both or neither fails the build. +- Each label links to `/-/issues?label_name[]=`. +- Archived labels are omitted. +```` + +- [ ] **Step 3: Add both components to the README** + +In `README.md`, immediately after the `### ``` section (and before the next `###`/section), add: + +````markdown +### `` + +The instance topic catalog as links, each with a project-count bubble. + +```mdx + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `filter` | string | — | Case-insensitive regex on the topic title | +| `order` | string | `name` | `name`, `name:asc`, or `name:desc` | +| `limit` | number | all | Max topics to show | + +### `` + +A project's or group's labels as links to the filtered issues list. `list` or `cards` layout. + +```mdx + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `project` | string \| number | — | Provide either `project` or `group` | +| `group` | string \| number | — | Provide either `project` or `group` | +| `layout` | string | `list` | `list` or `cards` | +| `filter` | string | — | Case-insensitive regex on the label name | +| `order` | string | `name` | `name`, `name:asc`, or `name:desc` | +| `limit` | number | all | Max labels to show | +```` + +- [ ] **Step 4: Verify the docs build parses (typecheck is unaffected; sanity-check MDX later via e2e)** + +Run: `git diff --stat` +Expected: shows the two new `.mdx` files and the modified `README.md`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/site/docs/components/topics.mdx examples/site/docs/components/labels.mdx README.md +git commit -m "docs: document GitlabTopics and GitlabLabels" +``` + +--- + +### Task 8: End-to-end coverage + +**Files:** +- Modify: `test/e2e/fixtures.ts` +- Modify: `examples/site/docs/intro.mdx` +- Modify: `test/e2e/build.test.ts` + +- [ ] **Step 1: Add stub endpoints for topics, labels, and group** + +In `test/e2e/fixtures.ts`, inside the `createServer` handler, add these branches **before** the generic `if (url.startsWith("/api/v4/projects/group%2Frepo") ...)` branch (project-labels must match before the generic project route), and add the topics/group routes anywhere before the final 404: + +```ts + if (url.startsWith("/api/v4/topics")) { + return send([ + { name: "docs", title: "Docs", total_projects_count: 4 }, + { name: "api", title: "API", total_projects_count: 9 }, + ]); + } + if (url.startsWith("/api/v4/projects/group%2Frepo/labels")) { + return send([ + { name: "bug", color: "#d9534f", text_color: "#ffffff", description: "Defect", archived: false }, + { name: "feature", color: "#5cb85c", text_color: "#1a1a1a", description: "New capability", archived: false }, + ]); + } + if (url.startsWith("/api/v4/groups/my-group/labels")) { + return send([ + { name: "epic", color: "#8e44ad", text_color: "#ffffff", description: "Cross-project", archived: false }, + ]); + } + if (url.startsWith("/api/v4/groups/my-group")) { + return send({ id: 42, web_url: "https://x/groups/my-group" }); + } +``` + +Note: the existing `/api/v4/projects/group%2Frepo/releases` and `.../issues` branches already sit before the generic project branch; place the new `.../labels` branch alongside them so it is not swallowed by the generic route. + +- [ ] **Step 2: Add live component usage to the e2e page** + +Append to `examples/site/docs/intro.mdx`: + +```mdx +## Topics + + + +## Labels + + + + +``` + +- [ ] **Step 3: Add e2e assertions** + +In `test/e2e/build.test.ts`, add this test inside the `describe("e2e: docusaurus build", ...)` block: + +```ts + it("bakes topics and labels into the static html", () => { + const html = readFileSync(join(siteDir, "build", "index.html"), "utf8"); + // topic explore link + count bubble (robust against Docusaurus's "Docs" navbar label) + expect(html).toContain("/explore/projects/topics/docs"); + expect(html).toContain("gitlab-count-bubble"); + // project label (cards layout) with its description and issues link + expect(html).toContain("gitlab-label-card"); + expect(html).toContain("label_name[]=bug"); + expect(html).toContain("New capability"); + // group label with the group issues link + expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); + }); +``` + +- [ ] **Step 4: Run the e2e build test** + +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS (~1 min). If it fails on a 404 for labels, confirm the `.../labels` branch precedes the generic `/api/v4/projects/group%2Frepo` branch. + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/fixtures.ts examples/site/docs/intro.mdx test/e2e/build.test.ts +git commit -m "test: e2e coverage for GitlabTopics and GitlabLabels" +``` + +--- + +### Task 9: Final verification + +- [ ] **Step 1: Full typecheck + unit tests** + +Run: `npm run typecheck && npx vitest run` +Expected: typecheck clean; all unit tests PASS. + +- [ ] **Step 2: Build the package** + +Run: `npm run build` +Expected: compiles to `dist/` with no errors (ESM `.js` + `.d.ts`). + +- [ ] **Step 3: Update the graphify graph** + +Run: `graphify update .` +Expected: graph regenerated (AST-only, no API cost). + +- [ ] **Step 4: Commit any remaining artifacts** + +```bash +git add -A +git commit -m "chore: rebuild and refresh graphify graph" || echo "nothing to commit" +``` + +--- + +## Notes for the implementer + +- **ESM imports:** intra-package imports use explicit `.js` extensions (e.g. `./Fallback.js`). Match this in new files. +- **snake_case → camelCase:** gitbeaker responses are snake_case (`total_projects_count`, `text_color`, `web_url`). Normalize in the fetcher only. +- **Archived field:** the labels API `archived` field may not exist on older GitLab versions; the `l.archived !== true` filter is a safe no-op there. If you have access to the target instance, confirm the field name during Step 3 of Task 3. +- **`layout` is presentational:** it is validated in the fetcher (`readLayout`) but never baked into the payload; the surviving JSX attribute reaches the component as a prop. +- **Do not** import `@theme/*` or `@docusaurus/*` in `src/components/*` (breaks SSR). Plain class names only; no CSS module import. From 26f77336f75220aa5a95b72be4adda18383fc8ad Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:26:07 +0200 Subject: [PATCH 03/16] feat: add topic/label domain types and gitbeaker client methods --- src/gitlab/client.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ src/gitlab/client.ts | 16 ++++++++++++++ src/gitlab/types.ts | 15 +++++++++++++ 3 files changed, 75 insertions(+) diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index 507d002..64fcfb1 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -5,6 +5,10 @@ const releasesAllMock = vi.fn(); const issuesAllMock = vi.fn(); const showRawMock = vi.fn(); const gitlabCtor = vi.fn(); +const topicsAllMock = vi.fn(); +const projectLabelsAllMock = vi.fn(); +const groupLabelsAllMock = vi.fn(); +const groupShowMock = vi.fn(); vi.mock("@gitbeaker/rest", () => ({ // Vitest 4 invokes the mock implementation as a real constructor under @@ -16,6 +20,10 @@ vi.mock("@gitbeaker/rest", () => ({ ProjectReleases: { all: releasesAllMock }, Issues: { all: issuesAllMock }, RepositoryFiles: { showRaw: showRawMock }, + Topics: { all: topicsAllMock }, + ProjectLabels: { all: projectLabelsAllMock }, + GroupLabels: { all: groupLabelsAllMock }, + Groups: { show: groupShowMock }, }; }), })); @@ -31,6 +39,10 @@ beforeEach(() => { issuesAllMock.mockReset(); showRawMock.mockReset(); gitlabCtor.mockReset(); + topicsAllMock.mockReset(); + projectLabelsAllMock.mockReset(); + groupLabelsAllMock.mockReset(); + groupShowMock.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); @@ -119,4 +131,36 @@ describe("GitLabClient", () => { const c = new GitLabClient({ host: "https://gitlab.com" }); await expect(c.requestBinary("https://gitlab.com/x.png")).rejects.toThrow(/404/); }); + + it("getTopics delegates to Topics.all with a 100-per-page request", async () => { + topicsAllMock.mockResolvedValue([{ name: "docs", total_projects_count: 3 }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getTopics(); + expect(data).toEqual([{ name: "docs", total_projects_count: 3 }]); + expect(topicsAllMock).toHaveBeenCalledWith({ perPage: 100 }); + }); + + it("getProjectLabels delegates to ProjectLabels.all", async () => { + projectLabelsAllMock.mockResolvedValue([{ name: "bug" }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getProjectLabels("group/repo"); + expect(data).toEqual([{ name: "bug" }]); + expect(projectLabelsAllMock).toHaveBeenCalledWith("group/repo"); + }); + + it("getGroupLabels delegates to GroupLabels.all", async () => { + groupLabelsAllMock.mockResolvedValue([{ name: "epic" }]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getGroupLabels("my-group"); + expect(data).toEqual([{ name: "epic" }]); + expect(groupLabelsAllMock).toHaveBeenCalledWith("my-group"); + }); + + it("getGroup delegates to Groups.show", async () => { + groupShowMock.mockResolvedValue({ id: 9, web_url: "https://x/groups/my-group" }); + const c = new GitLabClient({ host: "https://gitlab.com" }); + const data = await c.getGroup("my-group"); + expect(data).toEqual({ id: 9, web_url: "https://x/groups/my-group" }); + expect(groupShowMock).toHaveBeenCalledWith("my-group"); + }); }); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index aae79ec..8410bd4 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -53,6 +53,22 @@ export class GitLabClient { return typeof raw === "string" ? raw : await raw.text(); } + async getTopics(): Promise { + return this.api.Topics.all({ perPage: 100 }); + } + + async getProjectLabels(project: ProjectRef): Promise { + return this.api.ProjectLabels.all(project); + } + + async getGroupLabels(group: ProjectRef): Promise { + return this.api.GroupLabels.all(group); + } + + async getGroup(group: ProjectRef): Promise { + return this.api.Groups.show(group); + } + private headers(): Record { const h: Record = { Accept: "application/json" }; if (this.config.token) h["PRIVATE-TOKEN"] = this.config.token; diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index fdbcd99..f5972ac 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -73,3 +73,18 @@ export interface ComponentPayload { data?: T; error?: FetchError; } + +export interface TopicData { + name: string; + title: string; + totalProjectsCount: number; + webUrl: string; +} + +export interface LabelData { + name: string; + color: string; + textColor: string; + description: string | null; + webUrl: string; +} From 0afa05116f6c1182a857cd6a7447ca73226ac720 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:29:56 +0200 Subject: [PATCH 04/16] feat: add fetchTopics with filter/order/limit helpers Co-Authored-By: Claude Sonnet 4.6 --- src/gitlab/fetchers.test.ts | 50 +++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index d6a9a66..a9278bb 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile, fetchTopics } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -253,3 +253,51 @@ describe("fetchFile", () => { expect(code.language).toBe("weird"); }); }); + +describe("fetchTopics", () => { + const raw = [ + { name: "docs", title: "Docs", total_projects_count: 3 }, + { name: "api", title: "API", total_projects_count: 10 }, + { name: "internal-tool", title: "Internal Tool", total_projects_count: 1 }, + ]; + + it("normalizes topics and builds the explore URL, sorted by title ascending", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), {}); + expect(data.map((t) => t.title)).toEqual(["API", "Docs", "Internal Tool"]); + expect(data[0]).toEqual({ + name: "api", + title: "API", + totalProjectsCount: 10, + webUrl: "https://gitlab.com/explore/projects/topics/api", + }); + }); + + it("sorts descending when order=name:desc", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { order: "name:desc" }); + expect(data.map((t) => t.title)).toEqual(["Internal Tool", "Docs", "API"]); + }); + + it("filters by case-insensitive regex on the title", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { filter: "^a" }); + expect(data.map((t) => t.title)).toEqual(["API"]); + }); + + it("applies the limit after filtering and sorting", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + const data = await fetchTopics(ctx(client), { limit: 2 }); + expect(data.map((t) => t.title)).toEqual(["API", "Docs"]); + }); + + it("throws on an invalid order value", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await expect(fetchTopics(ctx(client), { order: "count" })).rejects.toThrow(/order/); + }); + + it("throws on an invalid filter regex", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await expect(fetchTopics(ctx(client), { filter: "(" })).rejects.toThrow(/filter/); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 8a43658..59d7bbf 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -9,6 +9,7 @@ import type { ProjectInfoData, ReadmeData, ReleaseData, + TopicData, } from "./types"; export interface GitLabContext { @@ -93,6 +94,41 @@ export async function fetchIssues(ctx: GitLabContext, attrs: Attrs): Promise boolean) | null { + if (value === undefined) return null; + const pattern = String(value); + let re: RegExp; + try { + re = new RegExp(pattern, "i"); + } catch { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "filter" is not a valid regular expression: ${pattern}`, + ); + } + return (text: string) => re.test(text); +} + +function sortByName(items: T[], get: (item: T) => string, dir: "asc" | "desc"): T[] { + const sorted = [...items].sort((a, b) => get(a).localeCompare(get(b))); + return dir === "desc" ? sorted.reverse() : sorted; +} + function readTocMode(value: unknown): TocMode { if (value === undefined) return "auto"; if (value === "hidden" || value === "inline" || value === "sidebar") return value; @@ -176,6 +212,27 @@ function languageFromPath(path: string): string { return LANGUAGE_BY_EXTENSION[ext] ?? ext ?? "text"; } +export async function fetchTopics(ctx: GitLabContext, attrs: Attrs): Promise { + const order = readOrder(attrs.order); + const match = compileFilter(attrs.filter); + const limit = typeof attrs.limit === "number" ? attrs.limit : undefined; + const host = ctx.options.host; + const key = `topics:${String(attrs.filter ?? "")}:${order.dir}:${limit ?? "all"}`; + return memo(ctx, key, async () => { + const raw = await ctx.client.getTopics(); + let items: TopicData[] = raw.map((t: any) => ({ + name: t.name, + title: t.title ?? t.name, + totalProjectsCount: t.total_projects_count ?? 0, + webUrl: `${host}/explore/projects/topics/${encodeURIComponent(t.name)}`, + })); + if (match) items = items.filter((t) => match(t.title)); + items = sortByName(items, (t) => t.title, order.dir); + if (limit !== undefined) items = items.slice(0, limit); + return items; + }); +} + export async function fetchFile(ctx: GitLabContext, attrs: Attrs): Promise { const project = attrs.project as string | number; const path = String(attrs.path); From 08c2e25e6a14d50c035582162787235b872c175e Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:51:31 +0200 Subject: [PATCH 05/16] feat: add fetchLabels with project/group scope and archived filter Co-Authored-By: Claude Sonnet 4.6 --- src/gitlab/fetchers.test.ts | 73 ++++++++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index a9278bb..26896fc 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile, fetchTopics } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -301,3 +301,74 @@ describe("fetchTopics", () => { await expect(fetchTopics(ctx(client), { filter: "(" })).rejects.toThrow(/filter/); }); }); + +describe("fetchLabels", () => { + const rawLabels = [ + { name: "bug", color: "#d9534f", text_color: "#ffffff", description: "Defect", archived: false }, + { name: "feature", color: "#5cb85c", text_color: "#1a1a1a", description: null, archived: false }, + { name: "old", color: "#cccccc", text_color: "#000000", description: "retired", archived: true }, + ]; + + function labelClient() { + return { + getProjectLabels: vi.fn(async () => rawLabels), + getGroupLabels: vi.fn(async () => rawLabels), + getProject: vi.fn(async () => ({ web_url: "https://gitlab.com/group/repo" })), + getGroup: vi.fn(async () => ({ web_url: "https://gitlab.com/groups/my-group" })), + }; + } + + it("normalizes project labels, drops archived, and builds the issues link", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo" }); + expect(data.map((l) => l.name)).toEqual(["bug", "feature"]); + expect(data[0]).toEqual({ + name: "bug", + color: "#d9534f", + textColor: "#ffffff", + description: "Defect", + webUrl: "https://gitlab.com/group/repo/-/issues?label_name[]=bug", + }); + expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo"); + expect(client.getGroupLabels).not.toHaveBeenCalled(); + }); + + it("uses the group endpoints and group issues link for group scope", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { group: "my-group" }); + expect(client.getGroupLabels).toHaveBeenCalledWith("my-group"); + expect(client.getProjectLabels).not.toHaveBeenCalled(); + expect(data[0].webUrl).toBe("https://gitlab.com/groups/my-group/-/issues?label_name[]=bug"); + }); + + it("filters by case-insensitive regex on the name", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo", filter: "^feat" }); + expect(data.map((l) => l.name)).toEqual(["feature"]); + }); + + it("sorts descending and applies the limit", async () => { + const client = labelClient(); + const data = await fetchLabels(ctx(client), { project: "group/repo", order: "name:desc", limit: 1 }); + expect(data.map((l) => l.name)).toEqual(["feature"]); + }); + + it("throws when neither project nor group is given", async () => { + const client = labelClient(); + await expect(fetchLabels(ctx(client), {})).rejects.toThrow(/exactly one/); + }); + + it("throws when both project and group are given", async () => { + const client = labelClient(); + await expect( + fetchLabels(ctx(client), { project: "group/repo", group: "my-group" }), + ).rejects.toThrow(/exactly one/); + }); + + it("throws on an invalid layout value", async () => { + const client = labelClient(); + await expect( + fetchLabels(ctx(client), { project: "group/repo", layout: "grid" }), + ).rejects.toThrow(/layout/); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 59d7bbf..a53ad9b 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -6,6 +6,7 @@ import type { TocEntry, TocMode } from "./toc.js"; import type { FileData, IssueData, + LabelData, ProjectInfoData, ReadmeData, ReleaseData, @@ -233,6 +234,56 @@ export async function fetchTopics(ctx: GitLabContext, attrs: Attrs): Promise "layout" must be "list" or "cards"; ` + + `got ${JSON.stringify(value)}.`, + ); +} + +export async function fetchLabels(ctx: GitLabContext, attrs: Attrs): Promise { + const project = attrs.project as string | number | undefined; + const group = attrs.group as string | number | undefined; + if ((project === undefined) === (group === undefined)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: requires exactly one of "project" or "group".`, + ); + } + readLayout(attrs.layout); // validate only; layout is presentational (read by the component) + const order = readOrder(attrs.order); + const match = compileFilter(attrs.filter); + const limit = typeof attrs.limit === "number" ? attrs.limit : undefined; + const scopeKey = project !== undefined ? `p:${String(project)}` : `g:${String(group)}`; + const key = `labels:${scopeKey}:${String(attrs.filter ?? "")}:${order.dir}:${limit ?? "all"}`; + return memo(ctx, key, async () => { + let raw: any[]; + let base: string; + if (project !== undefined) { + raw = await ctx.client.getProjectLabels(project); + base = (await ctx.client.getProject(project)).web_url; + } else { + raw = await ctx.client.getGroupLabels(group as string | number); + base = (await ctx.client.getGroup(group as string | number)).web_url; + } + let items: LabelData[] = raw + .filter((l) => l.archived !== true) + .map((l) => ({ + name: l.name, + color: l.color, + textColor: l.text_color, + description: l.description ?? null, + webUrl: `${base}/-/issues?label_name[]=${encodeURIComponent(l.name)}`, + })); + if (match) items = items.filter((l) => match(l.name)); + items = sortByName(items, (l) => l.name, order.dir); + if (limit !== undefined) items = items.slice(0, limit); + return items; + }); +} + export async function fetchFile(ctx: GitLabContext, attrs: Attrs): Promise { const project = attrs.project as string | number; const path = String(attrs.path); From c026645eae0e0edf2467226e8fe18d53f9450b14 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:53:55 +0200 Subject: [PATCH 06/16] feat: add GitlabTopics component Co-Authored-By: Claude Sonnet 4.6 --- src/components/GitlabTopics.test.tsx | 26 ++++++++++++++++++++++++++ src/components/GitlabTopics.tsx | 20 ++++++++++++++++++++ src/components/types.ts | 1 + 3 files changed, 47 insertions(+) create mode 100644 src/components/GitlabTopics.test.tsx create mode 100644 src/components/GitlabTopics.tsx diff --git a/src/components/GitlabTopics.test.tsx b/src/components/GitlabTopics.test.tsx new file mode 100644 index 0000000..4bd4791 --- /dev/null +++ b/src/components/GitlabTopics.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabTopics } from "./GitlabTopics"; + +const topics = [ + { name: "docs", title: "Docs", totalProjectsCount: 3, webUrl: "https://x/explore/projects/topics/docs" }, +]; + +describe("GitlabTopics", () => { + it("renders each topic as a link with its project-count bubble", () => { + render(); + const link = screen.getByRole("link", { name: /Docs/ }); + expect(link).toHaveAttribute("href", "https://x/explore/projects/topics/docs"); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/GitlabTopics.tsx b/src/components/GitlabTopics.tsx new file mode 100644 index 0000000..cc7f7ca --- /dev/null +++ b/src/components/GitlabTopics.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, TopicData } from "./types.js"; + +export function GitlabTopics({ data, error }: ComponentPayload) { + if (error) return ; + if (!data) return null; + return ( + + ); +} diff --git a/src/components/types.ts b/src/components/types.ts index cd029c2..fa0fdbc 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -4,6 +4,7 @@ export type { IssueData, ReadmeData, FileData, + TopicData, FetchError, ComponentPayload, } from "../gitlab/types.js"; From 8319e556c5f3e6b6bfc10fde040983a32b77268b Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:55:44 +0200 Subject: [PATCH 07/16] feat: add GitlabLabels component with list and cards layouts --- src/components/GitlabLabels.test.tsx | 37 +++++++++++++++++++++++ src/components/GitlabLabels.tsx | 45 ++++++++++++++++++++++++++++ src/components/types.ts | 1 + 3 files changed, 83 insertions(+) create mode 100644 src/components/GitlabLabels.test.tsx create mode 100644 src/components/GitlabLabels.tsx diff --git a/src/components/GitlabLabels.test.tsx b/src/components/GitlabLabels.test.tsx new file mode 100644 index 0000000..3f6544b --- /dev/null +++ b/src/components/GitlabLabels.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabLabels } from "./GitlabLabels"; + +const labels = [ + { name: "bug", color: "#d9534f", textColor: "#ffffff", description: "Defect", webUrl: "https://x/g/r/-/issues?label_name[]=bug" }, + { name: "feature", color: "#5cb85c", textColor: "#1a1a1a", description: null, webUrl: "https://x/g/r/-/issues?label_name[]=feature" }, +]; + +describe("GitlabLabels", () => { + it("defaults to the list layout: colored badge links, name only", () => { + render(); + const link = screen.getByRole("link", { name: "bug" }); + expect(link).toHaveAttribute("href", "https://x/g/r/-/issues?label_name[]=bug"); + expect(link).toHaveStyle({ backgroundColor: "#d9534f", color: "#ffffff" }); + expect(screen.queryByText("Defect")).not.toBeInTheDocument(); + }); + + it("renders description text in the cards layout", () => { + render(); + expect(screen.getByRole("link", { name: /bug/ })).toHaveAttribute( + "href", + "https://x/g/r/-/issues?label_name[]=bug", + ); + expect(screen.getByText("Defect")).toBeInTheDocument(); + }); + + it("renders the fallback on error", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("boom"); + }); + + it("renders nothing when there is no data", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/GitlabLabels.tsx b/src/components/GitlabLabels.tsx new file mode 100644 index 0000000..8fc9239 --- /dev/null +++ b/src/components/GitlabLabels.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, LabelData } from "./types.js"; + +interface GitlabLabelsProps extends ComponentPayload { + layout?: "list" | "cards"; +} + +export function GitlabLabels({ data, error, layout = "list" }: GitlabLabelsProps) { + if (error) return ; + if (!data) return null; + if (layout === "cards") { + return ( + + ); + } + return ( + + ); +} diff --git a/src/components/types.ts b/src/components/types.ts index fa0fdbc..d2e1810 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -5,6 +5,7 @@ export type { ReadmeData, FileData, TopicData, + LabelData, FetchError, ComponentPayload, } from "../gitlab/types.js"; From ad369433cde7eddb9512fa60eabcf59bed6f911c Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 19:59:12 +0200 Subject: [PATCH 08/16] feat: register GitlabTopics/GitlabLabels and export their types Also update the remark index test mock to include fetchTopics and fetchLabels so the module mock stays complete after registry expansion. Co-Authored-By: Claude Sonnet 4.6 --- src/components/index.ts | 4 ++++ src/index.ts | 2 ++ src/remark/index.test.ts | 2 ++ src/remark/registry.ts | 4 ++++ 4 files changed, 12 insertions(+) diff --git a/src/components/index.ts b/src/components/index.ts index 6c89749..6d22142 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,12 +3,16 @@ export { GitlabReadme } from "./GitlabReadme.js"; export { GitlabReleases } from "./GitlabReleases.js"; export { GitlabIssues } from "./GitlabIssues.js"; export { GitlabFile } from "./GitlabFile.js"; +export { GitlabTopics } from "./GitlabTopics.js"; +export { GitlabLabels } from "./GitlabLabels.js"; export type { ProjectInfoData, ReleaseData, IssueData, ReadmeData, FileData, + TopicData, + LabelData, FetchError, ComponentPayload, } from "./types.js"; diff --git a/src/index.ts b/src/index.ts index b738f30..c832fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,7 @@ export type { IssueData, ReadmeData, FileData, + TopicData, + LabelData, FetchError, } from "./gitlab/types.js"; diff --git a/src/remark/index.test.ts b/src/remark/index.test.ts index afa4ee9..889bb0c 100644 --- a/src/remark/index.test.ts +++ b/src/remark/index.test.ts @@ -13,6 +13,8 @@ vi.mock("../gitlab/fetchers.js", () => ({ throw new Error("api down"); }), fetchFile: vi.fn(), + fetchTopics: vi.fn(), + fetchLabels: vi.fn(), })); function processor(opts: any) { diff --git a/src/remark/registry.ts b/src/remark/registry.ts index 89f58a9..fa0059c 100644 --- a/src/remark/registry.ts +++ b/src/remark/registry.ts @@ -4,6 +4,8 @@ import { fetchReleases, fetchIssues, fetchFile, + fetchTopics, + fetchLabels, type GitLabContext, } from "../gitlab/fetchers.js"; @@ -15,4 +17,6 @@ export const COMPONENT_REGISTRY: Record = { GitlabReleases: fetchReleases, GitlabIssues: fetchIssues, GitlabFile: fetchFile, + GitlabTopics: fetchTopics, + GitlabLabels: fetchLabels, }; From 4489debd8c81ad3452b222cc4a593af6e8f7a5c4 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 20:01:06 +0200 Subject: [PATCH 09/16] docs: document GitlabTopics and GitlabLabels Co-Authored-By: Claude Sonnet 4.6 --- README.md | 31 +++++++++++++++++++++ examples/site/docs/components/labels.mdx | 35 ++++++++++++++++++++++++ examples/site/docs/components/topics.mdx | 31 +++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 examples/site/docs/components/labels.mdx create mode 100644 examples/site/docs/components/topics.mdx diff --git a/README.md b/README.md index 5dfa6e3..9f52a55 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,37 @@ syntax-highlighted code block (via `prism-react-renderer`). | `ref` | string | default branch | Branch, tag, or commit SHA | | `lines` | string | whole file | Line range for code files, e.g. `"10-25"` (1-based, inclusive) | +### `` + +The instance topic catalog as links, each with a project-count bubble. + +```mdx + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `filter` | string | — | Case-insensitive regex on the topic title | +| `order` | string | `name` | `name`, `name:asc`, or `name:desc` | +| `limit` | number | all | Max topics to show | + +### `` + +A project's or group's labels as links to the filtered issues list. `list` or `cards` layout. + +```mdx + +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `project` | string \| number | — | Provide either `project` or `group` | +| `group` | string \| number | — | Provide either `project` or `group` | +| `layout` | string | `list` | `list` or `cards` | +| `filter` | string | — | Case-insensitive regex on the label name | +| `order` | string | `name` | `name`, `name:asc`, or `name:desc` | +| `limit` | number | all | Max labels to show | + ## Plugin options | Option | Type | Default | Description | diff --git a/examples/site/docs/components/labels.mdx b/examples/site/docs/components/labels.mdx new file mode 100644 index 0000000..ded9e48 --- /dev/null +++ b/examples/site/docs/components/labels.mdx @@ -0,0 +1,35 @@ +--- +title: GitlabLabels +sidebar_position: 8 +--- + +# `` + +Renders the labels of a project **or** a group as a list of links. Each label links +to the issues list filtered by that label and keeps its GitLab color. Two layouts are +available. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `project` | `string \| number` | — | Project path or numeric ID. Provide **either** this or `group`. | +| `group` | `string \| number` | — | Group path or numeric ID. Provide **either** this or `project`. | +| `layout` | `string` | `list` | `list` (colored badges) or `cards` (badge + description). | +| `filter` | `string` | — | Case-insensitive regular expression matched against the label name. | +| `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | +| `limit` | `number` | all | Maximum number of labels to show (applied after filter + sort). | + +## Notes + +- Exactly one of `project` / `group` is required; providing both or neither fails the build. +- Each label links to `/-/issues?label_name[]=`. +- Archived labels are omitted. diff --git a/examples/site/docs/components/topics.mdx b/examples/site/docs/components/topics.mdx new file mode 100644 index 0000000..1e934b6 --- /dev/null +++ b/examples/site/docs/components/topics.mdx @@ -0,0 +1,31 @@ +--- +title: GitlabTopics +sidebar_position: 7 +--- + +# `` + +Renders the GitLab instance's topic catalog as a list of links. Each topic links to +its projects-by-topic explore page and shows a bubble with the number of projects +using it. Topics are instance-wide, so this component takes no `project`/`group`. + +## Usage + +```mdx + + + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `filter` | `string` | — | Case-insensitive regular expression matched against the topic title. | +| `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | +| `limit` | `number` | all | Maximum number of topics to show (applied after filter + sort). | + +## Notes + +- Each topic links to `/explore/projects/topics/`. +- The count bubble is the topic's `total_projects_count`. From 2ea1f1dfd1037cdc394e7860382fb5a84a37dda8 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 20:03:46 +0200 Subject: [PATCH 10/16] test: e2e coverage for GitlabTopics and GitlabLabels Add stub endpoints for /topics, /labels (project + group) to the GitLab stub server, add live component usage in the example site's intro.mdx, and assert that topics and label HTML is baked into the static build output. Co-Authored-By: Claude Sonnet 4.6 --- examples/site/docs/intro.mdx | 10 ++++++++++ test/e2e/build.test.ts | 13 +++++++++++++ test/e2e/fixtures.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/examples/site/docs/intro.mdx b/examples/site/docs/intro.mdx index 90d01c5..7c2a74a 100644 --- a/examples/site/docs/intro.mdx +++ b/examples/site/docs/intro.mdx @@ -15,3 +15,13 @@ Some intro text. + +## Topics + + + +## Labels + + + + diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index b018e3e..99890ef 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -80,4 +80,17 @@ describe("e2e: docusaurus build", () => { // ...and the README headings come AFTER the page heading that precedes the component. expect(html.indexOf('href="#overview"')).toBeLessThan(html.indexOf('href="#install"')); }); + + it("bakes topics and labels into the static html", () => { + const html = readFileSync(join(siteDir, "build", "index.html"), "utf8"); + // topic explore link + count bubble (robust against Docusaurus's "Docs" navbar label) + expect(html).toContain("/explore/projects/topics/docs"); + expect(html).toContain("gitlab-count-bubble"); + // project label (cards layout) with its description and issues link + expect(html).toContain("gitlab-label-card"); + expect(html).toContain("label_name[]=bug"); + expect(html).toContain("New capability"); + // group label with the group issues link + expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); + }); }); diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 59ec184..6f4edb2 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -37,6 +37,26 @@ export async function startGitlabStub(): Promise<{ url: string; stop: () => Prom author: { name: "Ann", web_url: "https://x/ann" }, created_at: "2026-01-01T00:00:00Z" }, ]); } + if (url.startsWith("/api/v4/projects/group%2Frepo/labels")) { + return send([ + { name: "bug", color: "#d9534f", text_color: "#ffffff", description: "Defect", archived: false }, + { name: "feature", color: "#5cb85c", text_color: "#1a1a1a", description: "New capability", archived: false }, + ]); + } + if (url.startsWith("/api/v4/topics")) { + return send([ + { name: "docs", title: "Docs", total_projects_count: 4 }, + { name: "api", title: "API", total_projects_count: 9 }, + ]); + } + if (url.startsWith("/api/v4/groups/my-group/labels")) { + return send([ + { name: "epic", color: "#8e44ad", text_color: "#ffffff", description: "Cross-project", archived: false }, + ]); + } + if (url.startsWith("/api/v4/groups/my-group")) { + return send({ id: 42, web_url: "https://x/groups/my-group" }); + } if (url.includes("/repository/files/README.md/raw")) { return send( "# Hello\n\nReadme body.\n\n## Install\n\nsetup\n\n## Usage\n\ngo\n\n![logo](./logo.png)", From 44597b3bdb01ca771958d92f8783e443a57ebaf9 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 1 Jul 2026 20:30:11 +0200 Subject: [PATCH 11/16] feat: cap topic/label fetch at 500 items, fewer pages for small limits Default pagination is perPage 100 / maxPages 5 (500-item security ceiling). When a limit is set without a name filter, reduce maxPages to just cover it; a filter forces the full ceiling since matches can land on any page. Co-Authored-By: Claude Opus 4.8 --- src/gitlab/client.test.ts | 19 +++++++++++++------ src/gitlab/client.ts | 30 ++++++++++++++++++++++++------ src/gitlab/fetchers.test.ts | 34 ++++++++++++++++++++++++++++++++-- src/gitlab/fetchers.ts | 25 +++++++++++++++++++++---- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index 64fcfb1..c575a6e 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -132,28 +132,35 @@ describe("GitLabClient", () => { await expect(c.requestBinary("https://gitlab.com/x.png")).rejects.toThrow(/404/); }); - it("getTopics delegates to Topics.all with a 100-per-page request", async () => { + it("getTopics defaults to 100 per page capped at 5 pages (500 max)", async () => { topicsAllMock.mockResolvedValue([{ name: "docs", total_projects_count: 3 }]); const c = new GitLabClient({ host: "https://gitlab.com" }); const data = await c.getTopics(); expect(data).toEqual([{ name: "docs", total_projects_count: 3 }]); - expect(topicsAllMock).toHaveBeenCalledWith({ perPage: 100 }); + expect(topicsAllMock).toHaveBeenCalledWith({ perPage: 100, maxPages: 5 }); }); - it("getProjectLabels delegates to ProjectLabels.all", async () => { + it("getTopics forwards caller pagination overrides", async () => { + topicsAllMock.mockResolvedValue([]); + const c = new GitLabClient({ host: "https://gitlab.com" }); + await c.getTopics({ perPage: 100, maxPages: 2 }); + expect(topicsAllMock).toHaveBeenCalledWith({ perPage: 100, maxPages: 2 }); + }); + + it("getProjectLabels delegates to ProjectLabels.all with the default 500 cap", async () => { projectLabelsAllMock.mockResolvedValue([{ name: "bug" }]); const c = new GitLabClient({ host: "https://gitlab.com" }); const data = await c.getProjectLabels("group/repo"); expect(data).toEqual([{ name: "bug" }]); - expect(projectLabelsAllMock).toHaveBeenCalledWith("group/repo"); + expect(projectLabelsAllMock).toHaveBeenCalledWith("group/repo", { perPage: 100, maxPages: 5 }); }); - it("getGroupLabels delegates to GroupLabels.all", async () => { + it("getGroupLabels delegates to GroupLabels.all with the default 500 cap", async () => { groupLabelsAllMock.mockResolvedValue([{ name: "epic" }]); const c = new GitLabClient({ host: "https://gitlab.com" }); const data = await c.getGroupLabels("my-group"); expect(data).toEqual([{ name: "epic" }]); - expect(groupLabelsAllMock).toHaveBeenCalledWith("my-group"); + expect(groupLabelsAllMock).toHaveBeenCalledWith("my-group", { perPage: 100, maxPages: 5 }); }); it("getGroup delegates to Groups.show", async () => { diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index 8410bd4..2d72720 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -18,6 +18,15 @@ export interface IssuesQuery { limit: number; } +/** Pagination for the topic/label list endpoints. Defaults cap the fetch at 500 items. */ +export interface PageOptions { + perPage?: number; + maxPages?: number; +} + +const DEFAULT_PER_PAGE = 100; +const DEFAULT_MAX_PAGES = 5; // 5 * 100 = 500 item ceiling + export class GitLabClient { private readonly api: InstanceType; @@ -53,16 +62,25 @@ export class GitLabClient { return typeof raw === "string" ? raw : await raw.text(); } - async getTopics(): Promise { - return this.api.Topics.all({ perPage: 100 }); + async getTopics(opts: PageOptions = {}): Promise { + return this.api.Topics.all({ + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); } - async getProjectLabels(project: ProjectRef): Promise { - return this.api.ProjectLabels.all(project); + async getProjectLabels(project: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.ProjectLabels.all(project, { + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); } - async getGroupLabels(group: ProjectRef): Promise { - return this.api.GroupLabels.all(group); + async getGroupLabels(group: ProjectRef, opts: PageOptions = {}): Promise { + return this.api.GroupLabels.all(group, { + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + }); } async getGroup(group: ProjectRef): Promise { diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 26896fc..bf25c6d 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -300,6 +300,24 @@ describe("fetchTopics", () => { const client = { getTopics: vi.fn(async () => raw) }; await expect(fetchTopics(ctx(client), { filter: "(" })).rejects.toThrow(/filter/); }); + + it("caps the fetch at 500 items (100 per page, 5 pages) by default", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await fetchTopics(ctx(client), {}); + expect(client.getTopics).toHaveBeenCalledWith({ perPage: 100, maxPages: 5 }); + }); + + it("fetches fewer pages when a small limit is set and there is no filter", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await fetchTopics(ctx(client), { limit: 150 }); + expect(client.getTopics).toHaveBeenCalledWith({ perPage: 100, maxPages: 2 }); + }); + + it("keeps the full 500-item cap when a filter is set even with a small limit", async () => { + const client = { getTopics: vi.fn(async () => raw) }; + await fetchTopics(ctx(client), { limit: 5, filter: "a" }); + expect(client.getTopics).toHaveBeenCalledWith({ perPage: 100, maxPages: 5 }); + }); }); describe("fetchLabels", () => { @@ -329,18 +347,30 @@ describe("fetchLabels", () => { description: "Defect", webUrl: "https://gitlab.com/group/repo/-/issues?label_name[]=bug", }); - expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo"); + expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo", { perPage: 100, maxPages: 5 }); expect(client.getGroupLabels).not.toHaveBeenCalled(); }); it("uses the group endpoints and group issues link for group scope", async () => { const client = labelClient(); const data = await fetchLabels(ctx(client), { group: "my-group" }); - expect(client.getGroupLabels).toHaveBeenCalledWith("my-group"); + expect(client.getGroupLabels).toHaveBeenCalledWith("my-group", { perPage: 100, maxPages: 5 }); expect(client.getProjectLabels).not.toHaveBeenCalled(); expect(data[0].webUrl).toBe("https://gitlab.com/groups/my-group/-/issues?label_name[]=bug"); }); + it("fetches fewer pages when a small limit is set and there is no filter", async () => { + const client = labelClient(); + await fetchLabels(ctx(client), { project: "group/repo", limit: 10 }); + expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo", { perPage: 100, maxPages: 1 }); + }); + + it("keeps the full 500-item cap when a filter is set even with a small limit", async () => { + const client = labelClient(); + await fetchLabels(ctx(client), { project: "group/repo", limit: 10, filter: "bug" }); + expect(client.getProjectLabels).toHaveBeenCalledWith("group/repo", { perPage: 100, maxPages: 5 }); + }); + it("filters by case-insensitive regex on the name", async () => { const client = labelClient(); const data = await fetchLabels(ctx(client), { project: "group/repo", filter: "^feat" }); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index a53ad9b..bbebe99 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -1,6 +1,6 @@ import type { AssetManager } from "./assets"; import type { FileCache } from "./cache"; -import type { GitLabClient } from "./client"; +import type { GitLabClient, PageOptions } from "./client"; import { renderMarkdown } from "./markdown"; import type { TocEntry, TocMode } from "./toc.js"; import type { @@ -213,6 +213,22 @@ function languageFromPath(path: string): string { return LANGUAGE_BY_EXTENSION[ext] ?? ext ?? "text"; } +const PAGE_SIZE = 100; +const MAX_PAGES = 5; // hard ceiling: 5 * 100 = 500 topics/labels fetched + +/** + * Bound the fetch to 500 items. When the component sets `limit` and there is no + * name filter, fetch only enough pages to satisfy it; a filter forces the full + * ceiling because a match can land on any page. + */ +function pageOptions(limit: number | undefined, hasFilter: boolean): PageOptions { + const maxPages = + limit !== undefined && !hasFilter + ? Math.min(MAX_PAGES, Math.max(1, Math.ceil(limit / PAGE_SIZE))) + : MAX_PAGES; + return { perPage: PAGE_SIZE, maxPages }; +} + export async function fetchTopics(ctx: GitLabContext, attrs: Attrs): Promise { const order = readOrder(attrs.order); const match = compileFilter(attrs.filter); @@ -220,7 +236,7 @@ export async function fetchTopics(ctx: GitLabContext, attrs: Attrs): Promise { - const raw = await ctx.client.getTopics(); + const raw = await ctx.client.getTopics(pageOptions(limit, match !== null)); let items: TopicData[] = raw.map((t: any) => ({ name: t.name, title: t.title ?? t.name, @@ -261,11 +277,12 @@ export async function fetchLabels(ctx: GitLabContext, attrs: Attrs): Promise { let raw: any[]; let base: string; + const pages = pageOptions(limit, match !== null); if (project !== undefined) { - raw = await ctx.client.getProjectLabels(project); + raw = await ctx.client.getProjectLabels(project, pages); base = (await ctx.client.getProject(project)).web_url; } else { - raw = await ctx.client.getGroupLabels(group as string | number); + raw = await ctx.client.getGroupLabels(group as string | number, pages); base = (await ctx.client.getGroup(group as string | number)).web_url; } let items: LabelData[] = raw From 6e7809d13ce6f76d30a04af081d7867dc97477ca Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 3 Jul 2026 07:50:22 +0200 Subject: [PATCH 12/16] chore: edit exqmples --- examples/site/docs/components/labels.mdx | 22 ++++++++++++++++++++++ examples/site/docs/components/topics.mdx | 6 ++++++ examples/site/docs/intro.mdx | 2 +- examples/site/docusaurus.config.ts | 4 +++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/examples/site/docs/components/labels.mdx b/examples/site/docs/components/labels.mdx index ded9e48..05c9975 100644 --- a/examples/site/docs/components/labels.mdx +++ b/examples/site/docs/components/labels.mdx @@ -15,6 +15,8 @@ available. + + ``` ## Props @@ -28,6 +30,26 @@ available. | `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | | `limit` | `number` | all | Maximum number of labels to show (applied after filter + sort). | +## Cards layout + +These props only affect the `cards` layout and tune the responsive grid: + +| Prop | Type | Default | Description | +|---|---|---|---| +| `cardColumns` | `number` | — | Fixed number of columns. Takes precedence over `cardMinWidth`. | +| `cardMinWidth` | `string` | `180px` | Minimum card width for a responsive (auto-fill) grid, e.g. `"220px"`. Ignored when `cardColumns` is set. | +| `gap` | `string` | `0.75rem` | Spacing between cards, e.g. `"1.5rem"`. | +| `maxWidth` | `string` | — | Constrain the grid width, e.g. `"900px"`. | +| `align` | `string` | `start` | `start` or `center` — horizontal placement of a width-constrained grid. | + +## Scoped labels + +[Scoped labels](https://docs.gitlab.com/ee/user/project/labels.html#scoped-labels) +(`scope::value`, e.g. `Abilities::Performance`) render as a two-part badge: the +**scope** keeps the label's GitLab color, and the **value** is shown next to it on a +dark-gray background. The split happens on the last `::`, so nested scopes like +`priority::severity::high` become scope `priority::severity` / value `high`. + ## Notes - Exactly one of `project` / `group` is required; providing both or neither fails the build. diff --git a/examples/site/docs/components/topics.mdx b/examples/site/docs/components/topics.mdx index 1e934b6..d119969 100644 --- a/examples/site/docs/components/topics.mdx +++ b/examples/site/docs/components/topics.mdx @@ -25,6 +25,12 @@ using it. Topics are instance-wide, so this component takes no `project`/`group` | `order` | `string` | `name` | `name` / `name:asc` / `name:desc`. | | `limit` | `number` | all | Maximum number of topics to show (applied after filter + sort). | +## Scoped topics + +A topic whose title uses the `scope::value` form (e.g. `team::backend`) renders as a +two-part badge: the **scope** keeps the default badge background and the **value** is +shown next to it on a dark-gray background. The split happens on the last `::`. + ## Notes - Each topic links to `/explore/projects/topics/`. diff --git a/examples/site/docs/intro.mdx b/examples/site/docs/intro.mdx index 7c2a74a..0d2df76 100644 --- a/examples/site/docs/intro.mdx +++ b/examples/site/docs/intro.mdx @@ -22,6 +22,6 @@ Some intro text. ## Labels - + diff --git a/examples/site/docusaurus.config.ts b/examples/site/docusaurus.config.ts index eed9893..90b727d 100644 --- a/examples/site/docusaurus.config.ts +++ b/examples/site/docusaurus.config.ts @@ -27,7 +27,9 @@ const config: Config = { remarkPlugins: [remarkGemoji, [remarkGitlab, gitlabOptions]], }, blog: false, - theme: {}, + theme: { + customCss: require.resolve("@ebuildy/docusaurus-plugin-gitlab/theme.css"), + }, }, ], ], From ee69d7fef51b73d020c9e4fd5f2ea9206b8f4f20 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 3 Jul 2026 07:51:46 +0200 Subject: [PATCH 13/16] fix: add .js extension to markdown import in fetchers The value import of renderMarkdown lacked the explicit .js extension required by the package's ESM (moduleResolution: Bundler) setup, so the compiled dist/gitlab/fetchers.js failed to resolve ./markdown at runtime and broke the Docusaurus build (ERR_MODULE_NOT_FOUND). Co-Authored-By: Claude Opus 4.8 --- src/gitlab/fetchers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index 03c48b0..792ea3f 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -1,7 +1,7 @@ import type { AssetManager } from "./assets"; import type { FileCache } from "./cache"; import type { GitLabClient, PageOptions } from "./client"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown } from "./markdown.js"; import type { TocEntry, TocMode } from "./toc.js"; import type { FileData, From 1be37481d27f08216522ec8e07caccd7d077bb67 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 3 Jul 2026 07:51:57 +0200 Subject: [PATCH 14/16] feat: scoped labels/topics rendering + configurable label card grid Render GitLab scoped labels/topics ('scope::value', e.g. Abilities::Performance) as a two-part badge: the scope keeps its color, the value gets a dark-gray treatment, and the whole pill is framed with a border in the scope color. Split is on the last '::' to match GitLab semantics (parseScopedLabel helper). Reuse the shared .gitlab-card styling (border + shadow + hover) for the labels cards layout, and make that grid configurable via a generic, reusable ComponentLayout interface: cardColumns, cardMinWidth, gap, maxWidth, align (cardsGridStyle builds the inline grid style; cardColumns wins over cardMinWidth). Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++ src/components/GitlabLabels.test.tsx | 53 ++++++++++++++++++++-- src/components/GitlabLabels.tsx | 68 +++++++++++++++++++++++----- src/components/GitlabTopics.test.tsx | 11 +++++ src/components/GitlabTopics.tsx | 38 ++++++++++++---- src/components/index.ts | 1 + src/components/layout.test.ts | 35 ++++++++++++++ src/components/layout.ts | 42 +++++++++++++++++ src/components/scopedLabel.test.ts | 34 ++++++++++++++ src/components/scopedLabel.ts | 21 +++++++++ theme.css | 60 ++++++++++++++++++++++++ 11 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 src/components/layout.test.ts create mode 100644 src/components/layout.ts create mode 100644 src/components/scopedLabel.test.ts create mode 100644 src/components/scopedLabel.ts diff --git a/README.md b/README.md index cfff8b9..0eeb9d3 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,14 @@ A project's or group's labels as links to the filtered issues list. `list` or `c | `order` | string | `name` | `name`, `name:asc`, or `name:desc` | | `limit` | number | all | Max labels to show | +The `cards` layout accepts grid props: `cardColumns` (fixed column count), `cardMinWidth` +(responsive min width, ignored when `cardColumns` is set), `gap`, `maxWidth`, and +`align` (`start`/`center`). + +Both components render [scoped labels/topics](https://docs.gitlab.com/ee/user/project/labels.html#scoped-labels) +(`scope::value`, e.g. `Abilities::Performance`) as a two-part badge — the scope keeps its +color and the value gets a dark-gray background. The split is on the last `::`. + ## Plugin options | Option | Type | Default | Description | diff --git a/src/components/GitlabLabels.test.tsx b/src/components/GitlabLabels.test.tsx index 3f6544b..ce1cf0e 100644 --- a/src/components/GitlabLabels.test.tsx +++ b/src/components/GitlabLabels.test.tsx @@ -18,13 +18,58 @@ describe("GitlabLabels", () => { it("renders description text in the cards layout", () => { render(); - expect(screen.getByRole("link", { name: /bug/ })).toHaveAttribute( - "href", - "https://x/g/r/-/issues?label_name[]=bug", - ); + const card = screen.getByRole("link", { name: /bug/ }); + expect(card).toHaveAttribute("href", "https://x/g/r/-/issues?label_name[]=bug"); + // reuse the shared project-card styling (border + shadow + hover) + expect(card).toHaveClass("gitlab-card"); expect(screen.getByText("Defect")).toBeInTheDocument(); }); + it("renders a scoped label as two separated segments: colored scope, gray value", () => { + const scoped = [ + { name: "Abilities::Performance", color: "#428bca", textColor: "#ffffff", description: null, webUrl: "https://x/g/r/-/issues?label_name[]=Abilities::Performance" }, + ]; + render(); + const link = screen.getByRole("link"); + const scope = screen.getByText("Abilities"); + const value = screen.getByText("Performance"); + expect(scope).toHaveClass("gitlab-label-scope"); + expect(scope).toHaveStyle({ backgroundColor: "#428bca", color: "#ffffff" }); + expect(value).toHaveClass("gitlab-label-value"); + // the value must not carry the label color — it gets the gray treatment via CSS + expect(link).not.toHaveStyle({ backgroundColor: "#428bca" }); + // the whole badge is bordered with the scope color to tie the segments together + expect(link).toHaveStyle({ borderColor: "#428bca" }); + }); + + it("lays the cards grid into a fixed number of columns via cardColumns", () => { + const { container } = render(); + const grid = container.querySelector(".gitlab-label-cards"); + expect(grid).toHaveStyle({ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }); + }); + + it("makes the grid responsive with cardMinWidth when no fixed column count is given", () => { + const { container } = render(); + const grid = container.querySelector(".gitlab-label-cards"); + expect(grid).toHaveStyle({ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }); + }); + + it("lets cardColumns win over cardMinWidth when both are given", () => { + const { container } = render( + , + ); + const grid = container.querySelector(".gitlab-label-cards"); + expect(grid).toHaveStyle({ gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }); + }); + + it("applies gap, maxWidth and centers the grid via align", () => { + const { container } = render( + , + ); + const grid = container.querySelector(".gitlab-label-cards"); + expect(grid).toHaveStyle({ gap: "1.5rem", maxWidth: "900px", marginLeft: "auto", marginRight: "auto" }); + }); + it("renders the fallback on error", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("boom"); diff --git a/src/components/GitlabLabels.tsx b/src/components/GitlabLabels.tsx index 8fc9239..5971288 100644 --- a/src/components/GitlabLabels.tsx +++ b/src/components/GitlabLabels.tsx @@ -1,24 +1,68 @@ import React from "react"; import { Fallback } from "./Fallback.js"; +import { cardsGridStyle, type ComponentLayout } from "./layout.js"; +import { parseScopedLabel } from "./scopedLabel.js"; import type { ComponentPayload, LabelData } from "./types.js"; -interface GitlabLabelsProps extends ComponentPayload { +interface GitlabLabelsProps extends ComponentPayload, ComponentLayout { layout?: "list" | "cards"; } -export function GitlabLabels({ data, error, layout = "list" }: GitlabLabelsProps) { +/** + * Render the inner content of a label badge. GitLab scoped labels + * ("scope::value") render as two segments: the scope keeps the label color, + * the value gets a dark-gray treatment (see `.gitlab-label-value` in theme.css). + */ +function LabelContent({ label }: { label: LabelData }) { + const scoped = parseScopedLabel(label.name); + if (!scoped) return <>{label.name}; + return ( + <> + + {scoped.scope} + + {scoped.value} + + ); +} + +function badgeStyle(label: LabelData): React.CSSProperties { + // A scoped label colors its segments individually and is framed with a border + // in the scope color; a plain label carries the color on the badge as before. + if (parseScopedLabel(label.name)) return { borderColor: label.color }; + return { backgroundColor: label.color, color: label.textColor }; +} + +function badgeClassName(label: LabelData): string { + const base = "gitlab-badge gitlab-label"; + return parseScopedLabel(label.name) ? `${base} gitlab-label--scoped` : base; +} + +export function GitlabLabels({ + data, + error, + layout = "list", + cardColumns, + cardMinWidth, + gap, + maxWidth, + align, +}: GitlabLabelsProps) { if (error) return ; if (!data) return null; if (layout === "cards") { return ( -
+
{data.map((l) => ( - - - {l.name} + + + {l.description &&

{l.description}

}
@@ -31,12 +75,12 @@ export function GitlabLabels({ data, error, layout = "list" }: GitlabLabelsProps {data.map((l) => (
  • - {l.name} +
  • ))} diff --git a/src/components/GitlabTopics.test.tsx b/src/components/GitlabTopics.test.tsx index 4bd4791..64c9f33 100644 --- a/src/components/GitlabTopics.test.tsx +++ b/src/components/GitlabTopics.test.tsx @@ -14,6 +14,17 @@ describe("GitlabTopics", () => { expect(screen.getByText("3")).toBeInTheDocument(); }); + it("renders a scoped topic as two separated segments plus its count bubble", () => { + const scoped = [ + { name: "team::backend", title: "team::backend", totalProjectsCount: 7, webUrl: "https://x/explore/projects/topics/team::backend" }, + ]; + render(); + expect(screen.getByRole("link")).toHaveClass("gitlab-topic--scoped"); + expect(screen.getByText("team")).toHaveClass("gitlab-label-scope"); + expect(screen.getByText("backend")).toHaveClass("gitlab-label-value"); + expect(screen.getByText("7")).toBeInTheDocument(); + }); + it("renders the fallback on error", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("boom"); diff --git a/src/components/GitlabTopics.tsx b/src/components/GitlabTopics.tsx index cc7f7ca..3c3b7a5 100644 --- a/src/components/GitlabTopics.tsx +++ b/src/components/GitlabTopics.tsx @@ -1,20 +1,42 @@ import React from "react"; import { Fallback } from "./Fallback.js"; +import { parseScopedLabel } from "./scopedLabel.js"; import type { ComponentPayload, TopicData } from "./types.js"; +/** + * Render the label of a topic badge. A scoped topic ("scope::value") splits + * into two segments — the scope keeps the default badge background, the value + * gets the dark-gray treatment (see `.gitlab-label-value` in theme.css). + */ +function TopicContent({ topic }: { topic: TopicData }) { + const scoped = parseScopedLabel(topic.title); + if (!scoped) return <>{topic.title}; + return ( + <> + {scoped.scope} + {scoped.value} + + ); +} + export function GitlabTopics({ data, error }: ComponentPayload) { if (error) return ; if (!data) return null; return ( ); } diff --git a/src/components/index.ts b/src/components/index.ts index 6d22142..b9ad787 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { GitlabIssues } from "./GitlabIssues.js"; export { GitlabFile } from "./GitlabFile.js"; export { GitlabTopics } from "./GitlabTopics.js"; export { GitlabLabels } from "./GitlabLabels.js"; +export type { ComponentLayout } from "./layout.js"; export type { ProjectInfoData, ReleaseData, diff --git a/src/components/layout.test.ts b/src/components/layout.test.ts new file mode 100644 index 0000000..881d7ff --- /dev/null +++ b/src/components/layout.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { cardsGridStyle } from "./layout"; + +describe("cardsGridStyle", () => { + it("is empty when no layout props are given (CSS defaults apply)", () => { + expect(cardsGridStyle({})).toEqual({}); + }); + + it("lays out a fixed number of columns via cardColumns", () => { + expect(cardsGridStyle({ cardColumns: 3 })).toEqual({ + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + }); + }); + + it("builds a responsive auto-fill grid from cardMinWidth", () => { + expect(cardsGridStyle({ cardMinWidth: "220px" })).toEqual({ + gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", + }); + }); + + it("lets cardColumns win over cardMinWidth", () => { + expect(cardsGridStyle({ cardColumns: 2, cardMinWidth: "220px" })).toEqual({ + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + }); + }); + + it("passes gap and maxWidth through and centers via align", () => { + expect(cardsGridStyle({ gap: "1.5rem", maxWidth: "900px", align: "center" })).toEqual({ + gap: "1.5rem", + maxWidth: "900px", + marginLeft: "auto", + marginRight: "auto", + }); + }); +}); diff --git a/src/components/layout.ts b/src/components/layout.ts new file mode 100644 index 0000000..6f353fa --- /dev/null +++ b/src/components/layout.ts @@ -0,0 +1,42 @@ +import type { CSSProperties } from "react"; + +/** + * Generic, reusable layout props for components that render a grid of cards. + * Kept separate from any single component so other card-based components can + * `extends ComponentLayout` and share the same knobs + `cardsGridStyle`. + */ +export interface ComponentLayout { + /** Fixed number of columns for the cards grid. Wins over `cardMinWidth`. */ + cardColumns?: number; + /** Minimum card width for a responsive (auto-fill) cards grid, e.g. "220px". */ + cardMinWidth?: string; + /** Spacing between cards, e.g. "1.5rem". */ + gap?: string; + /** Constrain the cards grid width, e.g. "900px". */ + maxWidth?: string; + /** Horizontal placement of a width-constrained cards grid. */ + align?: "start" | "center"; +} + +/** Build the inline style for a cards-grid container from the layout props. */ +export function cardsGridStyle({ + cardColumns, + cardMinWidth, + gap, + maxWidth, + align, +}: ComponentLayout): CSSProperties { + const style: CSSProperties = {}; + if (cardColumns && cardColumns > 0) { + style.gridTemplateColumns = `repeat(${cardColumns}, minmax(0, 1fr))`; + } else if (cardMinWidth) { + style.gridTemplateColumns = `repeat(auto-fill, minmax(${cardMinWidth}, 1fr))`; + } + if (gap) style.gap = gap; + if (maxWidth) style.maxWidth = maxWidth; + if (align === "center") { + style.marginLeft = "auto"; + style.marginRight = "auto"; + } + return style; +} diff --git a/src/components/scopedLabel.test.ts b/src/components/scopedLabel.test.ts new file mode 100644 index 0000000..365c8b2 --- /dev/null +++ b/src/components/scopedLabel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { parseScopedLabel } from "./scopedLabel"; + +describe("parseScopedLabel", () => { + it("splits a scoped label on the double colon into scope and value", () => { + expect(parseScopedLabel("Abilities::Performance")).toEqual({ + scope: "Abilities", + value: "Performance", + }); + }); + + it("returns null for a plain (non-scoped) label", () => { + expect(parseScopedLabel("bug")).toBeNull(); + }); + + it("splits on the last double colon so the scope may itself be nested", () => { + expect(parseScopedLabel("priority::severity::high")).toEqual({ + scope: "priority::severity", + value: "high", + }); + }); + + it("keeps spaces inside the value but trims the boundaries", () => { + expect(parseScopedLabel("workflow:: in dev ")).toEqual({ + scope: "workflow", + value: "in dev", + }); + }); + + it("returns null when the scope or value is empty", () => { + expect(parseScopedLabel("scope::")).toBeNull(); + expect(parseScopedLabel("::value")).toBeNull(); + }); +}); diff --git a/src/components/scopedLabel.ts b/src/components/scopedLabel.ts new file mode 100644 index 0000000..8d22e4f --- /dev/null +++ b/src/components/scopedLabel.ts @@ -0,0 +1,21 @@ +export interface ScopedLabel { + scope: string; + value: string; +} + +/** + * Parse a GitLab scoped label/topic name (e.g. "Abilities::Performance"). + * + * GitLab treats everything before the *last* `::` as the scope and the + * remainder as the value, so nested scopes like "priority::severity::high" + * split into scope "priority::severity" / value "high". Returns `null` when + * the name is not scoped or either side is empty. + */ +export function parseScopedLabel(name: string): ScopedLabel | null { + const idx = name.lastIndexOf("::"); + if (idx === -1) return null; + const scope = name.slice(0, idx).trim(); + const value = name.slice(idx + 2).trim(); + if (!scope || !value) return null; + return { scope, value }; +} diff --git a/theme.css b/theme.css index aa97939..47fd175 100644 --- a/theme.css +++ b/theme.css @@ -58,6 +58,66 @@ font-size: 0.85em; } +/* Scoped labels/topics ("scope::value") render as two joined segments: + the scope keeps its color, the value gets a dark-gray treatment. */ +.gitlab-label--scoped, +.gitlab-topic--scoped { + display: inline-flex; + align-items: stretch; + padding: 0; + overflow: hidden; + /* framed in the scope color: set inline for labels, primary for topics */ + border: 1px solid; +} +.gitlab-topic--scoped { + border-color: var(--ifm-color-primary); +} +.gitlab-label-scope, +.gitlab-label-value { + display: inline-flex; + align-items: center; + padding: 0 0.5rem; +} +/* Scoped topics carry no per-label color, so give the scope the badge default. */ +.gitlab-topic--scoped .gitlab-label-scope { + background: var(--ifm-color-emphasis-100); + color: var(--ifm-color-primary); +} +.gitlab-label-value { + background: #4c4c4c; + color: #fff; +} +.gitlab-topic--scoped .gitlab-count-bubble { + padding: 0 0.4rem; +} + +/* Labels — cards layout: a responsive grid of project-style cards. */ +.gitlab-label-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} +/* Reuse .gitlab-card (border + shadow + hover) but reset link chrome and the + card's default block margin so it sits flush in the grid. */ +.gitlab-label-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + margin: 0; + color: inherit; + text-decoration: none; +} +.gitlab-label-card:hover { + text-decoration: none; +} +.gitlab-label-card-desc { + margin: 0; + font-size: 0.85em; + color: var(--ifm-color-emphasis-700); +} + /* Issues list */ .gitlab-issues { list-style: none; From 4d51857f49776b4fc46954666a65ea3ecf4bbe67 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 3 Jul 2026 07:53:26 +0200 Subject: [PATCH 15/16] chore: edit exqmples --- examples/gitlab/docs/taxo.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/gitlab/docs/taxo.md diff --git a/examples/gitlab/docs/taxo.md b/examples/gitlab/docs/taxo.md new file mode 100644 index 0000000..1d21904 --- /dev/null +++ b/examples/gitlab/docs/taxo.md @@ -0,0 +1,3 @@ + + + From 1fe69401133620fdda7bba014374d754e43798b8 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 3 Jul 2026 07:53:50 +0200 Subject: [PATCH 16/16] chore: git ignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 94cb80c..b21c707 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ examples/*/build/ examples/*/.docusaurus/ examples/*/node_modules/ examples/*/static/gitlab-assets/ -node_modules/.cache/ \ No newline at end of file +node_modules/.cache/ + +.env +external/ \ No newline at end of file