From d0727dd99228b60f0780a373a5ecd12b9b1db220 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 8 Jul 2026 08:22:38 +0200 Subject: [PATCH 01/22] docs: design spec for GitlabProjectInfo embedded sections Add design for opt-in releases/commits/issues sections embedded in the GitlabProjectInfo card, plus a link override. Sections are count-gated (no fetch when unset/<=0), reuse fetchReleases/fetchIssues, add new CommitData + client.getCommits, and support list/cards layouts. --- .../2026-07-08-projectinfo-sections-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md diff --git a/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md new file mode 100644 index 0000000..06f6fab --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md @@ -0,0 +1,185 @@ +# GitlabProjectInfo: embedded releases / commits / issues sections + +**Date:** 2026-07-08 +**Status:** Approved design +**Component:** `` + +## Goal + +Let authors optionally embed compact, build-time-fetched summaries of a +project's latest releases, commits, and issues directly inside the +`GitlabProjectInfo` card, and let them override the card's title link. Each +section is opt-in via a count attribute, discreet by default, and configurable +to a richer layout. + +New MDX attributes on ``: + +| Attribute | Type | Default | Effect | +|---|---|---|---| +| `releases` | number | unset | Include latest N releases. Unset/`≤0` ⇒ not fetched, not rendered. | +| `commits` | number | unset | Include latest N commits. Unset/`≤0` ⇒ not fetched, not rendered. | +| `issues` | number | unset | Include latest N issues. Unset/`≤0` ⇒ not fetched, not rendered. | +| `link` | string | `data.webUrl` | Override the card title's `href`. | +| `releasesLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer cards. | +| `commitsLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer rows. | +| `issuesLayout` | `"list"` \| `"cards"` | `"list"` | Compact one-line list vs richer cards. | + +Attribute values are static scalar literals only (string/number/boolean), per +the existing `src/remark/attributes.ts` parser — no objects or arrays. + +Example: + +```mdx + + +``` + +## Chosen approach + +**Compose existing fetchers.** `fetchProjectInfo` remains the single fetcher +registered for `GitlabProjectInfo`. When a section's count attribute is present +and `> 0`, it awaits the matching fetcher and attaches the result to +`ProjectInfoData`: + +- releases → reuse existing `fetchReleases(ctx, { project, limit })` +- issues → reuse existing `fetchIssues(ctx, { project, limit })` +- commits → new `fetchCommits(ctx, { project, limit })` + new client method + +Rejected alternatives: + +- **Inline everything** in `fetchProjectInfo` — duplicates the + snake_case→camelCase normalization already living in `fetchReleases` / + `fetchIssues`. +- **Separate injected props** — the remark plugin injects exactly one `data` + prop per JSX element, so the sub-data must be nested inside `ProjectInfoData`. + +## Data model & fetching + +### Types (`src/gitlab/types.ts`) + +New `CommitData`: + +```ts +export interface CommitData { + shortId: string; // e.g. "a79a7f7" + title: string; + webUrl: string; // link to the commit + authorName: string; + createdAt: string; // ISO; rendered as a short/relative date +} +``` + +`ProjectInfoData` gains three optional fields, populated only when the matching +count attribute is set and `> 0`: + +```ts +export interface ProjectInfoData { + // …existing fields unchanged… + releases?: ReleaseData[]; + commits?: CommitData[]; + issues?: IssueData[]; +} +``` + +`ReleaseData` and `IssueData` are reused as-is. + +### Client (`src/gitlab/client.ts`) + +New method mirroring `getReleases` / `getIssues`: + +```ts +async getCommits(project: ProjectRef, limit: number): Promise { + const commits = await this.api.Commits.all(project, { perPage: limit, maxPages: 1 }); + return commits.slice(0, limit); +} +``` + +### Fetcher (`src/gitlab/fetchers.ts`) + +- Add `fetchCommits(ctx, attrs)` normalizing gitbeaker commits + (`short_id`, `title`, `web_url`, `author_name`, `created_at`) → + `CommitData`, memoized on `commits:${project}:${limit}`. +- Extend `fetchProjectInfo` to read `releases` / `commits` / `issues` as + numbers. For each present and `> 0`, await the corresponding fetcher (passing + `limit: N`) and attach the array to the result. +- **No-fetch rule (explicit):** if a count is unset or `≤ 0`, that section's + fetcher is **not called** — no `getCommits` / releases / issues request is + made, and the field is left `undefined`. +- Extend the `projectInfo:` cache key to include the three counts + (e.g. `projectInfo:${project}:r${rN}:c${cN}:i${iN}`) so different configs do + not collide. `link` and the `*Layout` attributes are presentational and are + **not** part of the cache key. + +## Component rendering (`src/components/GitlabProjectInfo.tsx`) + +The three sections render **inside the card, immediately after the +`descriptionHtml` block**, in fixed order: **releases → commits → issues**. A +section renders only if its data array is present and non-empty. + +New presentational props read directly from MDX attributes (not from `data`): + +- `link?: string` — overrides the title `href`; falls back to `data.webUrl`. +- `releasesLayout?`, `commitsLayout?`, `issuesLayout?` — `"list"` (default) or + `"cards"`. Invalid literals throw at build time, matching the existing + `GitlabLabels` layout validation. + +Each section has a small heading label ("Releases", "Latest commits", +"Issues"). + +### Compact (`list`) lines — the discreet default + +- **Release:** `tagName` — `name`, linked. +- **Commit:** `shortId` (linked to commit) · `title` · `authorName` · short date. +- **Issue:** `#iid` `title`, linked to `webUrl`. + +### Rich (`cards`) + +Self-contained renderers inside `GitlabProjectInfo` (per the requirement that +the sections live inside this component): + +- Releases and issues render as small stacked cards. +- Commits render as richer rows (SHA + title + author + date). + +Shared per-item markup lives in tiny local helpers to avoid duplication between +the two layouts. + +## Error handling + +- Composed sub-fetches run through the existing `strict` path: in `strict` + mode a failed releases/commits/issues fetch aborts the build (current + behavior); in non-strict/dev mode the failing section is omitted and the rest + of the card still renders. +- A failure of the core project-info fetch still yields the `error` prop → + `Fallback`, unchanged. +- Invalid `*Layout` literals throw at build time. + +## Testing (TDD) + +- **Client:** `getCommits` calls `Commits.all` with `perPage`/`maxPages` and + slices to `limit` (mocked gitbeaker). +- **Fetcher:** + - `fetchProjectInfo` attaches `releases` / `commits` / `issues` only when the + count is `> 0`. + - Asserts **no** client call for a section when its count is unset or `0`. + - Cache key varies by counts. + - `fetchCommits` normalizes snake_case → camelCase. +- **Component:** + - Sections render after the description in order releases → commits → issues. + - Compact vs `cards` layouts (queries by role/text). + - `link` overrides the title `href`; default is `data.webUrl`. + - Empty/absent arrays render nothing. + - Invalid layout literal throws. + +## Documentation + +- Update `README` with the new attributes and examples. +- Update the `GitlabProjectInfo` page under + `examples/site/docs/components/`; the e2e Docusaurus build + (`test/e2e/build.test.ts`) exercises the render path. + +## Out of scope + +- No standalone `GitlabCommits` component (commits live only inside + `GitlabProjectInfo`). +- No new pagination ceiling work; each section fetches a single page bounded by + its `limit` (`maxPages: 1`), consistent with `getReleases` / `getIssues`. From fcf0b8d8d1bd3fc5904e325bad6d1e0d76836754 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 8 Jul 2026 08:32:04 +0200 Subject: [PATCH 02/22] docs: add extended project stats to GitlabProjectInfo spec Fold a second feature into the design: append commits/contributors/open-issues/ repository-size pills to the stats row (gated by showStats, best-effort, never aborts the build). Adds getContributorsCount + statistics option on getProject; drops code-lines (no GitLab LOC API). --- .../2026-07-08-projectinfo-sections-design.md | 129 +++++++++++++++++- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md index 06f6fab..06300c6 100644 --- a/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md +++ b/docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md @@ -1,9 +1,20 @@ -# GitlabProjectInfo: embedded releases / commits / issues sections +# GitlabProjectInfo: embedded sections + extended stats **Date:** 2026-07-08 **Status:** Approved design **Component:** `` +Two related enhancements to ``: + +1. **Embedded sections** — opt-in latest releases / commits / issues rendered + inside the card, plus a title-link override. +2. **Extended stats** — additional pills (commits, contributors, open issues, + repository size) appended to the existing stats row when available. + +--- + +# Feature 1 — Embedded releases / commits / issues sections + ## Goal Let authors optionally embed compact, build-time-fetched summaries of a @@ -177,9 +188,119 @@ the two layouts. `examples/site/docs/components/`; the e2e Docusaurus build (`test/e2e/build.test.ts`) exercises the render path. -## Out of scope +--- + +# Feature 2 — Extended project stats + +## Goal + +Enrich the existing `gitlab-stats` row in `GitlabProjectInfo` with more +project metrics, sourced cheaply from the GitLab API. The existing `showStats` +prop still gates the whole row; the extra pills are appended automatically +whenever their data is available. No new opt-in attributes. + +Stats added: **commits count**, **contributors count**, **open issues count**, +**repository size**. (Code-lines count was requested but is **not feasible** — +GitLab exposes no lines-of-code API; `repository_size` is bytes-on-disk, +`languages` is percentages, and contributor `additions`/`deletions` are churn, +not current LOC.) + +## API feasibility (verified against installed gitbeaker) + +| Stat | Source | Cost | +|---|---|---| +| Commits count | `statistics.commit_count` on `Projects.show(project, {statistics:true})` | Free (same call) | +| Repository size | `statistics.repository_size` (bytes) on the same call | Free (same call) | +| Open issues count | `open_issues_count` on the base project object | Free (already fetched) | +| Contributors count | `X-Total` pagination header from `Repositories.allContributors` | 1 cheap request | + +**Permission caveat:** the `statistics` object is only returned when the +build-time token has **Reporter+** access. For anonymous/public builds it is +omitted, so `commitCount` / `repositorySize` are `undefined` and their pills +are simply not rendered. + +## Data model (`src/gitlab/types.ts`) + +`ProjectInfoData` gains four optional, best-effort fields (omitted when +unavailable): + +```ts +export interface ProjectInfoData { + // …existing: starCount, forksCount, lastActivityAt… + openIssuesCount?: number; // project.open_issues_count (only when issues_enabled) + commitCount?: number; // statistics.commit_count — needs Reporter+ token + repositorySize?: number; // statistics.repository_size (bytes) — needs Reporter+ token + contributorsCount?: number; // from the contributors endpoint's X-Total header +} +``` + +## Client (`src/gitlab/client.ts`) + +- `getProject(project, opts?)` gains an optional `{ statistics?: boolean }`. + `fetchProjectInfo` passes `{ statistics: true }`; all other callers + (readme / file / labels) keep today's behavior — no extra cost or permission + change elsewhere. +- New `getContributorsCount(project): Promise`: + calls `Repositories.allContributors(project, { showExpanded: true, perPage: 1, maxPages: 1 })` + and returns `paginationInfo.total` (the `X-Total` header). Returns `undefined` + when the header is absent — no full-list walk. + +## Fetcher (`fetchProjectInfo`) + +After the project fetch (now with `statistics: true`), map: + +- `commitCount` ← `p.statistics?.commit_count` (undefined when statistics withheld) +- `repositorySize` ← `p.statistics?.repository_size` (same) +- `openIssuesCount` ← `p.open_issues_count` only when `p.issues_enabled` +- `contributorsCount` ← `await getContributorsCount(project)` + +**All four are best-effort:** any absence or failure leaves the field +`undefined`, renders no pill, and **never aborts the build — even in `strict` +mode** (they are supplementary, unlike the Feature 1 section fetches, which +respect `strict`). The contributors call is wrapped so a failure degrades to +`undefined`. No cache-key change — these are deterministic per project and +already covered by the existing `projectInfo:` key. + +## Component (`src/components/GitlabProjectInfo.tsx`) + +Inside the existing `showStats` block, append one pill per **defined** field: + +- Commits — `formatCount(commitCount)` (e.g. "1.2k commits") +- Contributors — `formatCount(contributorsCount)` (e.g. "8 contributors") +- Open issues — `formatCount(openIssuesCount)` (e.g. "12 issues") +- Repository size — new `formatBytes(repositorySize)` helper (e.g. "4.2 MB") + +`showStats={false}` still hides the entire row. Existing star / fork / updated +pills are unchanged. + +## Testing (TDD) + +- **Client:** `getProject` forwards `statistics: true` when requested; + `getContributorsCount` returns the pagination `total` and `undefined` when the + header is absent. +- **Fetcher:** maps all four fields; omits `commitCount`/`repositorySize` when + `statistics` is absent; omits `openIssuesCount` when issues disabled; a + contributors-fetch failure yields `undefined` and does **not** throw (even in + strict mode). +- **Component:** each pill renders only when its value is defined; `formatBytes` + formatting (bytes → KB/MB/GB); `showStats={false}` hides the row. +- **`formatBytes`:** unit-tested directly (0, bytes, KB, MB, GB boundaries). + +## Documentation + +- Update the `GitlabProjectInfo` README section and the + `examples/site/docs/components/` page to describe the new stat pills and the + Reporter+ token requirement for commits/size. + +--- + +# Out of scope (both features) - No standalone `GitlabCommits` component (commits live only inside `GitlabProjectInfo`). -- No new pagination ceiling work; each section fetches a single page bounded by - its `limit` (`maxPages: 1`), consistent with `getReleases` / `getIssues`. +- No new pagination ceiling work for the sections; each fetches a single page + bounded by its `limit` (`maxPages: 1`), consistent with `getReleases` / + `getIssues`. +- No code-lines-count stat (no GitLab API for LOC). +- No new opt-in attributes for stats; visibility is governed by `showStats` + plus data availability. From 09ddca18cdb8fbcc76badac49d6df782a3916043 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Wed, 8 Jul 2026 08:46:49 +0200 Subject: [PATCH 03/22] docs: implementation plan for GitlabProjectInfo sections + stats --- ...26-07-08-projectinfo-sections-and-stats.md | 1062 +++++++++++++++++ 1 file changed, 1062 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md diff --git a/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md b/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md new file mode 100644 index 0000000..8b9ec14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-projectinfo-sections-and-stats.md @@ -0,0 +1,1062 @@ +# GitlabProjectInfo Sections + Extended Stats 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 opt-in embedded releases/commits/issues sections (with a title-link override) and extra stat pills (commits, contributors, open issues, repo size) to ``. + +**Architecture:** `fetchProjectInfo` stays the single fetcher for the element. It composes the existing `fetchReleases`/`fetchIssues` plus a new `fetchCommits` for the sections, and reads project `statistics` + a contributors count for the stats. All extra data is attached to `ProjectInfoData` as optional fields. The pure `GitlabProjectInfo` component renders sections (compact `list` default, opt-in `cards`) right after the description, and appends stat pills inside the existing `showStats` row. Section attributes are count-gated (no fetch when unset/≤0); stats are best-effort (never abort the build). + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), `@gitbeaker/rest`, React (SSR), Vitest + React Testing Library. + +**Spec:** `docs/superpowers/specs/2026-07-08-projectinfo-sections-design.md` + +**Conventions (read before starting):** +- ESM-only: every intra-package import uses an explicit `.js` extension. +- gitbeaker responses are snake_case; normalize to camelCase in fetchers. +- Component attribute values are static literals only; the remark plugin injects a `data` prop and leaves all other MDX attributes (e.g. `link`, `releasesLayout`, `showStats`) as props on the element — they flow straight to the React component. +- Component styling uses plain global class names (e.g. `gitlab-badge`, `gitlab-muted`, `gitlab-title`) — this file does NOT use CSS modules. Reuse existing classes; add new semantic class names freely (no new CSS file required). +- After each task run `npx vitest run ` for the touched tests; run `npm run typecheck` before the final commit of each feature. +- Commits are GPG-signed automatically (`commit.gpgsign=true`). Verify with `git log -1 --format='%G?'` (expect `G`). + +--- + +# FEATURE 1 — Embedded releases / commits / issues sections + +## File structure (Feature 1) + +- Modify `src/gitlab/client.ts` — add `getCommits`. +- Modify `src/gitlab/types.ts` — add `CommitData`; add `releases`/`commits`/`issues` to `ProjectInfoData`. +- Modify `src/gitlab/fetchers.ts` — add `fetchCommits`; compose sections + validate layouts in `fetchProjectInfo`. +- Modify `src/components/GitlabProjectInfo.tsx` — render sections + `link` override. +- Modify `src/components/types.ts`, `src/components/index.ts`, `src/index.ts` — re-export `CommitData`. +- Tests: `src/gitlab/client.test.ts`, `src/gitlab/fetchers.test.ts`, `src/components/GitlabProjectInfo.test.tsx`. +- Docs: `README.md`, `examples/site/docs/components/*ProjectInfo*`. + +--- + +## Task 1: `getCommits` client method + +**Files:** +- Modify: `src/gitlab/client.ts` (add method after `getIssues`) +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Write the failing test** + +In `src/gitlab/client.test.ts`, add a `commitsAllMock` alongside the other mocks. Add it to the mocked `Gitlab` return object as `Commits: { all: commitsAllMock }`, and reset it in `beforeEach` (`commitsAllMock.mockReset()`). Then add this test inside `describe("GitLabClient", ...)`: + +```ts +it("getCommits fetches one page and slices to the limit", async () => { + commitsAllMock.mockResolvedValue([ + { short_id: "a1", title: "one" }, + { short_id: "b2", title: "two" }, + { short_id: "c3", title: "three" }, + ]); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const commits = await client.getCommits("g/r", 2); + expect(commitsAllMock).toHaveBeenCalledWith("g/r", { perPage: 2, maxPages: 1 }); + expect(commits).toHaveLength(2); + expect(commits[0].short_id).toBe("a1"); +}); +``` + +Declare the mock at the top with the others: `const commitsAllMock = vi.fn();`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getCommits"` +Expected: FAIL — `client.getCommits is not a function`. + +- [ ] **Step 3: Implement `getCommits`** + +In `src/gitlab/client.ts`, add after `getIssues`: + +```ts + async getCommits(project: ProjectRef, limit: number): Promise { + const commits = await this.api.Commits.all(project, { perPage: limit, maxPages: 1 }); + return commits.slice(0, limit); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getCommits"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add getCommits to GitLabClient" +``` + +--- + +## Task 2: `CommitData` type + `fetchCommits` fetcher + +**Files:** +- Modify: `src/gitlab/types.ts` (add `CommitData`) +- Modify: `src/gitlab/fetchers.ts` (add `fetchCommits`) +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Add the `CommitData` type** + +In `src/gitlab/types.ts`, add after `IssueData`: + +```ts +export interface CommitData { + shortId: string; + title: string; + webUrl: string; + authorName: string; + createdAt: string; +} +``` + +- [ ] **Step 2: Write the failing test** + +In `src/gitlab/fetchers.test.ts`, import `fetchCommits` in the existing import from `./fetchers`. Add: + +```ts +describe("fetchCommits", () => { + it("normalizes commits and respects the limit", async () => { + const client = { + getCommits: vi.fn(async () => [ + { short_id: "a1b2c3d", title: "fix: thing", web_url: "https://gitlab.com/g/r/-/commit/a1b2c3d", + author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + }; + const c = ctx(client); + const data = await fetchCommits(c, { project: "g/r", limit: 5 }); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 5); + expect(data).toEqual([ + { shortId: "a1b2c3d", title: "fix: thing", webUrl: "https://gitlab.com/g/r/-/commit/a1b2c3d", + authorName: "Ada", createdAt: "2026-01-02T00:00:00Z" }, + ]); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchCommits"` +Expected: FAIL — `fetchCommits is not exported` / not a function. + +- [ ] **Step 4: Implement `fetchCommits`** + +In `src/gitlab/fetchers.ts`, add `CommitData` to the type import from `./types`, and add this fetcher after `fetchIssues`: + +```ts +export async function fetchCommits(ctx: GitLabContext, attrs: Attrs): Promise { + const project = String(attrs.project); + const limit = typeof attrs.limit === "number" ? attrs.limit : 10; + return memo(ctx, `commits:${project}:${limit}`, async () => { + const raw = await ctx.client.getCommits(attrs.project as string | number, limit); + return raw.map((c: any) => ({ + shortId: c.short_id, + title: c.title, + webUrl: c.web_url, + authorName: c.author_name ?? "", + createdAt: c.created_at, + } satisfies CommitData)); + }); +} +``` + +Also add `getCommits` to the `GitLabClient` type surface used by fetchers if a local interface exists — it does not; fetchers call `ctx.client` (typed `GitLabClient`), so no extra change beyond Task 1. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchCommits"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: add CommitData type and fetchCommits fetcher" +``` + +--- + +## Task 3: Compose sections into `fetchProjectInfo` + +**Files:** +- Modify: `src/gitlab/types.ts` (extend `ProjectInfoData`) +- Modify: `src/gitlab/fetchers.ts` (`fetchProjectInfo` + layout validator) +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Extend `ProjectInfoData`** + +In `src/gitlab/types.ts`, add optional fields to `ProjectInfoData` (after `avatarUrl`): + +```ts + releases?: ReleaseData[]; + commits?: CommitData[]; + issues?: IssueData[]; +``` + +- [ ] **Step 2: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`, add inside `describe("fetchProjectInfo", ...)`: + +```ts +it("attaches sections only when their count is > 0", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => [ + { name: "v1", tag_name: "v1", released_at: "2026-01-01T00:00:00Z", description: "", upcoming_release: false, assets: { links: [] } }, + ]), + getCommits: vi.fn(async () => [ + { short_id: "a1", title: "t", web_url: "u", author_name: "Ada", created_at: "2026-01-02T00:00:00Z" }, + ]), + getIssues: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2, commits: 3 }); + expect(client.getReleases).toHaveBeenCalledWith("g/r", 2); + expect(client.getCommits).toHaveBeenCalledWith("g/r", 3); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toHaveLength(1); + expect(data.commits).toHaveLength(1); + expect(data.issues).toBeUndefined(); +}); + +it("does not fetch a section when its count is 0 or absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => []), + getCommits: vi.fn(async () => []), + getIssues: vi.fn(async () => []), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r", commits: 0 }); + expect(client.getReleases).not.toHaveBeenCalled(); + expect(client.getCommits).not.toHaveBeenCalled(); + expect(client.getIssues).not.toHaveBeenCalled(); + expect(data.releases).toBeUndefined(); +}); + +it("omits a failing section in non-strict mode instead of throwing", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + }; + const c = ctx(client); + c.options.strict = false; + const data = await fetchProjectInfo(c, { project: "g/r", releases: 2 }); + expect(data.releases).toBeUndefined(); + expect(data.name).toBe("r"); +}); + +it("rethrows a failing section in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + })), + getReleases: vi.fn(async () => { throw new Error("boom"); }), + }; + const c = ctx(client); + c.options.strict = true; + await expect(fetchProjectInfo(c, { project: "g/r", releases: 2 })).rejects.toThrow("boom"); +}); + +it("rejects an invalid section layout", async () => { + const client = { getProject: vi.fn(async () => ({ id: 1, path_with_namespace: "g/r", name: "r", description: "", web_url: "u", star_count: 0, forks_count: 0, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null })) }; + await expect(fetchProjectInfo(ctx(client), { project: "g/r", releasesLayout: "grid" })).rejects.toThrow(/releasesLayout/); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: FAIL — new assertions fail (sections not attached, layout not validated). + +- [ ] **Step 4: Implement the composition** + +In `src/gitlab/fetchers.ts`, add a section-layout validator near `readLayout`: + +```ts +function readSectionLayout(value: unknown, attr: string): "list" | "cards" { + if (value === undefined || value === "list" || value === "cards") { + return value === undefined ? "list" : value; + } + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: "${attr}" must be "list" or "cards"; ` + + `got ${JSON.stringify(value)}.`, + ); +} +``` + +Then rewrite `fetchProjectInfo` so it validates layouts up front, builds the base object inside `memo`, and attaches the count-gated sections (respecting `strict`): + +```ts +export async function fetchProjectInfo(ctx: GitLabContext, attrs: Attrs): Promise { + const project = String(attrs.project); + // Validate presentational layout literals early (values are read by the component). + readSectionLayout(attrs.releasesLayout, "releasesLayout"); + readSectionLayout(attrs.commitsLayout, "commitsLayout"); + readSectionLayout(attrs.issuesLayout, "issuesLayout"); + + const rN = typeof attrs.releases === "number" ? attrs.releases : 0; + const cN = typeof attrs.commits === "number" ? attrs.commits : 0; + const iN = typeof attrs.issues === "number" ? attrs.issues : 0; + const strict = ctx.options.strict ?? true; + + async function section(count: number, fn: () => Promise): Promise { + if (!(count > 0)) return undefined; + try { + return await fn(); + } catch (err) { + if (strict) throw err; + return undefined; + } + } + + return memo(ctx, `projectInfo:${project}:r${rN}:c${cN}:i${iN}`, async () => { + const p = await ctx.client.getProject(attrs.project as string | number); + const avatarUrl = p.avatar_url ? await ctx.assets.localize(p.avatar_url, "", project) : null; + const [releases, commits, issues] = await Promise.all([ + section(rN, () => fetchReleases(ctx, { project, limit: rN })), + section(cN, () => fetchCommits(ctx, { project, limit: cN })), + section(iN, () => fetchIssues(ctx, { project, limit: iN })), + ]); + const base: ProjectInfoData = { + id: p.id, + path: p.path_with_namespace, + name: p.name, + descriptionHtml: await renderMarkdown(p.description ?? "", { renderChain: ctx.options.markdownRenderChain }), + webUrl: p.web_url, + starCount: p.star_count, + forksCount: p.forks_count, + topics: p.topics ?? [], + lastActivityAt: p.last_activity_at, + avatarUrl, + }; + if (releases) base.releases = releases; + if (commits) base.commits = commits; + if (issues) base.issues = issues; + return base; + }).then((v) => ({ ...v, path: v.path || project })); +} +``` + +Note: `fetchReleases`/`fetchIssues`/`fetchCommits` are hoisted function declarations in the same module, so the forward references are fine. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: PASS. Then run the full fetchers file to catch regressions: `npx vitest run src/gitlab/fetchers.test.ts` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: compose count-gated sections into fetchProjectInfo" +``` + +--- + +## Task 4: Render compact sections + `link` override in the component + +**Files:** +- Modify: `src/components/GitlabProjectInfo.tsx` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("renders compact release, commit, and issue lines after the description", () => { + render(); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("v1.0")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "a1b2c3d" })).toHaveAttribute("href", "https://gitlab.com/c/a1b2c3d"); + expect(screen.getByText("fix: bug")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Broken thing/ })).toHaveAttribute("href", "https://gitlab.com/i/42"); +}); + +it("renders no section blocks when arrays are absent", () => { + const { container } = render(); + expect(container.querySelector(".gitlab-section")).toBeNull(); +}); + +it("overrides the title link when link is provided", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://example.com/app"); +}); + +it("defaults the title link to the project webUrl", () => { + render(); + expect(screen.getByRole("link", { name: "My Repo" })).toHaveAttribute("href", "https://gitlab.com/g/r"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: FAIL — sections not rendered, `link` not applied. + +- [ ] **Step 3: Implement sections + link override** + +Rewrite `src/components/GitlabProjectInfo.tsx`. Update the imports and props, add a `SectionLayout` type + three local render helpers, use `link` for the title href, and render the sections right after the description block: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import { formatCount } from "./format.js"; +import type { ComponentPayload, ProjectInfoData, ReleaseData, CommitData, IssueData } from "./types.js"; + +type SectionLayout = "list" | "cards"; + +interface ProjectInfoProps extends ComponentPayload { + showStats?: boolean; + link?: string; + releasesLayout?: SectionLayout; + commitsLayout?: SectionLayout; + issuesLayout?: SectionLayout; +} + +function shortDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +function Releases({ items, layout }: { items: ReleaseData[]; layout: SectionLayout }) { + return ( +
+
Releases
+
    + {items.map((r) => ( +
  • + {r.tagName} + {r.name || r.tagName} + {layout === "cards" && ( + · {shortDate(r.releasedAt)} + )} +
  • + ))} +
+
+ ); +} + +function Commits({ items, layout }: { items: CommitData[]; layout: SectionLayout }) { + return ( +
+
Latest commits
+
    + {items.map((c) => ( +
  • + {c.shortId} + {c.title} + · {c.authorName} · {shortDate(c.createdAt)} +
  • + ))} +
+
+ ); +} + +function Issues({ items, layout }: { items: IssueData[]; layout: SectionLayout }) { + return ( +
+
Issues
+
    + {items.map((i) => ( +
  • + #{i.iid} {i.title} + {layout === "cards" && ( + · {i.state} · {i.authorName} + )} +
  • + ))} +
+
+ ); +} + +export function GitlabProjectInfo({ + data, + error, + showStats = true, + link, + releasesLayout = "list", + commitsLayout = "list", + issuesLayout = "list", +}: ProjectInfoProps) { + if (error) return ; + if (!data) return null; + return ( +
+
+ {data.avatarUrl && ( + {data.name} + )} + +
+ {data.descriptionHtml && ( +
+ )} + {data.releases && data.releases.length > 0 && ( + + )} + {data.commits && data.commits.length > 0 && ( + + )} + {data.issues && data.issues.length > 0 && ( + + )} + {data.topics.length > 0 && ( +
+ {data.topics.map((t) => ( + {t} + ))} +
+ )} + {showStats && ( +
+ ★ {formatCount(data.starCount)} + ⑂ {formatCount(data.forksCount)} + updated {new Date(data.lastActivityAt).toLocaleDateString()} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: PASS (all existing + new tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/GitlabProjectInfo.tsx src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: render embedded sections and link override in GitlabProjectInfo" +``` + +--- + +## Task 5: `cards` layout coverage + export `CommitData` + +**Files:** +- Modify: `src/components/types.ts`, `src/components/index.ts`, `src/index.ts` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing test (cards layout)** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("shows richer metadata in cards layout", () => { + render(); + expect(screen.getByText(/opened/)).toBeInTheDocument(); + expect(screen.getByText(/Ada/)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails, then passes** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx -t "cards layout"` +The rendering from Task 4 already implements the `cards` branch, so this test should PASS immediately. If it FAILS, fix the `layout === "cards"` branch in the `Issues` helper. (This task exists to lock in cards behavior and finish exports.) + +- [ ] **Step 3: Re-export `CommitData`** + +Add `CommitData` to the export list in `src/components/types.ts`: + +```ts +export type { + ProjectInfoData, + ReleaseData, + IssueData, + CommitData, + ReadmeData, + FileData, + TopicData, + LabelData, + FetchError, + ComponentPayload, +} from "../gitlab/types.js"; +``` + +Add `CommitData` to the `export type { … } from "./types.js";` block in `src/components/index.ts` (insert after `IssueData`). + +Add `CommitData` to the `export type { … }` block in `src/index.ts` (insert after `IssueData`). + +- [ ] **Step 4: Typecheck + run tests** + +Run: `npm run typecheck` +Expected: no errors. +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx src/gitlab/fetchers.test.ts src/gitlab/client.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/types.ts src/components/index.ts src/index.ts src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: export CommitData and cover cards layout" +``` + +--- + +## Task 6: Documentation (Feature 1) + +**Files:** +- Modify: `README.md` +- Modify: `examples/site/docs/components/` (the `GitlabProjectInfo` page) + +- [ ] **Step 1: Locate the docs** + +Run: `grep -rl "GitlabProjectInfo" README.md examples/site/docs` +Open the README section and the example page for `GitlabProjectInfo`. + +- [ ] **Step 2: Document the new attributes** + +Add an attributes subsection describing: `releases={N}`, `commits={N}`, `issues={N}` (opt-in counts; no fetch when unset/≤0), `releasesLayout` / `commitsLayout` / `issuesLayout` (`"list"` default, `"cards"`), and `link` (overrides the title href, defaults to the project URL). Include a worked example: + +```mdx + + +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md examples/site/docs +git commit -m "docs: document GitlabProjectInfo embedded sections" +``` + +--- + +# FEATURE 2 — Extended project stats + +## File structure (Feature 2) + +- Create `src/components/formatBytes.ts` — new `formatBytes` helper (kept next to `format.ts`). +- Modify `src/gitlab/client.ts` — `getProject` statistics option + `getContributorsCount`. +- Modify `src/gitlab/types.ts` — add stat fields to `ProjectInfoData`. +- Modify `src/gitlab/fetchers.ts` — map stats in `fetchProjectInfo`. +- Modify `src/components/GitlabProjectInfo.tsx` — render stat pills. +- Tests: `src/components/formatBytes.test.ts`, `src/gitlab/client.test.ts`, `src/gitlab/fetchers.test.ts`, `src/components/GitlabProjectInfo.test.tsx`. +- Docs: `README.md`, example page. + +--- + +## Task 7: `formatBytes` helper + +**Files:** +- Create: `src/components/formatBytes.ts` +- Test: `src/components/formatBytes.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/formatBytes.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { formatBytes } from "./formatBytes.js"; + +describe("formatBytes", () => { + it("formats zero and bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + }); + it("formats KB, MB, GB with one decimal", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(4_404_019)).toBe("4.2 MB"); + expect(formatBytes(2_147_483_648)).toBe("2 GB"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/components/formatBytes.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `formatBytes`** + +Create `src/components/formatBytes.ts`: + +```ts +/** Humanize a byte count: 1536 -> "1.5 KB", 4.4e6 -> "4.2 MB". */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = n; + let unit = -1; + do { + value /= 1024; + unit += 1; + } while (value >= 1024 && unit < units.length - 1); + return `${parseFloat(value.toFixed(1))} ${units[unit]}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/components/formatBytes.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/formatBytes.ts src/components/formatBytes.test.ts +git commit -m "feat: add formatBytes helper" +``` + +--- + +## Task 8: `getProject` statistics option + `getContributorsCount` + +**Files:** +- Modify: `src/gitlab/client.ts` +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/gitlab/client.test.ts`, add a `contributorsAllMock = vi.fn();` at the top, wire it into the mocked Gitlab object as `Repositories: { allContributors: contributorsAllMock }`, and reset it in `beforeEach`. Add: + +```ts +it("getProject forwards the statistics option", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r", { statistics: true }); + expect(showMock).toHaveBeenCalledWith("g/r", { statistics: true }); +}); + +it("getProject omits options by default", async () => { + showMock.mockResolvedValue({ id: 1 }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + await client.getProject("g/r"); + expect(showMock).toHaveBeenCalledWith("g/r"); +}); + +it("getContributorsCount returns the pagination total", async () => { + contributorsAllMock.mockResolvedValue({ data: [{}], paginationInfo: { total: 8 } }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + const count = await client.getContributorsCount("g/r"); + expect(contributorsAllMock).toHaveBeenCalledWith("g/r", { showExpanded: true, perPage: 1, maxPages: 1 }); + expect(count).toBe(8); +}); + +it("getContributorsCount returns undefined when total is absent", async () => { + contributorsAllMock.mockResolvedValue({ data: [], paginationInfo: {} }); + const client = new GitLabClient({ host: "https://gitlab.com" }); + expect(await client.getContributorsCount("g/r")).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/client.test.ts -t "getProject forwards|getContributorsCount"` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +In `src/gitlab/client.ts`, change `getProject` to accept options and add `getContributorsCount`: + +```ts + async getProject(project: ProjectRef, opts?: { statistics?: boolean }): Promise { + return opts ? this.api.Projects.show(project, opts) : this.api.Projects.show(project); + } + + async getContributorsCount(project: ProjectRef): Promise { + const res: any = await this.api.Repositories.allContributors(project, { + showExpanded: true, + perPage: 1, + maxPages: 1, + }); + const total = res?.paginationInfo?.total; + return typeof total === "number" ? total : undefined; + } +``` + +Note: passing `undefined` options to `Projects.show` would change the call signature in the "omits options by default" test — that is why the ternary calls `show(project)` with no second arg when `opts` is undefined. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/client.test.ts` +Expected: PASS (all client tests — the existing `getProject` callers still pass one arg). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -m "feat: add statistics option and getContributorsCount to client" +``` + +--- + +## Task 9: Map stats in `fetchProjectInfo` + +**Files:** +- Modify: `src/gitlab/types.ts` (stat fields) +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +- [ ] **Step 1: Extend `ProjectInfoData`** + +In `src/gitlab/types.ts`, add to `ProjectInfoData` (after the section fields from Task 3): + +```ts + openIssuesCount?: number; + commitCount?: number; + repositorySize?: number; + contributorsCount?: number; +``` + +- [ ] **Step 2: Write the failing tests** + +In `src/gitlab/fetchers.test.ts`, add inside `describe("fetchProjectInfo", ...)`: + +```ts +it("maps statistics, open issues, and contributors count", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 12, + statistics: { commit_count: 1200, repository_size: 4404019 }, + })), + getContributorsCount: vi.fn(async () => 8), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(client.getProject).toHaveBeenCalledWith("g/r", { statistics: true }); + expect(data.commitCount).toBe(1200); + expect(data.repositorySize).toBe(4404019); + expect(data.openIssuesCount).toBe(12); + expect(data.contributorsCount).toBe(8); +}); + +it("omits statistics-derived stats when statistics is absent", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: false, + })), + getContributorsCount: vi.fn(async () => undefined), + }; + const c = ctx(client); + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.commitCount).toBeUndefined(); + expect(data.repositorySize).toBeUndefined(); + expect(data.openIssuesCount).toBeUndefined(); + expect(data.contributorsCount).toBeUndefined(); +}); + +it("never throws when the contributors fetch fails, even in strict mode", async () => { + const client = { + getProject: vi.fn(async () => ({ + id: 7, path_with_namespace: "g/r", name: "r", description: "d", web_url: "https://gitlab.com/g/r", + star_count: 3, forks_count: 1, topics: [], last_activity_at: "2026-01-01T00:00:00Z", avatar_url: null, + issues_enabled: true, open_issues_count: 5, statistics: { commit_count: 1, repository_size: 1 }, + })), + getContributorsCount: vi.fn(async () => { throw new Error("no perms"); }), + }; + const c = ctx(client); + c.options.strict = true; + const data = await fetchProjectInfo(c, { project: "g/r" }); + expect(data.contributorsCount).toBeUndefined(); + expect(data.commitCount).toBe(1); +}); +``` + +Also update the two existing `fetchProjectInfo` tests that assert `expect(client.getProject).toHaveBeenCalledWith("g/r")` — they must now expect `toHaveBeenCalledWith("g/r", { statistics: true })`. Those two fakes have no `getContributorsCount`; add `getContributorsCount: vi.fn(async () => undefined)` to their `client` objects. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t "fetchProjectInfo"` +Expected: FAIL — stats not mapped; `getProject` called without options. + +- [ ] **Step 4: Implement stat mapping** + +In `src/gitlab/fetchers.ts`, inside the `fetchProjectInfo` memo closure, change the project fetch to request statistics and add the stat mapping. Update the `getProject` call: + +```ts + const p = await ctx.client.getProject(attrs.project as string | number, { statistics: true }); +``` + +Compute the contributors count best-effort (never throws) alongside the section fetches: + +```ts + const contributorsCount = await ctx.client + .getContributorsCount(attrs.project as string | number) + .catch(() => undefined); +``` + +After building `base` (before the `if (releases)` lines), attach the stats: + +```ts + if (typeof p.statistics?.commit_count === "number") base.commitCount = p.statistics.commit_count; + if (typeof p.statistics?.repository_size === "number") base.repositorySize = p.statistics.repository_size; + if (p.issues_enabled && typeof p.open_issues_count === "number") base.openIssuesCount = p.open_issues_count; + if (typeof contributorsCount === "number") base.contributorsCount = contributorsCount; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/gitlab/fetchers.test.ts` +Expected: PASS (including the two updated existing tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/gitlab/types.ts src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -m "feat: map commit/contributor/issue/size stats in fetchProjectInfo" +``` + +--- + +## Task 10: Render stat pills in the component + +**Files:** +- Modify: `src/components/GitlabProjectInfo.tsx` +- Test: `src/components/GitlabProjectInfo.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +In `src/components/GitlabProjectInfo.test.tsx`, add: + +```ts +it("appends stat pills when their data is present", () => { + render(); + expect(screen.getByText(/1.2k commits/)).toBeInTheDocument(); + expect(screen.getByText(/8 contributors/)).toBeInTheDocument(); + expect(screen.getByText(/12 issues/)).toBeInTheDocument(); + expect(screen.getByText(/4.2 MB/)).toBeInTheDocument(); +}); + +it("omits stat pills whose data is absent", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/contributors/)).not.toBeInTheDocument(); +}); + +it("hides all stats including new pills when showStats is false", () => { + render(); + expect(screen.queryByText(/commits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/★/)).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx -t "stat pills|showStats is false"` +Expected: FAIL. + +- [ ] **Step 3: Implement the pills** + +In `src/components/GitlabProjectInfo.tsx`, add the `formatBytes` import: + +```tsx +import { formatBytes } from "./formatBytes.js"; +``` + +Inside the `showStats` block, add the new pills after the forks span and before the `updated` span: + +```tsx + ⑂ {formatCount(data.forksCount)} + {typeof data.commitCount === "number" && ( + ⎇ {formatCount(data.commitCount)} commits + )} + {typeof data.contributorsCount === "number" && ( + 👥 {formatCount(data.contributorsCount)} contributors + )} + {typeof data.openIssuesCount === "number" && ( + ⊙ {formatCount(data.openIssuesCount)} issues + )} + {typeof data.repositorySize === "number" && ( + ▤ {formatBytes(data.repositorySize)} + )} + updated {new Date(data.lastActivityAt).toLocaleDateString()} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/components/GitlabProjectInfo.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/GitlabProjectInfo.tsx src/components/GitlabProjectInfo.test.tsx +git commit -m "feat: render extended stat pills in GitlabProjectInfo" +``` + +--- + +## Task 11: Documentation (Feature 2) + full verification + +**Files:** +- Modify: `README.md`, example page +- Verify: whole test suite + typecheck + +- [ ] **Step 1: Document the stats** + +In the README and the `GitlabProjectInfo` example page, describe the new stat pills (commits, contributors, open issues, repository size), note they appear automatically when data is available and are still gated by `showStats`, and add the **Reporter+ token** caveat: commits count and repository size require the build-time token to have Reporter access; otherwise those two pills are omitted. + +- [ ] **Step 2: Run the full unit suite** + +Run: `npx vitest run` +Expected: PASS (all files). + +- [ ] **Step 3: Typecheck + build** + +Run: `npm run typecheck && npm run build` +Expected: no errors; `dist/` emitted. + +- [ ] **Step 4: Commit** + +```bash +git add README.md examples/site/docs +git commit -m "docs: document GitlabProjectInfo extended stats" +``` + +- [ ] **Step 5 (optional but recommended): e2e** + +If you touched anything the e2e site exercises, add a `` usage to an example page and run: +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS (slow, ~1 min). + +--- + +## Self-review notes (already reconciled) + +- **Spec coverage:** sections (Tasks 1–4), layouts + exports (Task 5), sections docs (Task 6); stats helper (Task 7), client (Task 8), fetcher mapping (Task 9), component pills (Task 10), stats docs + verification (Task 11). Code-lines is intentionally absent (no API). +- **Strict semantics:** section fetches respect `strict` (Task 3); contributors/statistics are best-effort and never throw (Tasks 8–9). +- **Type consistency:** `CommitData` (`shortId/title/webUrl/authorName/createdAt`) is defined in Task 2 and used identically in Tasks 3–5; `getProject(project, { statistics })`, `getContributorsCount`, and the `openIssuesCount/commitCount/repositorySize/contributorsCount` fields are named identically across Tasks 8–10. +- **Existing-test updates:** Task 9 Step 2 explicitly updates the two prior `fetchProjectInfo` tests for the new `getProject` signature. From e542257b80231708ea1dcd1ecbcaed2e43f8f99c Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:18:23 +0200 Subject: [PATCH 04/22] docs: design spec for GitLab group page generation --- ...-09-gitlab-group-page-generation-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-gitlab-group-page-generation-design.md diff --git a/docs/superpowers/specs/2026-07-09-gitlab-group-page-generation-design.md b/docs/superpowers/specs/2026-07-09-gitlab-group-page-generation-design.md new file mode 100644 index 0000000..2450984 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-gitlab-group-page-generation-design.md @@ -0,0 +1,209 @@ +# GitLab group → generated doc pages + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-07-09 + +## Problem + +Today the plugin can only *embed* GitLab resources into hand-authored MDX pages +(via `` components and `{@includeGitlab…}` directives). There is no way +to **generate whole pages** from a GitLab group — e.g. "display all projects of +group 1, each project's README on its own page, with a landing page listing +them." + +## Goal + +A single directive on an index page spawns: + +1. One generated doc page **per project** in a GitLab group (recursing subgroups, + excluding archived, optionally filtered by topic). +2. A nested **sidebar** that mirrors the group's subgroup structure. +3. A **card grid** rendered in place of the directive on the index page. + +Each generated page shows a configurable set of sections (`info`, `readme`, +`releases`, `issues`) built from the **existing** pure components — no new render +path. + +## Non-goals + +- No runtime/browser fetching. All GitLab data is fetched at build time (existing + model). +- No config-based declaration (a plugin-options `generate: [...]` array). The + trigger is the on-page directive only. +- No custom sidebar rendering. We rely on Docusaurus autogenerated sidebars driven + by the generated folder tree. + +## Chosen approach + +**Generate physical `.mdx` files** into a nested folder tree that mirrors the +subgroups, then let the existing pipeline take over: + +- Docusaurus's autogenerated sidebar mirrors the folder tree **for free** (this is + the only approach that yields the requested nested sidebar without hand-building + navigation). +- Each generated file contains only `` components, so the **existing + remark plugin** fetches + injects the data and the **existing pure components** + render it. +- Reuses `FileCache`, `AssetManager`, theming, TOC, and the `strict` error + contract. + +Rejected alternatives: + +- **Programmatic routes** (`contentLoaded` + `addRoute`): does not feed the docs + sidebar, so mirroring subgroups would require a hand-built nav and a + re-implemented README render path. Diverges from the repo philosophy. +- **CLI-only trigger**: kept as an *additional* trigger, not the sole one. + +## Directive syntax + +Handled by the same grammar/expand machinery as `{@includeGitlab…}`: + +``` +{@generateGitlabPages group=1 sections="info,readme,releases" topics="public-docs" includeSubgroups=true includeArchived=false basePath="projects"} +``` + +| Attribute | Default | Meaning | +|---|---|---| +| `group` | *(required)* | GitLab group id or full path | +| `sections` | `readme` | Comma list from `info,readme,releases,issues` → components on each child page, in order | +| `topics` | *(none)* | Only projects carrying **all** listed topics | +| `includeSubgroups` | `false` | Recurse into subgroups | +| `includeArchived` | `false` | Include archived projects (excluded by default) | +| `basePath` | index page's folder | Folder (relative to the docs dir) the generated tree is written into | + +Validation errors (missing `group`, unknown `sections` value) throw at parse time +with a clear message, matching `parseInclude`. + +## Data layer + +One new fetcher shared by generation **and** the index grid, so a single fetch +feeds everything. + +- **Client:** new `getGroupProjects(group, { includeSubgroups, archived, topic, pages })` + → gitbeaker `Groups.allProjects`. Paginated, respecting the existing 500-item + security cap (`perPage 100 × maxPages 5`; see the topics/labels cap — do not + raise). +- **Domain type:** `GroupProjectData`: + ```ts + interface GroupProjectData { + id: number; + name: string; + path: string; + pathWithNamespace: string; + description: string | null; + webUrl: string; + starCount: number; + defaultBranch: string | null; + namespace: { fullPath: string; name: string }; + } + ``` +- **Fetcher:** `fetchGroupProjects(ctx, attrs)` normalizes snake_case → camelCase, + memoized via `FileCache`. + +## Generation core + +New module `src/generate/` — pure and independently testable. Entry: +`generateGroupPages({ resolved, siteDir, directive })`: + +1. Fetch the project tree via `fetchGroupProjects` (shared cache; no double GitLab + hits with the grid). +2. Resolve the target folder: `/docs//` (default `basePath` = + the index page's own folder). +3. **Clean + regenerate** the folder each run (idempotent; removed projects + disappear). The cleaner only deletes files it **owns** (identified by the + generated header marker + a manifest), never hand-authored docs. +4. For each project, write `//.mdx`, mirroring + the namespace tree. +5. For each subgroup level, write `_category_.json` (`{ "label": "" }`) + so the autogenerated sidebar shows the GitLab hierarchy. +6. Write a `.gitignore` (`*`) inside `/` so generated pages are never + committed. + +### Generated child page shape + +`projects/team-x/acme-mobile.mdx`: + +```mdx +--- +title: acme-mobile +description: +--- +{/* AUTO-GENERATED by @ebuildy/docusaurus-plugin-gitlab — do not edit */} + + + +``` + +The emitted components are driven by `sections`, in the listed order. From here the +existing remark plugin fetches + injects data. + +## Triggers (two, one core) + +- **Automatic (build-time):** the plugin default export becomes an **async** + function. During init it globs the site's docs/pages for the directive and runs + the core for each match. Init runs before the docs plugin scans the filesystem, + so generated files + `_category_.json` exist when the sidebar is built. +- **CLI:** `extendCli(cli)` registers `docusaurus gitlab:generate`, running the + identical core — for explicit/CI regeneration without a full build. +- An `alreadyGenerated` per-build guard prevents CLI-then-build from doing the work + twice. + +## Index card grid + +The directive does not vanish; it leaves a rendered grid on the index page via the +normal remark fetch/inject flow. + +- New pure component **`GitlabProjectGrid`** + registry entry + `GitlabProjectGrid: fetchGroupProjects` (reuses the same fetcher → same cached + data as generation). +- The expand step rewrites + `{@generateGitlabPages group=1 …}` → + ``. + One directive, one source of truth, two jobs: (a) trigger generation at init, + (b) expand to the grid at MDX-compile. +- Each card: name, description, star count, link to the project's generated page + (`///`). Cards follow the subgroup + grouping. Styling reuses `styles.module.css` conventions and the existing + `format*` / `layout` helpers. + +## Error handling + +Follows the existing `strict` contract: + +- **Generation (init/CLI):** a failed group fetch throws in `strict` (aborts the + build), or logs and skips in dev. +- **Grid render:** `error` prop → `Fallback`; no data → `null`; else render (the + standard component shape). +- **Directive validation:** throws at parse time with a clear message, matching + `parseInclude`. + +## Testing (TDD) + +- `getGroupProjects` client method — mocked gitbeaker (subgroups/archived/topic + params, 500-cap). +- `fetchGroupProjects` fetcher — normalization + cache memoization. +- Generation core — real temp dir: correct nested files, `_category_.json`, + `.gitignore`, idempotent clean/regenerate, only deletes owned files, `sections` + selection. +- Directive parse + expand-to-``. +- `GitlabProjectGrid` component — RTL (cards, links, Fallback on error). +- e2e: extend `examples/site` with an index page using the directive; assert + generated pages build and appear in the sidebar (guarded to degrade gracefully + without a token). + +## Module map (new/changed) + +| File | Change | +|---|---| +| `src/gitlab/client.ts` | + `getGroupProjects` | +| `src/gitlab/types.ts` | + `GroupProjectData` | +| `src/gitlab/fetchers.ts` | + `fetchGroupProjects` (memoized) | +| `src/generate/index.ts` | new: `generateGroupPages` core (fetch → write tree) | +| `src/generate/*` | new: file writer, `_category_.json`, gitignore, manifest/cleaner | +| `src/include/grammar.ts` | + parse `generateGitlabPages` directive | +| `src/include/expand.ts` | + rewrite directive → `` | +| `src/remark/registry.ts` | + `GitlabProjectGrid: fetchGroupProjects` | +| `src/components/GitlabProjectGrid.tsx` | new pure component | +| `src/components/index.ts`, `src/index.ts` | export component + type | +| `src/plugin/index.ts` | async init generation + `extendCli` command | +| `examples/site/docs/…` | e2e index page + docs | From 725fde9079142fc2ca24ae807b8bd81339aac00b Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:30:28 +0200 Subject: [PATCH 05/22] docs: implementation plan for GitLab group page generation --- ...2026-07-09-gitlab-group-page-generation.md | 1451 +++++++++++++++++ 1 file changed, 1451 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-gitlab-group-page-generation.md diff --git a/docs/superpowers/plans/2026-07-09-gitlab-group-page-generation.md b/docs/superpowers/plans/2026-07-09-gitlab-group-page-generation.md new file mode 100644 index 0000000..333a32e --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-gitlab-group-page-generation.md @@ -0,0 +1,1451 @@ +# GitLab Group Page Generation — 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:** A `{@generateGitlabPages group=…}` directive on an index MDX page generates one doc page per GitLab project in a group (mirroring subgroups in the sidebar) and renders a project card grid in place of the directive. + +**Architecture:** A shared fetcher (`fetchGroupProjects`) lists the group's projects. The plugin's async init scans docs for the directive and writes a nested tree of `.mdx` files (each just `` components) into a git-ignored subfolder — Docusaurus's autogenerated sidebar mirrors the folders, and the existing remark plugin fetches/injects data per generated page. The same directive is rewritten by the webpack loader into a `` element on the index page, fed by the same cached fetch. A CLI command exposes the same generation core. + +**Tech Stack:** TypeScript (ESM, `.js` import extensions), `@gitbeaker/rest`, Docusaurus 3 plugin lifecycle, React (pure SSR components), Vitest + React Testing Library. + +--- + +## Conventions (read before starting) + +- **ESM imports use explicit `.js`** (e.g. `import { memo } from "./fetchers.js"`). Match existing files. +- **Components are pure:** `error → ; no data → null; else render`. No hooks, no fetching. +- **gitbeaker responses are snake_case** — normalize to camelCase in the fetcher. +- **Error contract:** in `strict` mode failures throw (abort build); otherwise degrade. +- Run `npx vitest run ` for one test file, `npm run typecheck` after code edits. +- Commit with `git commit -S` (GPG signing is required and automatic; verify `git log -1 --format=%G?` prints `G`). + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `src/gitlab/client.ts` | + `getGroupProjects()` | 1 | +| `src/gitlab/types.ts` | + `GroupProjectData` | 2 | +| `src/gitlab/fetchers.ts` | + `fetchGroupProjects()` (memoized, normalizes + filters) | 3 | +| `src/generate/directive.ts` | Parse `{@generateGitlabPages …}` attribute string → `GeneratePagesSpec` | 4 | +| `src/generate/render-page.ts` | Render one child `.mdx` string from a project + sections | 5 | +| `src/generate/write.ts` | Write the nested file tree (`_category_.json`, `.gitignore`, ownership marker, cleanup) | 6 | +| `src/generate/scan.ts` | Find directive occurrences across the docs dir | 7 | +| `src/generate/index.ts` | Orchestrator `generateAll()` (scan → fetch → write) | 8 | +| `src/components/GitlabProjectGrid.tsx` | Pure card-grid component | 9 | +| `src/remark/registry.ts`, `src/components/index.ts`, `src/index.ts` | Register + export | 9 | +| `src/include/loader.ts` | Rewrite directive → `` JSX | 10 | +| `src/plugin/index.ts` | Async init generation + `extendCli` command | 11 | +| `examples/site/docs/…`, `README.md` | e2e page + docs | 12 | + +--- + +## Task 1: `getGroupProjects` client method + +**Files:** +- Modify: `src/gitlab/client.ts` +- Test: `src/gitlab/client.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/gitlab/client.test.ts`. First check the top of that file for how `api` is mocked; the existing tests stub `this.api` on a constructed client. Follow that pattern. This test asserts the gitbeaker call shape: + +```ts +describe("getGroupProjects", () => { + it("requests group projects with subgroup recursion and archived filter", async () => { + const client = new GitLabClient({ host: "https://gitlab.com" }); + const allProjects = vi.fn(async () => [{ id: 1, path: "a" }]); + (client as any).api = { Groups: { allProjects } }; + + const res = await client.getGroupProjects(1, { includeSubgroups: true, archived: false }); + + expect(res).toEqual([{ id: 1, path: "a" }]); + expect(allProjects).toHaveBeenCalledWith(1, { + includeSubgroups: true, + archived: false, + perPage: 100, + maxPages: 5, + orderBy: "path", + sort: "asc", + }); + }); + + it("omits archived filter when includeArchived is requested (archived undefined)", async () => { + const client = new GitLabClient({ host: "https://gitlab.com" }); + const allProjects = vi.fn(async () => []); + (client as any).api = { Groups: { allProjects } }; + + await client.getGroupProjects("grp", { includeSubgroups: false }); + + expect(allProjects).toHaveBeenCalledWith("grp", { + includeSubgroups: false, + perPage: 100, + maxPages: 5, + orderBy: "path", + sort: "asc", + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/client.test.ts -t getGroupProjects` +Expected: FAIL — `client.getGroupProjects is not a function`. + +- [ ] **Step 3: Implement the method** + +Add to `src/gitlab/client.ts`, next to `getGroup`. Note the 500-item ceiling (`DEFAULT_PER_PAGE × DEFAULT_MAX_PAGES`) is a security cap — do not raise it. + +```ts + async getGroupProjects( + group: ProjectRef, + opts: { includeSubgroups?: boolean; archived?: boolean; perPage?: number; maxPages?: number } = {}, + ): Promise { + return this.api.Groups.allProjects(group, { + includeSubgroups: opts.includeSubgroups ?? false, + ...(opts.archived === undefined ? {} : { archived: opts.archived }), + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + orderBy: "path", + sort: "asc", + }); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/client.test.ts -t getGroupProjects` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/client.ts src/gitlab/client.test.ts +git commit -S -m "feat(client): add getGroupProjects for group project listing" +``` + +--- + +## Task 2: `GroupProjectData` domain type + +**Files:** +- Modify: `src/gitlab/types.ts` + +- [ ] **Step 1: Add the type** + +Append to `src/gitlab/types.ts`: + +```ts +export interface GroupProjectData { + id: number; + name: string; + path: string; + /** Full namespace path, e.g. "mygroup/team-x/acme-mobile". */ + pathWithNamespace: string; + /** Path relative to the queried group root, e.g. "team-x/acme-mobile". Used + * as both the generated file path and the card link target. */ + slug: string; + description: string | null; + webUrl: string; + starCount: number; + defaultBranch: string | null; + topics: string[]; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS (no usages yet; just confirms valid syntax). + +- [ ] **Step 3: Commit** + +```bash +git add src/gitlab/types.ts +git commit -S -m "feat(types): add GroupProjectData domain type" +``` + +--- + +## Task 3: `fetchGroupProjects` fetcher + +**Files:** +- Modify: `src/gitlab/fetchers.ts` +- Test: `src/gitlab/fetchers.test.ts` + +The fetcher lists a group's projects, normalizes snake_case → `GroupProjectData`, computes `slug` by stripping the group's `full_path` prefix from `path_with_namespace`, filters by topics client-side (matches **all** requested topics), and memoizes via the cache. It reads normalized attrs: `group`, `includeSubgroups`, `includeArchived`, `topics` (string[] or CSV string). + +- [ ] **Step 1: Write the failing test** + +Add to `src/gitlab/fetchers.test.ts` (reuse the existing `ctx(client)` helper at the top of that file; add `fetchGroupProjects` to the import from `./fetchers`): + +```ts +describe("fetchGroupProjects", () => { + const project = (over: any) => ({ + id: 1, name: "Acme Web", path: "acme-web", + path_with_namespace: "mygroup/acme-web", description: "web app", + web_url: "https://gitlab.com/mygroup/acme-web", star_count: 4, + default_branch: "main", topics: ["public-docs"], ...over, + }); + + function client(projects: any[]) { + return { + getGroup: vi.fn(async () => ({ full_path: "mygroup" })), + getGroupProjects: vi.fn(async () => projects), + }; + } + + it("normalizes projects and derives slug from the group prefix", async () => { + const c = ctx(client([ + project({}), + project({ id: 2, name: "Mobile", path: "acme-mobile", path_with_namespace: "mygroup/team-x/acme-mobile" }), + ])); + const data = await fetchGroupProjects(c, { group: "mygroup", includeSubgroups: true }); + expect(data.map((p) => p.slug)).toEqual(["acme-web", "team-x/acme-mobile"]); + expect(data[0]).toMatchObject({ id: 1, name: "Acme Web", pathWithNamespace: "mygroup/acme-web", starCount: 4, description: "web app" }); + }); + + it("filters to projects carrying all requested topics", async () => { + const c = ctx(client([ + project({ topics: ["public-docs", "featured"] }), + project({ id: 2, path: "hidden", path_with_namespace: "mygroup/hidden", topics: ["public-docs"] }), + ])); + const data = await fetchGroupProjects(c, { group: "mygroup", topics: "public-docs,featured" }); + expect(data.map((p) => p.path)).toEqual(["acme-web"]); + }); + + it("excludes archived by default (passes archived:false to the client)", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup" }); + expect(c.client.getGroupProjects).toHaveBeenCalledWith("mygroup", { includeSubgroups: false, archived: false }); + }); + + it("includes archived when includeArchived is true (archived undefined)", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup", includeArchived: true }); + expect(c.client.getGroupProjects).toHaveBeenCalledWith("mygroup", { includeSubgroups: false, archived: undefined }); + }); + + it("memoizes on the second call", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup" }); + await fetchGroupProjects(c, { group: "mygroup" }); + expect(c.client.getGroupProjects).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t fetchGroupProjects` +Expected: FAIL — `fetchGroupProjects is not exported`. + +- [ ] **Step 3: Implement the fetcher** + +Add to `src/gitlab/fetchers.ts`. Add `GroupProjectData` to the type import from `./types`. Place these near the other exported fetchers: + +```ts +/** Parse a topic list attribute: `["a","b"]`, `"a,b"`, or undefined → string[]. */ +function parseTopicList(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean); + if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean); + return []; +} + +function asBool(value: unknown): boolean { + return value === true || value === "true"; +} + +export async function fetchGroupProjects(ctx: GitLabContext, attrs: Attrs): Promise { + const group = attrs.group as string | number | undefined; + if (group === undefined) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: group projects require a "group".`); + } + const includeSubgroups = asBool(attrs.includeSubgroups); + const includeArchived = asBool(attrs.includeArchived); + const topics = parseTopicList(attrs.topics); + const key = `groupProjects:${String(group)}:sub=${includeSubgroups}:arch=${includeArchived}:t=${topics.join(",")}`; + return memo(ctx, key, async () => { + const info = await ctx.client.getGroup(group); + const prefix = `${String(info.full_path)}/`; + const raw = await ctx.client.getGroupProjects(group, { + includeSubgroups, + archived: includeArchived ? undefined : false, + }); + let items: GroupProjectData[] = raw.map((p: any) => { + const pathWithNamespace = String(p.path_with_namespace); + return { + id: p.id, + name: p.name, + path: p.path, + pathWithNamespace, + slug: pathWithNamespace.startsWith(prefix) ? pathWithNamespace.slice(prefix.length) : p.path, + description: p.description ?? null, + webUrl: p.web_url, + starCount: p.star_count ?? 0, + defaultBranch: p.default_branch ?? null, + topics: Array.isArray(p.topics) ? p.topics : [], + }; + }); + if (topics.length) items = items.filter((p) => topics.every((t) => p.topics.includes(t))); + items.sort((a, b) => a.slug.localeCompare(b.slug)); + return items; + }); +} +``` + +Note: `Attrs` is the local attribute type already used by the other fetchers in this file — reuse it (do not invent a new one). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/gitlab/fetchers.test.ts -t fetchGroupProjects` +Expected: PASS (all five cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/gitlab/fetchers.ts src/gitlab/fetchers.test.ts +git commit -S -m "feat(fetchers): add fetchGroupProjects with slug + topic filtering" +``` + +--- + +## Task 4: Directive parser + +**Files:** +- Create: `src/generate/directive.ts` +- Test: `src/generate/directive.test.ts` + +Parses the attribute string inside `{@generateGitlabPages …}` into a validated spec. Shared by the loader (Task 10) and the scanner/orchestrator (Tasks 7–8). + +- [ ] **Step 1: Write the failing test** + +Create `src/generate/directive.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseGeneratePages, SECTION_NAMES } from "./directive.js"; + +describe("parseGeneratePages", () => { + it("parses all attributes with quoted and bare values", () => { + const spec = parseGeneratePages( + `group=1 sections="info,readme,releases" topics="public-docs" includeSubgroups=true includeArchived=false basePath="projects"`, + ); + expect(spec).toEqual({ + group: "1", + sections: ["info", "readme", "releases"], + topics: ["public-docs"], + includeSubgroups: true, + includeArchived: false, + basePath: "projects", + }); + }); + + it("applies defaults: sections=[readme], no topics, flags false, basePath=projects", () => { + expect(parseGeneratePages(`group=42`)).toEqual({ + group: "42", + sections: ["readme"], + topics: [], + includeSubgroups: false, + includeArchived: false, + basePath: "projects", + }); + }); + + it("throws when group is missing", () => { + expect(() => parseGeneratePages(`sections="readme"`)).toThrow(/requires a "group"/); + }); + + it("throws on an unknown section", () => { + expect(() => parseGeneratePages(`group=1 sections="readme,bogus"`)).toThrow(/bogus/); + }); + + it("exposes the valid section names", () => { + expect(SECTION_NAMES).toEqual(["info", "readme", "releases", "issues"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/generate/directive.test.ts` +Expected: FAIL — cannot find module `./directive.js`. + +- [ ] **Step 3: Implement the parser** + +Create `src/generate/directive.ts`: + +```ts +export const SECTION_NAMES = ["info", "readme", "releases", "issues"] as const; +export type SectionName = (typeof SECTION_NAMES)[number]; + +export interface GeneratePagesSpec { + group: string; + sections: SectionName[]; + topics: string[]; + includeSubgroups: boolean; + includeArchived: boolean; + basePath: string; +} + +/** Tokenize `key=value` pairs; value may be "double"/'single' quoted or bare. */ +function parseAttrString(input: string): Record { + const out: Record = {}; + const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + out[m[1]] = m[2] ?? m[3] ?? m[4] ?? ""; + } + return out; +} + +function splitList(value: string | undefined): string[] { + if (!value) return []; + return value.split(",").map((s) => s.trim()).filter(Boolean); +} + +export function parseGeneratePages(attrString: string): GeneratePagesSpec { + const raw = parseAttrString(attrString); + if (!raw.group) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages} requires a "group".`); + } + const sections = splitList(raw.sections); + const resolvedSections = (sections.length ? sections : ["readme"]) as string[]; + for (const s of resolvedSections) { + if (!SECTION_NAMES.includes(s as SectionName)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages} unknown section "${s}"; ` + + `valid: ${SECTION_NAMES.join(", ")}.`, + ); + } + } + return { + group: raw.group, + sections: resolvedSections as SectionName[], + topics: splitList(raw.topics), + includeSubgroups: raw.includeSubgroups === "true", + includeArchived: raw.includeArchived === "true", + basePath: raw.basePath || "projects", + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/directive.test.ts` +Expected: PASS (all five cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/generate/directive.ts src/generate/directive.test.ts +git commit -S -m "feat(generate): add generateGitlabPages directive parser" +``` + +--- + +## Task 5: Child page renderer + +**Files:** +- Create: `src/generate/render-page.ts` +- Test: `src/generate/render-page.test.ts` + +Renders one child `.mdx` string: YAML frontmatter + an auto-generated marker comment + one `` component per requested section, using the project's `pathWithNamespace` as the `project` attribute. + +- [ ] **Step 1: Write the failing test** + +Create `src/generate/render-page.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renderChildPage } from "./render-page.js"; + +const project = { + id: 1, name: "Acme Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", + slug: "acme-web", description: 'A "web" app', webUrl: "https://gitlab.com/mygroup/acme-web", + starCount: 4, defaultBranch: "main", topics: [], +}; + +describe("renderChildPage", () => { + it("emits frontmatter, the marker, and one component per section in order", () => { + const out = renderChildPage(project as any, ["info", "readme"]); + expect(out).toContain('title: "Acme Web"'); + expect(out).toContain('description: "A \\"web\\" app"'); + expect(out).toContain("AUTO-GENERATED"); + const info = out.indexOf(''); + const readme = out.indexOf(''); + expect(info).toBeGreaterThan(-1); + expect(readme).toBeGreaterThan(info); + }); + + it("omits the description key when the project has none", () => { + const out = renderChildPage({ ...project, description: null } as any, ["readme"]); + expect(out).not.toContain("description:"); + }); + + it("maps every section name to its component", () => { + const out = renderChildPage(project as any, ["info", "readme", "releases", "issues"]); + expect(out).toContain(" = { + info: "GitlabProjectInfo", + readme: "GitlabReadme", + releases: "GitlabReleases", + issues: "GitlabIssues", +}; + +/** JSON.stringify yields a valid double-quoted YAML scalar for simple strings. */ +function yamlString(value: string): string { + return JSON.stringify(value); +} + +export function renderChildPage(project: GroupProjectData, sections: SectionName[]): string { + const frontmatter = [ + "---", + `title: ${yamlString(project.name)}`, + ...(project.description ? [`description: ${yamlString(project.description)}`] : []), + "---", + ].join("\n"); + + const body = sections + .map((s) => `<${SECTION_COMPONENT[s]} project="${project.pathWithNamespace}" />`) + .join("\n"); + + return `${frontmatter}\n{/* AUTO-GENERATED by @ebuildy/docusaurus-plugin-gitlab — do not edit */}\n\n${body}\n`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/render-page.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/generate/render-page.ts src/generate/render-page.test.ts +git commit -S -m "feat(generate): render per-project child page from sections" +``` + +--- + +## Task 6: File-tree writer + +**Files:** +- Create: `src/generate/write.ts` +- Test: `src/generate/write.test.ts` + +Writes the generated tree into a target directory: an ownership marker (`.gitlab-generated`), a `.gitignore` (`*`), a root `_category_.json` (label = group label), one `_category_.json` per subgroup directory (label = the path segment), and one `.mdx` per project. Regeneration is idempotent: if the target dir already exists **and** carries the marker it is removed and rewritten; if it exists **without** the marker, throw (never clobber hand-authored docs). + +- [ ] **Step 1: Write the failing test** + +Create `src/generate/write.test.ts`: + +```ts +import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { writeProjectPages } from "./write.js"; + +const projects = [ + { id: 1, name: "Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", slug: "acme-web", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, + { id: 2, name: "Mobile", path: "acme-mobile", pathWithNamespace: "mygroup/team-x/acme-mobile", slug: "team-x/acme-mobile", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, +]; + +function target() { + return join(mkdtempSync(join(tmpdir(), "glgen-")), "projects"); +} + +describe("writeProjectPages", () => { + it("writes marker, gitignore, root category, nested pages, and subgroup category", () => { + const dir = target(); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "My Group" }); + + expect(existsSync(join(dir, ".gitlab-generated"))).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8").trim()).toBe("*"); + expect(JSON.parse(readFileSync(join(dir, "_category_.json"), "utf8")).label).toBe("My Group"); + expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); + expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, "team-x", "_category_.json"), "utf8")).label).toBe("team-x"); + expect(readFileSync(join(dir, "acme-web.mdx"), "utf8")).toContain(''); + }); + + it("is idempotent: regenerating removes stale files from a previously-owned dir", () => { + const dir = target(); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + writeProjectPages([projects[0]] as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); + expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(false); + }); + + it("refuses to overwrite a directory it does not own", () => { + const dir = target(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "handwritten.mdx"), "keep me"); + expect(() => writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" })).toThrow(/not generated by/i); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/generate/write.test.ts` +Expected: FAIL — cannot find module `./write.js`. + +- [ ] **Step 3: Implement the writer** + +Create `src/generate/write.ts`: + +```ts +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { GroupProjectData } from "../gitlab/types.js"; +import type { SectionName } from "./directive.js"; +import { renderChildPage } from "./render-page.js"; + +const MARKER = ".gitlab-generated"; + +export interface WriteOptions { + targetDir: string; + sections: SectionName[]; + /** Label for the root `_category_.json` (e.g. the GitLab group name). */ + groupLabel: string; +} + +function writeCategory(dir: string, label: string): void { + writeFileSync(join(dir, "_category_.json"), `${JSON.stringify({ label }, null, 2)}\n`); +} + +/** Ensure every intermediate dir of `slug` exists and carries a `_category_.json`. */ +function ensureSlugDirs(targetDir: string, slug: string): void { + const segments = slug.split("/"); + segments.pop(); // drop the file segment + let current = targetDir; + for (const seg of segments) { + current = join(current, seg); + if (!existsSync(current)) { + mkdirSync(current, { recursive: true }); + writeCategory(current, seg); + } + } +} + +export function writeProjectPages(projects: GroupProjectData[], opts: WriteOptions): string[] { + const { targetDir } = opts; + + if (existsSync(targetDir)) { + if (!existsSync(join(targetDir, MARKER))) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: refusing to overwrite "${targetDir}" — ` + + `it was not generated by this plugin (missing ${MARKER}).`, + ); + } + rmSync(targetDir, { recursive: true, force: true }); + } + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(targetDir, MARKER), ""); + writeFileSync(join(targetDir, ".gitignore"), "*\n"); + writeCategory(targetDir, opts.groupLabel); + + const written: string[] = []; + for (const project of projects) { + ensureSlugDirs(targetDir, project.slug); + const file = join(targetDir, `${project.slug}.mdx`); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, renderChildPage(project, opts.sections)); + written.push(file); + } + return written; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/write.test.ts` +Expected: PASS (all three cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/generate/write.ts src/generate/write.test.ts +git commit -S -m "feat(generate): write nested project page tree with ownership guard" +``` + +--- + +## Task 7: Docs scanner + +**Files:** +- Create: `src/generate/scan.ts` +- Test: `src/generate/scan.test.ts` + +Finds every `{@generateGitlabPages …}` occurrence under a docs directory, returning the file path, the parsed spec, and the target dir (`/`). Uses a recursive readdir (no new deps). + +- [ ] **Step 1: Write the failing test** + +Create `src/generate/scan.test.ts`: + +```ts +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { scanGeneratePages } from "./scan.js"; + +function site() { + const root = mkdtempSync(join(tmpdir(), "glscan-")); + const docs = join(root, "docs"); + mkdirSync(join(docs, "sub"), { recursive: true }); + return { docs }; +} + +describe("scanGeneratePages", () => { + it("finds the directive and computes its target dir from basePath", () => { + const { docs } = site(); + writeFileSync(join(docs, "index.mdx"), `# Projects\n\n{@generateGitlabPages group=1 basePath="apps"}\n`); + writeFileSync(join(docs, "sub", "other.md"), `no directive here`); + + const hits = scanGeneratePages(docs); + expect(hits).toHaveLength(1); + expect(hits[0].file).toBe(join(docs, "index.mdx")); + expect(hits[0].spec.group).toBe("1"); + expect(hits[0].targetDir).toBe(join(docs, "apps")); + }); + + it("defaults the target dir to /projects", () => { + const { docs } = site(); + writeFileSync(join(docs, "sub", "page.mdx"), `{@generateGitlabPages group=7}`); + const hits = scanGeneratePages(docs); + expect(hits[0].targetDir).toBe(join(docs, "sub", "projects")); + }); + + it("returns nothing when the docs dir has no directive", () => { + const { docs } = site(); + writeFileSync(join(docs, "plain.mdx"), `just docs`); + expect(scanGeneratePages(docs)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/generate/scan.test.ts` +Expected: FAIL — cannot find module `./scan.js`. + +- [ ] **Step 3: Implement the scanner** + +Create `src/generate/scan.ts`: + +```ts +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { parseGeneratePages, type GeneratePagesSpec } from "./directive.js"; + +/** Global matcher for the directive; capture group 1 is the attribute string. */ +export const GENERATE_RE = /\{@generateGitlabPages\s+([^}]*)\}/g; + +export interface GeneratePagesHit { + file: string; + spec: GeneratePagesSpec; + /** Directory the generated tree is written into (`/`). */ + targetDir: string; +} + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (/\.mdx?$/.test(entry.name)) out.push(full); + } + return out; +} + +export function scanGeneratePages(docsDir: string): GeneratePagesHit[] { + if (!existsSync(docsDir)) return []; + const hits: GeneratePagesHit[] = []; + for (const file of walk(docsDir)) { + const source = readFileSync(file, "utf8"); + if (!source.includes("{@generateGitlabPages")) continue; + for (const m of source.matchAll(GENERATE_RE)) { + const spec = parseGeneratePages(m[1]); + hits.push({ file, spec, targetDir: join(dirname(file), spec.basePath) }); + } + } + return hits; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/scan.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/generate/scan.ts src/generate/scan.test.ts +git commit -S -m "feat(generate): scan docs dir for generateGitlabPages directives" +``` + +--- + +## Task 8: Generation orchestrator + +**Files:** +- Create: `src/generate/index.ts` +- Test: `src/generate/index.test.ts` + +Ties it together: for each directive hit, fetch the project list via `fetchGroupProjects` and write the tree via `writeProjectPages`. Uses the group's display name (from `getGroup`) as the root category label. Takes a `GitLabContext`, a docs dir, and options `{ strict }` so it is fully testable with a fake client. Per the error contract, a hit that fails rethrows in `strict` mode (aborting the build) but is logged and skipped otherwise. + +- [ ] **Step 1: Write the failing test** + +Create `src/generate/index.test.ts`: + +```ts +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, vi } from "vitest"; +import { FileCache } from "../gitlab/cache.js"; +import { generateAll } from "./index.js"; + +function ctx() { + const dir = mkdtempSync(join(tmpdir(), "glorch-")); + const client = { + getGroup: vi.fn(async () => ({ full_path: "mygroup", name: "My Group" })), + getGroupProjects: vi.fn(async () => [ + { id: 1, name: "Web", path: "acme-web", path_with_namespace: "mygroup/acme-web", description: null, web_url: "", star_count: 0, default_branch: "main", topics: [] }, + ]), + }; + return { + client, + cache: new FileCache(join(dir, "c"), { ttl: 60 }), + options: { host: "https://gitlab.com" }, + assets: { localize: vi.fn() }, + } as any; +} + +describe("generateAll", () => { + it("generates a page tree for each directive using the group name as label", async () => { + const c = ctx(); + const root = mkdtempSync(join(tmpdir(), "glsite-")); + const docs = join(root, "docs"); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1 sections="readme"}`); + + const result = await generateAll(c, docs, { strict: true }); + + expect(result.pagesWritten).toBe(1); + expect(existsSync(join(docs, "projects", "acme-web.mdx"))).toBe(true); + expect(c.client.getGroupProjects).toHaveBeenCalled(); + }); + + it("does nothing when there are no directives", async () => { + const c = ctx(); + const docs = mkdtempSync(join(tmpdir(), "glempty-")); + const result = await generateAll(c, docs, { strict: true }); + expect(result.pagesWritten).toBe(0); + }); + + it("rethrows a hit failure in strict mode", async () => { + const c = ctx(); + c.client.getGroup = vi.fn(async () => { throw new Error("boom"); }); + const docs = mkdtempSync(join(tmpdir(), "glstrict-")); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1}`); + await expect(generateAll(c, docs, { strict: true })).rejects.toThrow(/boom/); + }); + + it("logs and skips a hit failure when not strict", async () => { + const c = ctx(); + c.client.getGroup = vi.fn(async () => { throw new Error("boom"); }); + const docs = mkdtempSync(join(tmpdir(), "glnostrict-")); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1}`); + const result = await generateAll(c, docs, { strict: false }); + expect(result.pagesWritten).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/generate/index.test.ts` +Expected: FAIL — cannot find module `./index.js`. + +- [ ] **Step 3: Implement the orchestrator** + +Create `src/generate/index.ts`: + +```ts +import type { GitLabContext } from "../gitlab/fetchers.js"; +import { fetchGroupProjects } from "../gitlab/fetchers.js"; +import { scanGeneratePages } from "./scan.js"; +import { writeProjectPages } from "./write.js"; + +export interface GenerateResult { + directives: number; + pagesWritten: number; +} + +export interface GenerateOptions { + /** In strict mode a failed hit rethrows (aborts the build); otherwise it is skipped. */ + strict: boolean; +} + +export async function generateAll( + ctx: GitLabContext, + docsDir: string, + opts: GenerateOptions, +): Promise { + const hits = scanGeneratePages(docsDir); + let pagesWritten = 0; + for (const hit of hits) { + const { spec } = hit; + try { + const projects = await fetchGroupProjects(ctx, { + group: spec.group, + includeSubgroups: spec.includeSubgroups, + includeArchived: spec.includeArchived, + topics: spec.topics, + }); + const info = await ctx.client.getGroup(spec.group); + writeProjectPages(projects, { + targetDir: hit.targetDir, + sections: spec.sections, + groupLabel: String(info.name ?? spec.group), + }); + pagesWritten += projects.length; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (opts.strict) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages group=${spec.group}} in ${hit.file} failed — ${message}`, + ); + } + // eslint-disable-next-line no-console + console.warn( + `@ebuildy/docusaurus-plugin-gitlab: skipping {@generateGitlabPages group=${spec.group}} in ${hit.file} — ${message}`, + ); + } + } + return { directives: hits.length, pagesWritten }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/index.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/generate/index.ts src/generate/index.test.ts +git commit -S -m "feat(generate): orchestrate scan + fetch + write for all directives" +``` + +--- + +## Task 9: `GitlabProjectGrid` component + registration + +**Files:** +- Create: `src/components/GitlabProjectGrid.tsx` +- Test: `src/components/GitlabProjectGrid.test.tsx` +- Modify: `src/remark/registry.ts`, `src/components/index.ts`, `src/index.ts` + +Pure card-grid component. Reads `data: GroupProjectData[]` (injected by remark) plus a `basePath` prop (default `"projects"`). Each card links to `${basePath}/${slug}` — a relative URL that resolves to the generated page because the generated tree is written as a `/` subfolder of the index page (see Task 6/7). + +- [ ] **Step 1: Write the failing component test** + +Create `src/components/GitlabProjectGrid.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabProjectGrid } from "./GitlabProjectGrid"; + +const projects = [ + { id: 1, name: "Acme Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", slug: "acme-web", description: "web app", webUrl: "https://x/mygroup/acme-web", starCount: 4, defaultBranch: "main", topics: [] }, + { id: 2, name: "Mobile", path: "acme-mobile", pathWithNamespace: "mygroup/team-x/acme-mobile", slug: "team-x/acme-mobile", description: null, webUrl: "https://x/mygroup/team-x/acme-mobile", starCount: 0, defaultBranch: "main", topics: [] }, +]; + +describe("GitlabProjectGrid", () => { + it("renders a card per project linking to its generated page under basePath", () => { + render(); + const web = screen.getByRole("link", { name: /Acme Web/ }); + expect(web).toHaveAttribute("href", "apps/acme-web"); + const mobile = screen.getByRole("link", { name: /Mobile/ }); + expect(mobile).toHaveAttribute("href", "apps/team-x/acme-mobile"); + }); + + it("defaults basePath to 'projects' and shows description + star count", () => { + render(); + expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "projects/acme-web"); + expect(screen.getByText("web app")).toBeInTheDocument(); + expect(screen.getByText("4")).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 test to verify it fails** + +Run: `npx vitest run src/components/GitlabProjectGrid.test.tsx` +Expected: FAIL — cannot find module `./GitlabProjectGrid`. + +- [ ] **Step 3: Implement the component** + +Create `src/components/GitlabProjectGrid.tsx`: + +```tsx +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, GroupProjectData } from "./types.js"; + +interface GridProps extends ComponentPayload { + basePath?: string; +} + +export function GitlabProjectGrid({ data, error, basePath = "projects" }: GridProps) { + if (error) return ; + if (!data) return null; + return ( + + ); +} +``` + +- [ ] **Step 4: Add `GroupProjectData` to the component type re-exports** + +In `src/components/types.ts`, add `GroupProjectData` to the `export type { … } from "../gitlab/types.js";` list. + +- [ ] **Step 5: Register the fetcher and export the component** + +In `src/remark/registry.ts`: add `fetchGroupProjects` to the import from `../gitlab/fetchers.js` and add the registry entry: + +```ts + GitlabProjectGrid: fetchGroupProjects, +``` + +In `src/components/index.ts`: add `export { GitlabProjectGrid } from "./GitlabProjectGrid.js";` and add `GroupProjectData` to the `export type { … } from "./types.js";` list. + +In `src/index.ts`: add `GroupProjectData` to the `export type { … } from "./gitlab/types.js";` list. + +- [ ] **Step 6: Run the component test + typecheck** + +Run: `npx vitest run src/components/GitlabProjectGrid.test.tsx && npm run typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/GitlabProjectGrid.tsx src/components/GitlabProjectGrid.test.tsx src/components/types.ts src/components/index.ts src/remark/registry.ts src/index.ts +git commit -S -m "feat(components): add GitlabProjectGrid card component + registry entry" +``` + +--- + +## Task 10: Loader rewrite of the directive → `` + +**Files:** +- Create: `src/generate/rewrite.ts` +- Test: `src/generate/rewrite.test.ts` +- Modify: `src/include/loader.ts` +- Modify: `src/include/loader.test.ts` (if present; otherwise skip the loader-integration assertion) + +The loader currently only handles `{@includeGitlab…}`. Add a pure text rewrite that turns each `{@generateGitlabPages …}` into a `` JSX element so the remark plugin fetches + injects its data. This is synchronous and does no network I/O. + +- [ ] **Step 1: Write the failing test for the rewrite** + +Create `src/generate/rewrite.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { rewriteGeneratePages } from "./rewrite.js"; + +describe("rewriteGeneratePages", () => { + it("rewrites the directive into a GitlabProjectGrid element with literal attrs", () => { + const out = rewriteGeneratePages( + `# Projects\n\n{@generateGitlabPages group=1 sections="info,readme" topics="x" includeSubgroups=true basePath="apps"}\n`, + ); + expect(out).toContain( + ``, + ); + expect(out).not.toContain("{@generateGitlabPages"); + }); + + it("returns the source unchanged when no directive is present", () => { + const src = `# Just docs\n`; + expect(rewriteGeneratePages(src)).toBe(src); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/generate/rewrite.test.ts` +Expected: FAIL — cannot find module `./rewrite.js`. + +- [ ] **Step 3: Implement the rewrite** + +Create `src/generate/rewrite.ts`: + +```ts +import { parseGeneratePages } from "./directive.js"; +import { GENERATE_RE } from "./scan.js"; + +/** Replace each `{@generateGitlabPages …}` with a `` element. */ +export function rewriteGeneratePages(source: string): string { + if (!source.includes("{@generateGitlabPages")) return source; + return source.replace(GENERATE_RE, (_full, attrs: string) => { + const spec = parseGeneratePages(attrs); + return ( + `` + ); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/generate/rewrite.test.ts` +Expected: PASS. + +- [ ] **Step 5: Wire the rewrite into the loader** + +In `src/include/loader.ts`, apply the rewrite to `source` before the `{@includeGitlab}` early-return, so a page that *only* has the generate directive is still rewritten. Add the import and adjust the body: + +```ts +import { rewriteGeneratePages } from "../generate/rewrite.js"; +``` + +Replace the current early section of `gitlabIncludeLoader` from `const { resolved, processorsId } = this.getOptions();` through the `if (!source.includes("{@includeGitlab"))` block with: + +```ts + const { resolved, processorsId } = this.getOptions(); + + const rewritten = rewriteGeneratePages(source); + + if (!rewritten.includes("{@includeGitlab")) { + callback(null, rewritten); + return; + } +``` + +Then change the final `transformIncludes(source, …)` call to use `rewritten` instead of `source`. + +- [ ] **Step 6: Run the loader tests + typecheck** + +Run: `npx vitest run src/include/loader.test.ts && npm run typecheck` +Expected: PASS. (If `loader.test.ts` has a "passthrough when no directive" test, confirm it still passes — a plain source with neither directive returns unchanged.) + +- [ ] **Step 7: Commit** + +```bash +git add src/generate/rewrite.ts src/generate/rewrite.test.ts src/include/loader.ts +git commit -S -m "feat(loader): rewrite generateGitlabPages directive to GitlabProjectGrid" +``` + +--- + +## Task 11: Plugin init generation + CLI command + +**Files:** +- Modify: `src/plugin/index.ts` +- Test: `src/plugin/index.test.ts` + +Make the plugin generate pages during init (before Docusaurus scans docs) and expose the same work as `docusaurus gitlab:generate`. Generation must run at most once per process (`generateOnce`). Build the `GitLabContext` from resolved options via the existing `buildContext`, and derive the docs dir as `/docs` (Docusaurus default). + +- [ ] **Step 1: Write the failing test** + +Add to `src/plugin/index.test.ts`. Because generation touches the network/filesystem, the test points the plugin at an empty temp "site" (no docs dir / no directives) and asserts init completes and returns the plugin object with `extendCli`. This keeps the unit test hermetic; the real generation path is covered by Task 8's orchestrator test and Task 12's e2e. + +```ts +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +it("is an async plugin that returns the plugin object and registers a CLI command", async () => { + const siteDir = mkdtempSync(join(tmpdir(), "glplugin-")); + const plugin = await gitlabPlugin({ siteDir } as any, opts); + expect(plugin.name).toBe("@ebuildy/docusaurus-plugin-gitlab"); + + const registered: string[] = []; + const cli = { command: (name: string) => { + registered.push(name); + const chain: any = { description: () => chain, action: () => chain }; + return chain; + } }; + plugin.extendCli?.(cli as any); + expect(registered).toContain("gitlab:generate"); +}); +``` + +Note: the existing synchronous tests call `gitlabPlugin(ctx, opts)` without `await`. Since the function becomes `async`, those calls now return a Promise. Update each existing test that reads `.configureWebpack`/`.name`/`.getClientModules` to `await gitlabPlugin(...)` first (e.g. `const p = await gitlabPlugin(ctx, opts);`). The `ruleOptions` helper becomes `async` and its callers `await` it. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/plugin/index.test.ts` +Expected: FAIL — `gitlabPlugin(...).name` is undefined (a Promise) / `extendCli` missing. + +- [ ] **Step 3: Implement async init + generation + CLI** + +Edit `src/plugin/index.ts`. Add imports: + +```ts +import { buildContext } from "../gitlab/context.js"; +import { generateAll } from "../generate/index.js"; +``` + +Add a module-level once-guard below `processorSeq`: + +```ts +let generated = false; + +async function generateOnce(resolved: ReturnType, siteDir: string): Promise { + if (generated) return; + generated = true; + const ctx = buildContext(resolved); + await generateAll(ctx, path.join(siteDir, "docs"), { strict: resolved.strict }); +} +``` + +Change the signature to `async` and run generation before returning the plugin object, and add `extendCli`: + +```ts +export default async function gitlabPlugin(context: unknown, options: PluginOptions) { + const mode = process.env.NODE_ENV === "production" ? "production" : "development"; + const resolved = resolveOptions(options, mode); + const siteDir = (context as PluginContextLike | undefined)?.siteDir ?? process.cwd(); + + const processorsId = `gitlab-out-${processorSeq++}`; + registerOutProcessors(processorsId, options.outProcessors ?? []); + + // Generate pages before Docusaurus's docs plugin scans the filesystem, so the + // generated tree + `_category_.json` files feed the autogenerated sidebar. + await generateOnce(resolved, siteDir); + + return { + name: "@ebuildy/docusaurus-plugin-gitlab", + + getClientModules() { + return [path.resolve(dirname, "../../theme.css")]; + }, + + extendCli(cli: any) { + cli + .command("gitlab:generate") + .description("Generate Docusaurus pages from GitLab groups (@ebuildy/docusaurus-plugin-gitlab)") + .action(async () => { + await generateOnce(resolved, siteDir); + }); + }, + + configureWebpack(..._args: unknown[]) { + /* unchanged — keep the existing body verbatim */ + }, + }; +} +``` + +Keep the existing `configureWebpack` body exactly as it is. + +- [ ] **Step 4: Update the existing plugin tests to await** + +Apply the `await` changes described in Step 1's note so all existing assertions still pass against the now-async factory. + +- [ ] **Step 5: Run the plugin tests + typecheck** + +Run: `npx vitest run src/plugin/index.test.ts && npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/plugin/index.ts src/plugin/index.test.ts +git commit -S -m "feat(plugin): generate group pages at init and via gitlab:generate CLI" +``` + +--- + +## Task 12: Example site page, e2e, and docs + +The e2e (`test/e2e/build.test.ts`) builds `examples/site` in a child process against an **in-process GitLab stub** (`test/e2e/fixtures.ts`), not real gitlab.com. So this task (a) extends the stub to serve group projects, (b) adds an index page with the directive, (c) asserts the generated tree + card grid, and (d) documents the feature. + +**Files:** +- Modify: `test/e2e/fixtures.ts` +- Create: `examples/site/docs/generate/projects.mdx` +- Modify: `test/e2e/build.test.ts` +- Modify: `README.md` + +- [ ] **Step 1: Extend the stub to serve group projects + group name** + +In `test/e2e/fixtures.ts`: the group `my-group` list endpoint must be added **before** the existing bare `/api/v4/groups/my-group` handler (more specific first), and the bare group handler must return `name` + `full_path` so the generator can label the category and derive slugs. Add this block immediately above the existing `if (url.startsWith("/api/v4/groups/my-group/labels"))` line: + +```ts + if (url.startsWith("/api/v4/groups/my-group/projects")) { + return send([ + { + id: 1, name: "Repo", path: "repo", path_with_namespace: "group/repo", + description: "Desc", web_url: "https://x/group/repo", star_count: 5, + default_branch: "main", topics: ["docs"], + }, + ]); + } +``` + +And replace the existing bare group handler: + +```ts + if (url.startsWith("/api/v4/groups/my-group")) { + return send({ id: 42, web_url: "https://x/groups/my-group" }); + } +``` + +with one that includes `name` + `full_path`: + +```ts + if (url.startsWith("/api/v4/groups/my-group")) { + return send({ id: 42, name: "My Group", full_path: "my-group", web_url: "https://x/groups/my-group" }); + } +``` + +Note on slug: `full_path` is `my-group` but the project's `path_with_namespace` is `group/repo`, which does not start with `my-group/`, so the fetcher falls back to `p.path` → slug `repo`. The generated page is therefore `generate/projects/repo.mdx` referencing `project="group/repo"`, which the existing project/readme/info stub routes already serve. + +- [ ] **Step 2: Add an example index page using the directive** + +Create `examples/site/docs/generate/projects.mdx`: + +```mdx +--- +title: Group projects +--- + +# Our GitLab projects + +{@generateGitlabPages group="my-group" sections="info,readme" includeSubgroups=false} +``` + +- [ ] **Step 3: Add cleanup for the generated folder** + +The generated subfolder `examples/site/docs/generate/projects/` is written during the build and is git-ignored. Clean it in the e2e's `beforeAll` (before the build) and `afterAll`, alongside the existing `rmSync` calls. Add this line to both hooks: + +```ts + rmSync(join(siteDir, "docs", "generate", "projects"), { recursive: true, force: true }); +``` + +(The committed `examples/site/docs/generate/projects.mdx` index page is source and must NOT be removed — only the `projects/` subfolder.) + +- [ ] **Step 4: Add e2e assertions** + +Add a new test to the `describe("e2e: docusaurus build", …)` block in `test/e2e/build.test.ts`. It asserts the generation ran (child page on disk), the child page built (README baked in), and the index page rendered the card grid: + +```ts + it("generates a page per group project and a card grid on the index page", () => { + // The generator wrote the child page into the docs tree during the build. + const childSource = join(siteDir, "docs", "generate", "projects", "repo.mdx"); + expect(readFileSync(childSource, "utf8")).toContain(''); + + // The child page built and baked in the README. + const childHtml = readFileSync(join(siteDir, "build", "generate", "projects", "repo", "index.html"), "utf8"); + expect(childHtml).toContain("Readme body"); + + // The index page rendered the card grid linking to the generated child page. + const indexHtml = readFileSync(join(siteDir, "build", "generate", "projects", "index.html"), "utf8"); + expect(indexHtml).toContain("gitlab-project-grid"); + expect(indexHtml).toContain('href="projects/repo"'); + expect(indexHtml).toContain("Repo"); + }); +``` + +Note: the index doc `docs/generate/projects.mdx` builds to `build/generate/projects/index.html` (Docusaurus emits `/index.html`). If the exact output path differs in this Docusaurus version, adjust by inspecting the `build/generate/` tree; `readdirSync` the folder to confirm. + +- [ ] **Step 5: Run the e2e (slow, ~1 min)** + +Run: `npx vitest run test/e2e/build.test.ts` +Expected: PASS, including the new assertion. If the index HTML path differs, correct it per the Step 4 note and re-run. + +- [ ] **Step 6: Document the feature in the README** + +Add a "Generating pages from a group" section to `README.md` following the style of the existing component sections. Include: +- The directive with its full attribute table (`group`, `sections`, `topics`, `includeSubgroups`, `includeArchived`, `basePath`). +- A note that generated files land in a git-ignored `/` folder next to the index page and are regenerated every build. +- The `docusaurus gitlab:generate` CLI command. +- A note that the index page renders a `GitlabProjectGrid` card grid in place of the directive. + +- [ ] **Step 7: Full verification** + +Run: `npx vitest run && npm run typecheck && npm run build` +Expected: All tests PASS, typecheck clean, build emits `dist/generate/*.js` + `.d.ts`. + +- [ ] **Step 8: Commit** + +```bash +git add test/e2e/fixtures.ts examples/site/docs/generate/projects.mdx test/e2e/build.test.ts README.md +git commit -S -m "docs: example + e2e + README for group page generation" +``` + +--- + +## Final verification checklist + +- [ ] `npx vitest run` — all unit + component tests pass. +- [ ] `npm run typecheck` — clean. +- [ ] `npm run build` — ESM `.js` + `.d.ts` emitted for `src/generate/*` and the new component. +- [ ] `npx vitest run test/e2e/build.test.ts` — builds `examples/site` against the in-process GitLab stub. +- [ ] `git log --format="%G?" | head` — every new commit shows `G` (GPG-signed). +- [ ] Run `graphify update .` to refresh the knowledge graph. + +## Notes / assumptions locked in + +- **Relative card links work** because generated children live in a `/` subfolder of the index page, exactly mirroring the URL path; `href="${basePath}/${slug}"` resolves correctly under Docusaurus's default (no trailing slash) doc URLs. If a site sets `trailingSlash: true`, links would need a leading `./` — out of scope here; note it in the README. +- **500-project cap** (perPage 100 × maxPages 5) is a deliberate security ceiling shared with topics/labels — do not raise it. +- **Subgroup category labels** use the path segment (e.g. `team-x`), not the subgroup display name, for deterministic output without extra API calls. A future enhancement could fetch subgroup names. +- **Ownership guard:** the writer only ever deletes a target dir that carries the `.gitlab-generated` marker; a collision with hand-authored docs throws instead of clobbering. From 5cc755ab52326e81cfdde8667dbf213775b67e84 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:33:02 +0200 Subject: [PATCH 06/22] feat(client): add getGroupProjects for group project listing --- src/gitlab/client.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/gitlab/client.ts | 14 ++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/gitlab/client.test.ts b/src/gitlab/client.test.ts index 0146bbb..2ecc111 100644 --- a/src/gitlab/client.test.ts +++ b/src/gitlab/client.test.ts @@ -224,3 +224,39 @@ describe("GitLabClient", () => { expect(await client.getContributorsCount("g/r")).toBeUndefined(); }); }); + +describe("getGroupProjects", () => { + it("requests group projects with subgroup recursion and archived filter", async () => { + const client = new GitLabClient({ host: "https://gitlab.com" }); + const allProjects = vi.fn(async () => [{ id: 1, path: "a" }]); + (client as any).api = { Groups: { allProjects } }; + + const res = await client.getGroupProjects(1, { includeSubgroups: true, archived: false }); + + expect(res).toEqual([{ id: 1, path: "a" }]); + expect(allProjects).toHaveBeenCalledWith(1, { + includeSubgroups: true, + archived: false, + perPage: 100, + maxPages: 5, + orderBy: "path", + sort: "asc", + }); + }); + + it("omits archived filter when includeArchived is requested (archived undefined)", async () => { + const client = new GitLabClient({ host: "https://gitlab.com" }); + const allProjects = vi.fn(async () => []); + (client as any).api = { Groups: { allProjects } }; + + await client.getGroupProjects("grp", { includeSubgroups: false }); + + expect(allProjects).toHaveBeenCalledWith("grp", { + includeSubgroups: false, + perPage: 100, + maxPages: 5, + orderBy: "path", + sort: "asc", + }); + }); +}); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts index 9be03ef..5ee0352 100644 --- a/src/gitlab/client.ts +++ b/src/gitlab/client.ts @@ -102,6 +102,20 @@ export class GitLabClient { return this.api.Groups.show(group); } + async getGroupProjects( + group: ProjectRef, + opts: { includeSubgroups?: boolean; archived?: boolean; perPage?: number; maxPages?: number } = {}, + ): Promise { + return this.api.Groups.allProjects(group, { + includeSubgroups: opts.includeSubgroups ?? false, + ...(opts.archived === undefined ? {} : { archived: opts.archived }), + perPage: opts.perPage ?? DEFAULT_PER_PAGE, + maxPages: opts.maxPages ?? DEFAULT_MAX_PAGES, + orderBy: "path", + sort: "asc", + }); + } + private headers(): Record { const h: Record = { Accept: "application/json" }; if (this.config.token) h["PRIVATE-TOKEN"] = this.config.token; From 303eebb3d71297e03efe74adb3e6c773ca191c93 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:35:41 +0200 Subject: [PATCH 07/22] feat(types): add GroupProjectData domain type --- src/gitlab/types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index f2f8692..047b942 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -107,3 +107,19 @@ export interface LabelData { description: string | null; webUrl: string; } + +export interface GroupProjectData { + id: number; + name: string; + path: string; + /** Full namespace path, e.g. "mygroup/team-x/acme-mobile". */ + pathWithNamespace: string; + /** Path relative to the queried group root, e.g. "team-x/acme-mobile". Used + * as both the generated file path and the card link target. */ + slug: string; + description: string | null; + webUrl: string; + starCount: number; + defaultBranch: string | null; + topics: string[]; +} From 67161db9a44bb9949d9939da0a4971a77331ac43 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:38:07 +0200 Subject: [PATCH 08/22] feat(fetchers): add fetchGroupProjects with slug + topic filtering --- src/gitlab/fetchers.test.ts | 56 ++++++++++++++++++++++++++++++++++++- src/gitlab/fetchers.ts | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/gitlab/fetchers.test.ts b/src/gitlab/fetchers.test.ts index 1b2fe68..6ebf236 100644 --- a/src/gitlab/fetchers.test.ts +++ b/src/gitlab/fetchers.test.ts @@ -6,7 +6,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { describe, it, expect, vi } from "vitest"; import { FileCache } from "./cache"; -import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels } from "./fetchers"; +import { fetchProjectInfo, fetchReleases, fetchIssues, fetchCommits, fetchReadme, fetchFile, fetchTopics, fetchLabels, fetchGroupProjects } from "./fetchers"; function ctx(client: any) { const dir = mkdtempSync(join(tmpdir(), "glfetch-")); @@ -628,3 +628,57 @@ describe("fetchLabels", () => { ).rejects.toThrow(/layout/); }); }); + +describe("fetchGroupProjects", () => { + const project = (over: any) => ({ + id: 1, name: "Acme Web", path: "acme-web", + path_with_namespace: "mygroup/acme-web", description: "web app", + web_url: "https://gitlab.com/mygroup/acme-web", star_count: 4, + default_branch: "main", topics: ["public-docs"], ...over, + }); + + function client(projects: any[]) { + return { + getGroup: vi.fn(async () => ({ full_path: "mygroup" })), + getGroupProjects: vi.fn(async () => projects), + }; + } + + it("normalizes projects and derives slug from the group prefix", async () => { + const c = ctx(client([ + project({}), + project({ id: 2, name: "Mobile", path: "acme-mobile", path_with_namespace: "mygroup/team-x/acme-mobile" }), + ])); + const data = await fetchGroupProjects(c, { group: "mygroup", includeSubgroups: true }); + expect(data.map((p) => p.slug)).toEqual(["acme-web", "team-x/acme-mobile"]); + expect(data[0]).toMatchObject({ id: 1, name: "Acme Web", pathWithNamespace: "mygroup/acme-web", starCount: 4, description: "web app" }); + }); + + it("filters to projects carrying all requested topics", async () => { + const c = ctx(client([ + project({ topics: ["public-docs", "featured"] }), + project({ id: 2, path: "hidden", path_with_namespace: "mygroup/hidden", topics: ["public-docs"] }), + ])); + const data = await fetchGroupProjects(c, { group: "mygroup", topics: "public-docs,featured" }); + expect(data.map((p) => p.path)).toEqual(["acme-web"]); + }); + + it("excludes archived by default (passes archived:false to the client)", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup" }); + expect(c.client.getGroupProjects).toHaveBeenCalledWith("mygroup", { includeSubgroups: false, archived: false }); + }); + + it("includes archived when includeArchived is true (archived undefined)", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup", includeArchived: true }); + expect(c.client.getGroupProjects).toHaveBeenCalledWith("mygroup", { includeSubgroups: false, archived: undefined }); + }); + + it("memoizes on the second call", async () => { + const c = ctx(client([project({})])); + await fetchGroupProjects(c, { group: "mygroup" }); + await fetchGroupProjects(c, { group: "mygroup" }); + expect(c.client.getGroupProjects).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index d27c5bb..fa55a89 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -12,6 +12,7 @@ import type { TocEntry, TocMode } from "./toc.js"; import type { CommitData, FileData, + GroupProjectData, IssueData, LabelData, ProjectInfoData, @@ -405,6 +406,54 @@ export async function fetchLabels(ctx: GitLabContext, attrs: Attrs): Promise s.trim()).filter(Boolean); + if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean); + return []; +} + +function asBool(value: unknown): boolean { + return value === true || value === "true"; +} + +export async function fetchGroupProjects(ctx: GitLabContext, attrs: Attrs): Promise { + const group = attrs.group as string | number | undefined; + if (group === undefined) { + throw new Error(`@ebuildy/docusaurus-plugin-gitlab: requires a "group".`); + } + const includeSubgroups = asBool(attrs.includeSubgroups); + const includeArchived = asBool(attrs.includeArchived); + const topics = parseTopicList(attrs.topics); + const key = `groupProjects:${String(group)}:sub=${includeSubgroups}:arch=${includeArchived}:t=${topics.join(",")}`; + return memo(ctx, key, async () => { + const info = await ctx.client.getGroup(group); + const prefix = `${String(info.full_path)}/`; + const raw = await ctx.client.getGroupProjects(group, { + includeSubgroups, + archived: includeArchived ? undefined : false, + }); + let items: GroupProjectData[] = raw.map((p: any) => { + const pathWithNamespace = String(p.path_with_namespace); + return { + id: p.id, + name: p.name, + path: p.path, + pathWithNamespace, + slug: pathWithNamespace.startsWith(prefix) ? pathWithNamespace.slice(prefix.length) : p.path, + description: p.description ?? null, + webUrl: p.web_url, + starCount: p.star_count ?? 0, + defaultBranch: p.default_branch ?? null, + topics: Array.isArray(p.topics) ? p.topics : [], + }; + }); + if (topics.length) items = items.filter((p) => topics.every((t) => p.topics.includes(t))); + items = sortByName(items, (p) => p.slug, "asc"); + return items; + }); +} + export async function fetchFile(ctx: GitLabContext, attrs: Attrs): Promise { const project = attrs.project as string | number; const path = String(attrs.path); From 5c558e3acc11d58c1e73f9831171f15fe2b8b691 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:44:34 +0200 Subject: [PATCH 09/22] feat(generate): add generateGitlabPages directive parser --- src/generate/directive.test.ts | 41 ++++++++++++++++++++++++ src/generate/directive.ts | 57 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/generate/directive.test.ts create mode 100644 src/generate/directive.ts diff --git a/src/generate/directive.test.ts b/src/generate/directive.test.ts new file mode 100644 index 0000000..6237c98 --- /dev/null +++ b/src/generate/directive.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { parseGeneratePages, SECTION_NAMES } from "./directive.js"; + +describe("parseGeneratePages", () => { + it("parses all attributes with quoted and bare values", () => { + const spec = parseGeneratePages( + `group=1 sections="info,readme,releases" topics="public-docs" includeSubgroups=true includeArchived=false basePath="projects"`, + ); + expect(spec).toEqual({ + group: "1", + sections: ["info", "readme", "releases"], + topics: ["public-docs"], + includeSubgroups: true, + includeArchived: false, + basePath: "projects", + }); + }); + + it("applies defaults: sections=[readme], no topics, flags false, basePath=projects", () => { + expect(parseGeneratePages(`group=42`)).toEqual({ + group: "42", + sections: ["readme"], + topics: [], + includeSubgroups: false, + includeArchived: false, + basePath: "projects", + }); + }); + + it("throws when group is missing", () => { + expect(() => parseGeneratePages(`sections="readme"`)).toThrow(/requires a "group"/); + }); + + it("throws on an unknown section", () => { + expect(() => parseGeneratePages(`group=1 sections="readme,bogus"`)).toThrow(/bogus/); + }); + + it("exposes the valid section names", () => { + expect(SECTION_NAMES).toEqual(["info", "readme", "releases", "issues"]); + }); +}); diff --git a/src/generate/directive.ts b/src/generate/directive.ts new file mode 100644 index 0000000..aa57561 --- /dev/null +++ b/src/generate/directive.ts @@ -0,0 +1,57 @@ +export const SECTION_NAMES = ["info", "readme", "releases", "issues"] as const; +export type SectionName = (typeof SECTION_NAMES)[number]; + +export interface GeneratePagesSpec { + group: string; + sections: SectionName[]; + topics: string[]; + includeSubgroups: boolean; + includeArchived: boolean; + basePath: string; +} + +/** Tokenize `key=value` pairs; value may be "double"/'single' quoted or bare. */ +function parseAttrString(input: string): Record { + const out: Record = {}; + const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + out[m[1]] = m[2] ?? m[3] ?? m[4] ?? ""; + } + return out; +} + +function splitList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export function parseGeneratePages(attrString: string): GeneratePagesSpec { + const raw = parseAttrString(attrString); + if (!raw.group) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages} requires a "group".`, + ); + } + const sections = splitList(raw.sections); + const resolvedSections = (sections.length ? sections : ["readme"]) as string[]; + for (const s of resolvedSections) { + if (!SECTION_NAMES.includes(s as SectionName)) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages} unknown section "${s}"; ` + + `valid: ${SECTION_NAMES.join(", ")}.`, + ); + } + } + return { + group: raw.group, + sections: resolvedSections as SectionName[], + topics: splitList(raw.topics), + includeSubgroups: raw.includeSubgroups === "true", + includeArchived: raw.includeArchived === "true", + basePath: raw.basePath || "projects", + }; +} From c10aede3adb5b3d786cf08c91fce56523194018c Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:47:09 +0200 Subject: [PATCH 10/22] feat(generate): render per-project child page from sections --- src/generate/render-page.test.ts | 34 ++++++++++++++++++++++++++++++++ src/generate/render-page.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/generate/render-page.test.ts create mode 100644 src/generate/render-page.ts diff --git a/src/generate/render-page.test.ts b/src/generate/render-page.test.ts new file mode 100644 index 0000000..714050b --- /dev/null +++ b/src/generate/render-page.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { renderChildPage } from "./render-page.js"; + +const project = { + id: 1, name: "Acme Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", + slug: "acme-web", description: 'A "web" app', webUrl: "https://gitlab.com/mygroup/acme-web", + starCount: 4, defaultBranch: "main", topics: [], +}; + +describe("renderChildPage", () => { + it("emits frontmatter, the marker, and one component per section in order", () => { + const out = renderChildPage(project as any, ["info", "readme"]); + expect(out).toContain('title: "Acme Web"'); + expect(out).toContain('description: "A \\"web\\" app"'); + expect(out).toContain("AUTO-GENERATED"); + const info = out.indexOf(''); + const readme = out.indexOf(''); + expect(info).toBeGreaterThan(-1); + expect(readme).toBeGreaterThan(info); + }); + + it("omits the description key when the project has none", () => { + const out = renderChildPage({ ...project, description: null } as any, ["readme"]); + expect(out).not.toContain("description:"); + }); + + it("maps every section name to its component", () => { + const out = renderChildPage(project as any, ["info", "readme", "releases", "issues"]); + expect(out).toContain(" = { + info: "GitlabProjectInfo", + readme: "GitlabReadme", + releases: "GitlabReleases", + issues: "GitlabIssues", +}; + +/** JSON.stringify yields a valid double-quoted YAML scalar for simple strings. */ +function yamlString(value: string): string { + return JSON.stringify(value); +} + +export function renderChildPage(project: GroupProjectData, sections: SectionName[]): string { + const frontmatter = [ + "---", + `title: ${yamlString(project.name)}`, + ...(project.description ? [`description: ${yamlString(project.description)}`] : []), + "---", + ].join("\n"); + + const body = sections + .map((s) => `<${SECTION_COMPONENT[s]} project="${project.pathWithNamespace}" />`) + .join("\n"); + + return `${frontmatter}\n{/* AUTO-GENERATED by @ebuildy/docusaurus-plugin-gitlab — do not edit */}\n\n${body}\n`; +} From d8833974412d5d423a69cd97ab58c2317f72fdf7 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:49:25 +0200 Subject: [PATCH 11/22] feat(generate): write nested project page tree with ownership guard --- src/generate/write.test.ts | 56 +++++++++++++++++++++++++++++++++++ src/generate/write.ts | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/generate/write.test.ts create mode 100644 src/generate/write.ts diff --git a/src/generate/write.test.ts b/src/generate/write.test.ts new file mode 100644 index 0000000..7e17f06 --- /dev/null +++ b/src/generate/write.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { writeProjectPages } from "./write.js"; + +const projects = [ + { id: 1, name: "Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", slug: "acme-web", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, + { id: 2, name: "Mobile", path: "acme-mobile", pathWithNamespace: "mygroup/team-x/acme-mobile", slug: "team-x/acme-mobile", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, +]; + +function target() { + return join(mkdtempSync(join(tmpdir(), "glgen-")), "projects"); +} + +describe("writeProjectPages", () => { + it("writes marker, gitignore, root category, nested pages, and subgroup category", () => { + const dir = target(); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "My Group" }); + + expect(existsSync(join(dir, ".gitlab-generated"))).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8").trim()).toBe("*"); + expect(JSON.parse(readFileSync(join(dir, "_category_.json"), "utf8")).label).toBe("My Group"); + expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); + expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, "team-x", "_category_.json"), "utf8")).label).toBe("team-x"); + expect(readFileSync(join(dir, "acme-web.mdx"), "utf8")).toContain(''); + }); + + it("is idempotent: regenerating removes stale files from a previously-owned dir", () => { + const dir = target(); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + writeProjectPages([projects[0]] as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); + expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(false); + }); + + it("places two projects sharing a subgroup in one dir with a single category file", () => { + const dir = target(); + const shared = [ + { id: 3, name: "A", path: "a", pathWithNamespace: "mygroup/team-x/a", slug: "team-x/a", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, + { id: 4, name: "B", path: "b", pathWithNamespace: "mygroup/team-x/b", slug: "team-x/b", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, + ]; + writeProjectPages(shared as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + expect(existsSync(join(dir, "team-x", "a.mdx"))).toBe(true); + expect(existsSync(join(dir, "team-x", "b.mdx"))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, "team-x", "_category_.json"), "utf8")).label).toBe("team-x"); + }); + + it("refuses to overwrite a directory it does not own", () => { + const dir = target(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "handwritten.mdx"), "keep me"); + expect(() => writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" })).toThrow(/not generated by/i); + }); +}); diff --git a/src/generate/write.ts b/src/generate/write.ts new file mode 100644 index 0000000..e85f5c3 --- /dev/null +++ b/src/generate/write.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { GroupProjectData } from "../gitlab/types.js"; +import type { SectionName } from "./directive.js"; +import { renderChildPage } from "./render-page.js"; + +const MARKER = ".gitlab-generated"; + +export interface WriteOptions { + targetDir: string; + sections: SectionName[]; + /** Label for the root `_category_.json` (e.g. the GitLab group name). */ + groupLabel: string; +} + +function writeCategory(dir: string, label: string): void { + writeFileSync(join(dir, "_category_.json"), `${JSON.stringify({ label }, null, 2)}\n`); +} + +/** Ensure every intermediate dir of `slug` exists and carries a `_category_.json`. */ +function ensureSlugDirs(targetDir: string, slug: string): void { + const segments = slug.split("/"); + segments.pop(); // drop the file segment + let current = targetDir; + for (const seg of segments) { + current = join(current, seg); + if (!existsSync(current)) { + mkdirSync(current, { recursive: true }); + writeCategory(current, seg); + } + } +} + +export function writeProjectPages(projects: GroupProjectData[], opts: WriteOptions): string[] { + const { targetDir } = opts; + + if (existsSync(targetDir)) { + if (!existsSync(join(targetDir, MARKER))) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: refusing to overwrite "${targetDir}" — ` + + `it was not generated by this plugin (missing ${MARKER}).`, + ); + } + rmSync(targetDir, { recursive: true, force: true }); + } + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(targetDir, MARKER), ""); + writeFileSync(join(targetDir, ".gitignore"), "*\n"); + writeCategory(targetDir, opts.groupLabel); + + const written: string[] = []; + for (const project of projects) { + ensureSlugDirs(targetDir, project.slug); + const file = join(targetDir, `${project.slug}.mdx`); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, renderChildPage(project, opts.sections)); + written.push(file); + } + return written; +} From 71f71a2f79a3d10276c6facac6a5cf30af9812fe Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:55:44 +0200 Subject: [PATCH 12/22] feat(generate): scan docs dir for generateGitlabPages directives --- src/generate/scan.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/generate/scan.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/generate/scan.test.ts create mode 100644 src/generate/scan.ts diff --git a/src/generate/scan.test.ts b/src/generate/scan.test.ts new file mode 100644 index 0000000..a1602b2 --- /dev/null +++ b/src/generate/scan.test.ts @@ -0,0 +1,39 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { scanGeneratePages } from "./scan.js"; + +function site() { + const root = mkdtempSync(join(tmpdir(), "glscan-")); + const docs = join(root, "docs"); + mkdirSync(join(docs, "sub"), { recursive: true }); + return { docs }; +} + +describe("scanGeneratePages", () => { + it("finds the directive and computes its target dir from basePath", () => { + const { docs } = site(); + writeFileSync(join(docs, "index.mdx"), `# Projects\n\n{@generateGitlabPages group=1 basePath="apps"}\n`); + writeFileSync(join(docs, "sub", "other.md"), `no directive here`); + + const hits = scanGeneratePages(docs); + expect(hits).toHaveLength(1); + expect(hits[0].file).toBe(join(docs, "index.mdx")); + expect(hits[0].spec.group).toBe("1"); + expect(hits[0].targetDir).toBe(join(docs, "apps")); + }); + + it("defaults the target dir to /projects", () => { + const { docs } = site(); + writeFileSync(join(docs, "sub", "page.mdx"), `{@generateGitlabPages group=7}`); + const hits = scanGeneratePages(docs); + expect(hits[0].targetDir).toBe(join(docs, "sub", "projects")); + }); + + it("returns nothing when the docs dir has no directive", () => { + const { docs } = site(); + writeFileSync(join(docs, "plain.mdx"), `just docs`); + expect(scanGeneratePages(docs)).toEqual([]); + }); +}); diff --git a/src/generate/scan.ts b/src/generate/scan.ts new file mode 100644 index 0000000..028d559 --- /dev/null +++ b/src/generate/scan.ts @@ -0,0 +1,37 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { parseGeneratePages, type GeneratePagesSpec } from "./directive.js"; + +/** Global matcher for the directive; capture group 1 is the attribute string. */ +export const GENERATE_RE = /\{@generateGitlabPages\s([^}]*)\}/g; + +export interface GeneratePagesHit { + file: string; + spec: GeneratePagesSpec; + /** Directory the generated tree is written into (`/`). */ + targetDir: string; +} + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (/\.mdx?$/.test(entry.name)) out.push(full); + } + return out; +} + +export function scanGeneratePages(docsDir: string): GeneratePagesHit[] { + if (!existsSync(docsDir)) return []; + const hits: GeneratePagesHit[] = []; + for (const file of walk(docsDir)) { + const source = readFileSync(file, "utf8"); + if (!source.includes("{@generateGitlabPages")) continue; + for (const m of source.matchAll(GENERATE_RE)) { + const spec = parseGeneratePages(m[1]); + hits.push({ file, spec, targetDir: join(dirname(file), spec.basePath) }); + } + } + return hits; +} From bf3738daacb8f99411546de49294772dd274d772 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 17:59:25 +0200 Subject: [PATCH 13/22] feat(generate): orchestrate scan + fetch + write for all directives --- src/generate/index.test.ts | 67 ++++++++++++++++++++++++++++++++++++++ src/generate/index.ts | 52 +++++++++++++++++++++++++++++ src/gitlab/fetchers.ts | 6 +++- 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/generate/index.test.ts create mode 100644 src/generate/index.ts diff --git a/src/generate/index.test.ts b/src/generate/index.test.ts new file mode 100644 index 0000000..44c2f75 --- /dev/null +++ b/src/generate/index.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, vi } from "vitest"; +import { FileCache } from "../gitlab/cache.js"; +import { generateAll } from "./index.js"; + +function ctx() { + const dir = mkdtempSync(join(tmpdir(), "glorch-")); + const client = { + getGroup: vi.fn(async () => ({ full_path: "mygroup", name: "My Group" })), + getGroupProjects: vi.fn(async () => [ + { id: 1, name: "Web", path: "acme-web", path_with_namespace: "mygroup/acme-web", description: null, web_url: "", star_count: 0, default_branch: "main", topics: [] }, + ]), + }; + return { + client, + cache: new FileCache(join(dir, "c"), { ttl: 60 }), + options: { host: "https://gitlab.com" }, + assets: { localize: vi.fn() }, + } as any; +} + +describe("generateAll", () => { + it("generates a page tree for each directive using the group name as label", async () => { + const c = ctx(); + const root = mkdtempSync(join(tmpdir(), "glsite-")); + const docs = join(root, "docs"); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1 sections="readme"}`); + + const result = await generateAll(c, docs, { strict: true }); + + expect(result.pagesWritten).toBe(1); + expect(existsSync(join(docs, "projects", "acme-web.mdx"))).toBe(true); + expect(c.client.getGroupProjects).toHaveBeenCalled(); + }); + + it("does nothing when there are no directives", async () => { + const c = ctx(); + const docs = mkdtempSync(join(tmpdir(), "glempty-")); + const result = await generateAll(c, docs, { strict: true }); + expect(result.pagesWritten).toBe(0); + }); + + it("rethrows a hit failure in strict mode", async () => { + const c = ctx(); + c.client.getGroup = vi.fn(async () => { throw new Error("boom"); }); + const docs = mkdtempSync(join(tmpdir(), "glstrict-")); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1}`); + await expect(generateAll(c, docs, { strict: true })).rejects.toThrow(/boom/); + }); + + it("logs and skips a hit failure when not strict", async () => { + const c = ctx(); + c.client.getGroup = vi.fn(async () => { throw new Error("boom"); }); + const docs = mkdtempSync(join(tmpdir(), "glnostrict-")); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1}`); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const result = await generateAll(c, docs, { strict: false }); + expect(result.pagesWritten).toBe(0); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/src/generate/index.ts b/src/generate/index.ts new file mode 100644 index 0000000..ed77a78 --- /dev/null +++ b/src/generate/index.ts @@ -0,0 +1,52 @@ +import type { GitLabContext } from "../gitlab/fetchers.js"; +import { fetchGroup, fetchGroupProjects } from "../gitlab/fetchers.js"; +import { scanGeneratePages } from "./scan.js"; +import { writeProjectPages } from "./write.js"; + +export interface GenerateResult { + directives: number; + pagesWritten: number; +} + +export interface GenerateOptions { + /** In strict mode a failed hit rethrows (aborts the build); otherwise it is skipped. */ + strict: boolean; +} + +export async function generateAll( + ctx: GitLabContext, + docsDir: string, + opts: GenerateOptions, +): Promise { + const hits = scanGeneratePages(docsDir); + let pagesWritten = 0; + for (const hit of hits) { + const { spec } = hit; + try { + const projects = await fetchGroupProjects(ctx, { + group: spec.group, + includeSubgroups: spec.includeSubgroups, + includeArchived: spec.includeArchived, + topics: spec.topics, + }); + const info = await fetchGroup(ctx, spec.group); + const written = writeProjectPages(projects, { + targetDir: hit.targetDir, + sections: spec.sections, + groupLabel: String(info.name ?? spec.group), + }); + pagesWritten += written.length; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (opts.strict) { + throw new Error( + `@ebuildy/docusaurus-plugin-gitlab: {@generateGitlabPages group=${spec.group}} in ${hit.file} failed — ${message}`, + ); + } + console.warn( + `@ebuildy/docusaurus-plugin-gitlab: skipping {@generateGitlabPages group=${spec.group}} in ${hit.file} — ${message}`, + ); + } + } + return { directives: hits.length, pagesWritten }; +} diff --git a/src/gitlab/fetchers.ts b/src/gitlab/fetchers.ts index fa55a89..1c49084 100644 --- a/src/gitlab/fetchers.ts +++ b/src/gitlab/fetchers.ts @@ -417,6 +417,10 @@ function asBool(value: unknown): boolean { return value === true || value === "true"; } +export async function fetchGroup(ctx: GitLabContext, group: string | number): Promise { + return memo(ctx, `group:${String(group)}`, () => ctx.client.getGroup(group)); +} + export async function fetchGroupProjects(ctx: GitLabContext, attrs: Attrs): Promise { const group = attrs.group as string | number | undefined; if (group === undefined) { @@ -427,7 +431,7 @@ export async function fetchGroupProjects(ctx: GitLabContext, attrs: Attrs): Prom const topics = parseTopicList(attrs.topics); const key = `groupProjects:${String(group)}:sub=${includeSubgroups}:arch=${includeArchived}:t=${topics.join(",")}`; return memo(ctx, key, async () => { - const info = await ctx.client.getGroup(group); + const info = await fetchGroup(ctx, group); const prefix = `${String(info.full_path)}/`; const raw = await ctx.client.getGroupProjects(group, { includeSubgroups, From ad4ec655ed44dc3a7887714887ef47b235f10c0f Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 18:13:44 +0200 Subject: [PATCH 14/22] feat(components): add GitlabProjectGrid card component + registry entry Includes a fix to src/remark/index.test.ts's fetchers.js mock, which needed a fetchGroupProjects stub after the new registry entry was added. --- src/components/GitlabProjectGrid.test.tsx | 35 +++++++++++++++++++ src/components/GitlabProjectGrid.tsx | 25 ++++++++++++++ src/components/index.ts | 2 ++ src/components/types.ts | 1 + src/index.ts | 1 + src/remark/index.test.ts | 1 + src/remark/registry.ts | 2 ++ theme.css | 42 +++++++++++++++++++++++ 8 files changed, 109 insertions(+) create mode 100644 src/components/GitlabProjectGrid.test.tsx create mode 100644 src/components/GitlabProjectGrid.tsx diff --git a/src/components/GitlabProjectGrid.test.tsx b/src/components/GitlabProjectGrid.test.tsx new file mode 100644 index 0000000..4b15872 --- /dev/null +++ b/src/components/GitlabProjectGrid.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { GitlabProjectGrid } from "./GitlabProjectGrid"; + +const projects = [ + { id: 1, name: "Acme Web", path: "acme-web", pathWithNamespace: "mygroup/acme-web", slug: "acme-web", description: "web app", webUrl: "https://x/mygroup/acme-web", starCount: 4, defaultBranch: "main", topics: [] }, + { id: 2, name: "Mobile", path: "acme-mobile", pathWithNamespace: "mygroup/team-x/acme-mobile", slug: "team-x/acme-mobile", description: null, webUrl: "https://x/mygroup/team-x/acme-mobile", starCount: 0, defaultBranch: "main", topics: [] }, +]; + +describe("GitlabProjectGrid", () => { + it("renders a card per project linking to its generated page under basePath", () => { + render(); + const web = screen.getByRole("link", { name: /Acme Web/ }); + expect(web).toHaveAttribute("href", "apps/acme-web"); + const mobile = screen.getByRole("link", { name: /Mobile/ }); + expect(mobile).toHaveAttribute("href", "apps/team-x/acme-mobile"); + }); + + it("defaults basePath to 'projects' and shows description + star count", () => { + render(); + expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "projects/acme-web"); + expect(screen.getByText("web app")).toBeInTheDocument(); + expect(screen.getByText("4")).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/GitlabProjectGrid.tsx b/src/components/GitlabProjectGrid.tsx new file mode 100644 index 0000000..f78f478 --- /dev/null +++ b/src/components/GitlabProjectGrid.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, GroupProjectData } from "./types.js"; + +interface GridProps extends ComponentPayload { + basePath?: string; +} + +export function GitlabProjectGrid({ data, error, basePath = "projects" }: GridProps) { + if (error) return ; + if (!data) return null; + return ( + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index a97be37..9e88a25 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 { GitlabProjectGrid } from "./GitlabProjectGrid.js"; export type { ComponentLayout } from "./layout.js"; export type { ProjectInfoData, @@ -15,6 +16,7 @@ export type { FileData, TopicData, LabelData, + GroupProjectData, FetchError, ComponentPayload, } from "./types.js"; diff --git a/src/components/types.ts b/src/components/types.ts index 8d488a0..650cd7c 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -7,6 +7,7 @@ export type { FileData, TopicData, LabelData, + GroupProjectData, FetchError, ComponentPayload, } from "../gitlab/types.js"; diff --git a/src/index.ts b/src/index.ts index b5d80e4..83ee861 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,5 +19,6 @@ export type { FileData, TopicData, LabelData, + GroupProjectData, FetchError, } from "./gitlab/types.js"; diff --git a/src/remark/index.test.ts b/src/remark/index.test.ts index 889bb0c..c3e6b5a 100644 --- a/src/remark/index.test.ts +++ b/src/remark/index.test.ts @@ -15,6 +15,7 @@ vi.mock("../gitlab/fetchers.js", () => ({ fetchFile: vi.fn(), fetchTopics: vi.fn(), fetchLabels: vi.fn(), + fetchGroupProjects: vi.fn(), })); function processor(opts: any) { diff --git a/src/remark/registry.ts b/src/remark/registry.ts index fa0059c..b222cf8 100644 --- a/src/remark/registry.ts +++ b/src/remark/registry.ts @@ -6,6 +6,7 @@ import { fetchFile, fetchTopics, fetchLabels, + fetchGroupProjects, type GitLabContext, } from "../gitlab/fetchers.js"; @@ -19,4 +20,5 @@ export const COMPONENT_REGISTRY: Record = { GitlabFile: fetchFile, GitlabTopics: fetchTopics, GitlabLabels: fetchLabels, + GitlabProjectGrid: fetchGroupProjects, }; diff --git a/theme.css b/theme.css index ef1b7dc..3ffd08e 100644 --- a/theme.css +++ b/theme.css @@ -365,3 +365,45 @@ padding-left: 0; list-style: none; } + +/* Project grid — a responsive grid of project cards linking to generated pages. */ +.gitlab-project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} +.gitlab-project-card { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.85rem 1rem; + margin: 0; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 10px; + background: var(--ifm-background-surface-color); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 2px 8px rgb(0 0 0 / 0.04); + color: inherit; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.gitlab-project-card:hover { + border-color: var(--ifm-color-primary); + text-decoration: none; +} +.gitlab-project-card__name { + font-weight: 600; + color: var(--ifm-color-primary); +} +.gitlab-project-card__desc { + font-size: 0.85em; + color: var(--ifm-color-emphasis-700); +} +.gitlab-project-card__stars { + font-size: 0.8em; + color: var(--ifm-color-emphasis-600); +} +.gitlab-project-card__stars::before { + content: "★ "; + color: var(--ifm-color-warning); +} From d676cecffd47c06a256adee7f6d345150c00e8e7 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 18:20:28 +0200 Subject: [PATCH 15/22] feat(loader): rewrite generateGitlabPages directive to GitlabProjectGrid --- src/generate/rewrite.test.ts | 25 +++++++++++++++++++++++++ src/generate/rewrite.ts | 20 ++++++++++++++++++++ src/include/loader.test.ts | 10 ++++++++++ src/include/loader.ts | 10 +++++++--- 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/generate/rewrite.test.ts create mode 100644 src/generate/rewrite.ts diff --git a/src/generate/rewrite.test.ts b/src/generate/rewrite.test.ts new file mode 100644 index 0000000..2ac0d98 --- /dev/null +++ b/src/generate/rewrite.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { rewriteGeneratePages } from "./rewrite.js"; + +describe("rewriteGeneratePages", () => { + it("rewrites the directive into a GitlabProjectGrid element with literal attrs", () => { + const out = rewriteGeneratePages( + `# Projects\n\n{@generateGitlabPages group=1 sections="info,readme" topics="x" includeSubgroups=true basePath="apps"}\n`, + ); + expect(out).toContain( + ``, + ); + expect(out).not.toContain("{@generateGitlabPages"); + }); + + it("returns the source unchanged when no directive is present", () => { + const src = `# Just docs\n`; + expect(rewriteGeneratePages(src)).toBe(src); + }); + + it("escapes double quotes in interpolated attribute values", () => { + const out = rewriteGeneratePages(`{@generateGitlabPages group=1 topics='a"b'}`); + expect(out).toContain('topics="a"b"'); + expect(out).not.toContain('topics="a"b"'); + }); +}); diff --git a/src/generate/rewrite.ts b/src/generate/rewrite.ts new file mode 100644 index 0000000..80cdc33 --- /dev/null +++ b/src/generate/rewrite.ts @@ -0,0 +1,20 @@ +import { parseGeneratePages } from "./directive.js"; +import { GENERATE_RE } from "./scan.js"; + +/** Replace each `{@generateGitlabPages …}` with a `` element. */ +export function rewriteGeneratePages(source: string): string { + if (!source.includes("{@generateGitlabPages")) return source; + const esc = (v: string) => v.replace(/"/g, """); + return source.replace(GENERATE_RE, (_full, attrs: string) => { + const spec = parseGeneratePages(attrs); + return ( + `` + ); + }); +} diff --git a/src/include/loader.test.ts b/src/include/loader.test.ts index 5103598..bce6b48 100644 --- a/src/include/loader.test.ts +++ b/src/include/loader.test.ts @@ -30,4 +30,14 @@ describe("gitlab include loader", () => { }); expect(out).toContain("> ⚠️"); }); + + it("rewrites a generateGitlabPages directive to ", async () => { + const out = await run(`{@generateGitlabPages group=1 sections="readme"}`, { + strict: true, + host: "https://gl", + cache: false, + }); + expect(out).toContain(" callback(null, out), (err) => callback(err instanceof Error ? err : new Error(String(err))), ); From 68b0cbf2133afbef46f9942c3044885d4d907e71 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Thu, 9 Jul 2026 18:54:51 +0200 Subject: [PATCH 16/22] feat(plugin): generate group pages at init and via gitlab:generate CLI --- src/plugin/index.test.ts | 102 +++++++++++++++++++++++++++------------ src/plugin/index.ts | 39 ++++++++++++++- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/src/plugin/index.test.ts b/src/plugin/index.test.ts index b61eac5..84509af 100644 --- a/src/plugin/index.test.ts +++ b/src/plugin/index.test.ts @@ -1,22 +1,33 @@ -import { describe, it, expect } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, vi } from "vitest"; import { getOutProcessors } from "../include/out-processors.js"; import gitlabPlugin from "./index.js"; +vi.mock("../generate/index.js", () => ({ + generateAll: vi.fn(async () => ({ directives: 0, pagesWritten: 0 })), +})); +import { generateAll } from "../generate/index.js"; + const ctx = { siteDir: "/site" } as any; const opts = { host: "https://gitlab.example.com", cache: false } as any; -const ruleOptions = (o: any) => { - const wp = gitlabPlugin(ctx, o).configureWebpack!({} as any, false, {} as any); +const ruleOptions = async (o: any) => { + const plugin = await gitlabPlugin(ctx, o); + const wp = plugin.configureWebpack!({} as any, false, {} as any); return (wp.module!.rules as any[])[0].use[0].options; }; describe("gitlabPlugin", () => { - it("has the package name", () => { - expect(gitlabPlugin(ctx, opts).name).toBe("@ebuildy/docusaurus-plugin-gitlab"); + it("has the package name", async () => { + const plugin = await gitlabPlugin(ctx, opts); + expect(plugin.name).toBe("@ebuildy/docusaurus-plugin-gitlab"); }); - it("registers a pre-loader rule for markdown files", () => { - const wp = gitlabPlugin(ctx, opts).configureWebpack!({} as any, false, {} as any); + it("registers a pre-loader rule for markdown files", async () => { + const plugin = await gitlabPlugin(ctx, opts); + const wp = plugin.configureWebpack!({} as any, false, {} as any); const rule = (wp.module!.rules as any[])[0]; expect(rule.enforce).toBe("pre"); expect(String(rule.test)).toContain("mdx?"); @@ -25,7 +36,7 @@ describe("gitlabPlugin", () => { expect(rule.use[0].options.resolved.host).toBe("https://gitlab.example.com"); }); - it("scopes the rule's include to siteDir instead of leaving it undefined", () => { + it("scopes the rule's include to siteDir instead of leaving it undefined", async () => { // @docusaurus/core's synthetic MDX-fallback plugin flattens every // `.mdx?`-matching rule's `include` into its own `exclude` array // (getMDXFallbackExcludedPaths in server/plugins/synthetic.js). Without @@ -35,55 +46,84 @@ describe("gitlabPlugin", () => { // `null`, which fails webpack's config schema and aborts the build. // Reproduced directly against webpack-merge while debugging Task 12's // e2e test; see examples/site's real Docusaurus build for the full repro. - const wp = gitlabPlugin(ctx, opts).configureWebpack!({} as any, false, {} as any); + const plugin = await gitlabPlugin(ctx, opts); + const wp = plugin.configureWebpack!({} as any, false, {} as any); const rule = (wp.module!.rules as any[])[0]; expect(rule.include).toEqual(["/site"]); }); - it("falls back to cwd for include when context has no siteDir", () => { - const wp = gitlabPlugin({} as any, opts).configureWebpack!({} as any, false, {} as any); + it("falls back to cwd for include when context has no siteDir", async () => { + const plugin = await gitlabPlugin({} as any, opts); + const wp = plugin.configureWebpack!({} as any, false, {} as any); const rule = (wp.module!.rules as any[])[0]; expect(rule.include).toEqual([process.cwd()]); }); - it("appends (not index-merges) module.rules so other plugins' rules survive webpack-merge", () => { + it("appends (not index-merges) module.rules so other plugins' rules survive webpack-merge", async () => { // webpack-merge's default array strategy deep-merges `module.rules` by // index instead of concatenating, which would corrupt other plugins' // rule objects. `append` makes it plain-concat instead. - const wp = gitlabPlugin(ctx, opts).configureWebpack!({} as any, false, {} as any); + const plugin = await gitlabPlugin(ctx, opts); + const wp = plugin.configureWebpack!({} as any, false, {} as any); expect((wp as any).mergeStrategy).toEqual({ "module.rules": "append" }); }); - it("contributes the theme stylesheet", () => { - const mods = gitlabPlugin(ctx, opts).getClientModules!(); + it("contributes the theme stylesheet", async () => { + const plugin = await gitlabPlugin(ctx, opts); + const mods = plugin.getClientModules!(); expect(mods[0]).toContain("theme.css"); }); - it("validates options eagerly", () => { - expect(() => gitlabPlugin(ctx, { host: "not-a-url" } as any)).toThrow(); + it("validates options eagerly", async () => { + await expect(gitlabPlugin(ctx, { host: "not-a-url" } as any)).rejects.toThrow(); }); - it("drives the built-in fixes via resolved options (default on)", () => { - expect(ruleOptions(opts).resolved.fixAutolinks).toBe(true); - expect(ruleOptions(opts).resolved.fixVoidTags).toBe(true); - expect(ruleOptions(opts).resolved.fixInlineStyles).toBe(true); - expect(ruleOptions(opts).resolved.convertAlerts).toBe(true); - expect(ruleOptions({ ...opts, fixAutolinks: false }).resolved.fixAutolinks).toBe(false); - expect(ruleOptions({ ...opts, fixVoidTags: false }).resolved.fixVoidTags).toBe(false); - expect(ruleOptions({ ...opts, fixInlineStyles: false }).resolved.fixInlineStyles).toBe(false); - expect(ruleOptions({ ...opts, convertAlerts: false }).resolved.convertAlerts).toBe(false); + it("drives the built-in fixes via resolved options (default on)", async () => { + expect((await ruleOptions(opts)).resolved.fixAutolinks).toBe(true); + expect((await ruleOptions(opts)).resolved.fixVoidTags).toBe(true); + expect((await ruleOptions(opts)).resolved.fixInlineStyles).toBe(true); + expect((await ruleOptions(opts)).resolved.convertAlerts).toBe(true); + expect((await ruleOptions({ ...opts, fixAutolinks: false })).resolved.fixAutolinks).toBe(false); + expect((await ruleOptions({ ...opts, fixVoidTags: false })).resolved.fixVoidTags).toBe(false); + expect((await ruleOptions({ ...opts, fixInlineStyles: false })).resolved.fixInlineStyles).toBe(false); + expect((await ruleOptions({ ...opts, convertAlerts: false })).resolved.convertAlerts).toBe(false); }); - it("drives stripToc via resolved options (default off)", () => { - expect(ruleOptions(opts).resolved.stripToc).toBe(false); - expect(ruleOptions({ ...opts, stripToc: true }).resolved.stripToc).toBe(true); + it("drives stripToc via resolved options (default off)", async () => { + expect((await ruleOptions(opts)).resolved.stripToc).toBe(false); + expect((await ruleOptions({ ...opts, stripToc: true })).resolved.stripToc).toBe(true); }); - it("registers user outProcessors under the loader's processorsId", () => { + it("registers user outProcessors under the loader's processorsId", async () => { const user = (md: string) => md; const o = { host: "https://gl.custom.example.com", cache: false, outProcessors: [user] } as any; - const { processorsId } = ruleOptions(o); + const { processorsId } = await ruleOptions(o); expect(typeof processorsId).toBe("string"); expect(getOutProcessors(processorsId)).toEqual([user]); }); + + it("is an async plugin that returns the plugin object and registers a CLI command", async () => { + const siteDir = mkdtempSync(join(tmpdir(), "glplugin-")); + const plugin = await gitlabPlugin({ siteDir } as any, opts); + expect(plugin.name).toBe("@ebuildy/docusaurus-plugin-gitlab"); + + const registered: string[] = []; + const cli = { + command: (name: string) => { + registered.push(name); + const chain: any = { description: () => chain, action: () => chain }; + return chain; + }, + }; + plugin.extendCli?.(cli as any); + expect(registered).toContain("gitlab:generate"); + }); + + it("runs generation against the site's docs dir during init", async () => { + const siteDir = mkdtempSync(join(tmpdir(), "glgen-init-")); + await gitlabPlugin({ siteDir } as any, opts); + expect(generateAll).toHaveBeenCalledWith(expect.anything(), join(siteDir, "docs"), { + strict: expect.any(Boolean), + }); + }); }); diff --git a/src/plugin/index.ts b/src/plugin/index.ts index dea01ae..7b1d21a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,5 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { generateAll } from "../generate/index.js"; +import { buildContext } from "../gitlab/context.js"; import { registerOutProcessors } from "../include/out-processors.js"; import { resolveOptions, type PluginOptions } from "../options.js"; @@ -8,11 +10,33 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); // Stable, serializable ids for in-process processor registration (see below). let processorSeq = 0; +const generatedSites = new Set(); + +// Generation runs once per site per process. Re-invoking the factory for the +// same siteDir (or a second plugin instance pointed at the same site) is a +// no-op — editing a {@generateGitlabPages} block during `docusaurus start` +// requires a restart to regenerate. +async function generateOnce(resolved: ReturnType, siteDir: string): Promise { + if (generatedSites.has(siteDir)) return; + generatedSites.add(siteDir); + const ctx = buildContext(resolved); + await generateAll(ctx, path.join(siteDir, "docs"), { strict: resolved.strict }); +} + interface PluginContextLike { siteDir?: string; } -export default function gitlabPlugin(context: unknown, options: PluginOptions) { +interface CliCommandLike { + description(text: string): CliCommandLike; + action(fn: () => void | Promise): CliCommandLike; +} + +interface CliLike { + command(name: string): CliCommandLike; +} + +export default async function gitlabPlugin(context: unknown, options: PluginOptions) { const mode = process.env.NODE_ENV === "production" ? "production" : "development"; const resolved = resolveOptions(options, mode); const siteDir = (context as PluginContextLike | undefined)?.siteDir ?? process.cwd(); @@ -25,6 +49,10 @@ export default function gitlabPlugin(context: unknown, options: PluginOptions) { const processorsId = `gitlab-out-${processorSeq++}`; registerOutProcessors(processorsId, options.outProcessors ?? []); + // Generate pages before Docusaurus's docs plugin scans the filesystem, so the + // generated tree + `_category_.json` files feed the autogenerated sidebar. + await generateOnce(resolved, siteDir); + return { name: "@ebuildy/docusaurus-plugin-gitlab", @@ -33,6 +61,15 @@ export default function gitlabPlugin(context: unknown, options: PluginOptions) { return [path.resolve(dirname, "../../theme.css")]; }, + extendCli(cli: CliLike) { + cli + .command("gitlab:generate") + .description("Generate Docusaurus pages from GitLab groups (@ebuildy/docusaurus-plugin-gitlab)") + .action(async () => { + await generateOnce(resolved, siteDir); + }); + }, + configureWebpack(..._args: unknown[]) { return { module: { From 11fe0d2e5a410b4fdfcf7fb7b3e7989d5ca5cdce Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 08:26:16 +0200 Subject: [PATCH 17/22] docs: example + e2e + README for group page generation --- README.md | 49 ++++++++++++++++++++++++ examples/site/docs/generate/projects.mdx | 7 ++++ test/e2e/build.test.ts | 18 +++++++++ test/e2e/fixtures.ts | 11 +++++- 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 examples/site/docs/generate/projects.mdx diff --git a/README.md b/README.md index b5e3b3b..05133be 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,55 @@ Both components render [scoped labels/topics](https://docs.gitlab.com/ee/user/pr (`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 `::`. +## Generating pages from a group + +Instead of writing one page per project by hand, drop a single directive on an +index MDX page and let the plugin generate a child page per project in a +GitLab group at build time: + +```mdx +--- +title: Group projects +--- + +# Our GitLab projects + +{@generateGitlabPages group="my-group" sections="info,readme" includeSubgroups=false} +``` + +| Attribute | Type | Default | Description | +|---|---|---|---| +| `group` | string \| number | — | **Required.** Group path or ID | +| `sections` | string | `"readme"` | Comma-separated list of `info`, `readme`, `releases`, `issues` — becomes the components rendered on each generated project page | +| `topics` | string | — | Comma-separated topic filter; only projects with **all** listed topics are included | +| `includeSubgroups` | boolean | `false` | Include projects from subgroups | +| `includeArchived` | boolean | `false` | Include archived projects | +| `basePath` | string | `"projects"` | Folder name (relative to the index page) the generated child pages are written into | + +Generated files land in a **git-ignored** `/` folder next to the +index page (e.g. `docs/generate/projects/`) and are regenerated on every +build — never hand-edit them, and never commit them. Generation runs once at +plugin init, before the docs plugin scans the filesystem, so the generated +tree and its `_category_.json` files feed the autogenerated sidebar like any +other doc. + +You can also (re)generate the pages without a full build: + +```bash +npx docusaurus gitlab:generate +``` + +> During `docusaurus start`, generation runs once per process at startup. +> Editing the `{@generateGitlabPages …}` attributes (group, sections, topics, +> …) requires restarting `docusaurus start` to regenerate — it is not +> re-evaluated on hot reload. + +On the index page itself, the directive is replaced with a `` +card grid — one card per project, linking to its generated child page. Card +links are built as `/` and assume Docusaurus's default +(no-trailing-slash) doc URLs; if your site is configured with +`trailingSlash: true`, adjust routing accordingly. + ### `::include` directives inside included markdown When a fetched GitLab README or markdown file contains a GitLab diff --git a/examples/site/docs/generate/projects.mdx b/examples/site/docs/generate/projects.mdx new file mode 100644 index 0000000..6ac38d2 --- /dev/null +++ b/examples/site/docs/generate/projects.mdx @@ -0,0 +1,7 @@ +--- +title: Group projects +--- + +# Our GitLab projects + +{@generateGitlabPages group="my-group" sections="info,readme" includeSubgroups=false} diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index 99890ef..33c78a8 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -32,6 +32,7 @@ describe("e2e: docusaurus build", () => { recursive: true, force: true, }); + rmSync(join(siteDir, "docs", "generate", "projects"), { recursive: true, force: true }); await runBuild({ ...process.env, GITLAB_HOST: stub.url, GITLAB_TOKEN: "" }); }, 180_000); @@ -39,6 +40,7 @@ describe("e2e: docusaurus build", () => { await stub?.stop(); rmSync(join(siteDir, "build"), { recursive: true, force: true }); rmSync(join(siteDir, "static", "gitlab-assets"), { recursive: true, force: true }); + rmSync(join(siteDir, "docs", "generate", "projects"), { recursive: true, force: true }); }); it("bakes project info, releases, and issues into the static html", () => { @@ -93,4 +95,20 @@ describe("e2e: docusaurus build", () => { // group label with the group issues link expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); }); + + it("generates a page per group project and a card grid on the index page", () => { + // The generator wrote the child page into the docs tree during the build. + const childSource = join(siteDir, "docs", "generate", "projects", "repo.mdx"); + expect(readFileSync(childSource, "utf8")).toContain(''); + + // The child page built and baked in the README. + const childHtml = readFileSync(join(siteDir, "build", "generate", "projects", "repo", "index.html"), "utf8"); + expect(childHtml).toContain("Readme body"); + + // The index page rendered the card grid linking to the generated child page. + const indexHtml = readFileSync(join(siteDir, "build", "generate", "projects", "index.html"), "utf8"); + expect(indexHtml).toContain("gitlab-project-grid"); + expect(indexHtml).toContain('href="projects/repo"'); + expect(indexHtml).toContain("Repo"); + }); }); diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 893393a..9a0c1d2 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -49,13 +49,22 @@ export async function startGitlabStub(): Promise<{ url: string; stop: () => Prom { name: "api", title: "API", total_projects_count: 9 }, ]); } + if (url.startsWith("/api/v4/groups/my-group/projects")) { + return send([ + { + id: 1, name: "Repo", path: "repo", path_with_namespace: "group/repo", + description: "Desc", web_url: "https://x/group/repo", star_count: 5, + default_branch: "main", topics: ["docs"], + }, + ]); + } 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" }); + return send({ id: 42, name: "My Group", full_path: "my-group", web_url: "https://x/groups/my-group" }); } if (url.includes("/repository/files/README.md/raw")) { return send( From 8444246bbdc2b3e92ba22206d0e7ad37f9a08cdc Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 08:30:10 +0200 Subject: [PATCH 18/22] fix(plugin): only generate pages when Docusaurus provides a siteDir --- src/plugin/index.test.ts | 6 ++++++ src/plugin/index.ts | 11 ++++++++--- test/packaging.test.ts | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/plugin/index.test.ts b/src/plugin/index.test.ts index 84509af..9f79f55 100644 --- a/src/plugin/index.test.ts +++ b/src/plugin/index.test.ts @@ -126,4 +126,10 @@ describe("gitlabPlugin", () => { strict: expect.any(Boolean), }); }); + + it("does not generate when the context provides no siteDir", async () => { + (generateAll as unknown as { mockClear: () => void }).mockClear(); + await gitlabPlugin({} as any, opts); + expect(generateAll).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 7b1d21a..9c8a96f 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -39,7 +39,8 @@ interface CliLike { export default async function gitlabPlugin(context: unknown, options: PluginOptions) { const mode = process.env.NODE_ENV === "production" ? "production" : "development"; const resolved = resolveOptions(options, mode); - const siteDir = (context as PluginContextLike | undefined)?.siteDir ?? process.cwd(); + const providedSiteDir = (context as PluginContextLike | undefined)?.siteDir; + const siteDir = providedSiteDir ?? process.cwd(); // User `outProcessors` are functions, which can't survive webpack's // serialization of loader options. Register them in-process under a plain @@ -50,8 +51,12 @@ export default async function gitlabPlugin(context: unknown, options: PluginOpti registerOutProcessors(processorsId, options.outProcessors ?? []); // Generate pages before Docusaurus's docs plugin scans the filesystem, so the - // generated tree + `_category_.json` files feed the autogenerated sidebar. - await generateOnce(resolved, siteDir); + // generated tree + `_category_.json` files feed the autogenerated sidebar. Only + // run when Docusaurus actually provided a siteDir — a bare cwd fallback would + // scan an unrelated tree (and is what real Docusaurus never does). + if (providedSiteDir) { + await generateOnce(resolved, providedSiteDir); + } return { name: "@ebuildy/docusaurus-plugin-gitlab", diff --git a/test/packaging.test.ts b/test/packaging.test.ts index b22a69a..cb785f9 100644 --- a/test/packaging.test.ts +++ b/test/packaging.test.ts @@ -50,10 +50,10 @@ describe("packaging: plugin default export", () => { // test` builds the package first, so the file exists at runtime. const entry = new URL("../dist/index.js", import.meta.url).href; const mod = (await import(entry)) as { - default: (context: unknown, options: unknown) => { name: string }; + default: (context: unknown, options: unknown) => Promise<{ name: string }>; }; expect(typeof mod.default).toBe("function"); - const plugin = mod.default({}, { host: "https://gitlab.example.com", cache: false }); + const plugin = await mod.default({}, { host: "https://gitlab.example.com", cache: false }); expect(plugin.name).toBe("@ebuildy/docusaurus-plugin-gitlab"); }); }); From 33a37a03b1145f579919c2c76b335992abcd5d0b Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 17:53:14 +0200 Subject: [PATCH 19/22] feat(generate): nest generated pages under the declaring index page Generated project pages are now written as siblings of the declaring page (which must be a folder index: index.mdx/README.mdx) so Docusaurus's autogenerated sidebar nests them beneath it, instead of a separate basePath subfolder. Removes the basePath attribute; the writer shares the folder with the author's index page using a JSON manifest + scoped .gitignore (never touching hand-authored files). Card links resolve via the index page's folder name (linkBase). --- README.md | 51 ++++--- .../docs/generate/{projects.mdx => index.mdx} | 3 + src/components/GitlabProjectGrid.test.tsx | 12 +- src/components/GitlabProjectGrid.tsx | 13 +- src/generate/directive.test.ts | 6 +- src/generate/directive.ts | 2 - src/generate/index.test.ts | 10 +- src/generate/index.ts | 4 +- src/generate/rewrite.test.ts | 12 +- src/generate/rewrite.ts | 13 +- src/generate/scan.test.ts | 12 +- src/generate/scan.ts | 9 +- src/generate/write.test.ts | 52 ++++--- src/generate/write.ts | 127 +++++++++++++----- src/include/loader.ts | 8 +- test/e2e/build.test.ts | 34 +++-- 16 files changed, 254 insertions(+), 114 deletions(-) rename examples/site/docs/generate/{projects.mdx => index.mdx} (58%) diff --git a/README.md b/README.md index 05133be..e286f57 100644 --- a/README.md +++ b/README.md @@ -266,14 +266,20 @@ color and the value gets a dark-gray background. The split is on the last `::`. ## Generating pages from a group -Instead of writing one page per project by hand, drop a single directive on an -index MDX page and let the plugin generate a child page per project in a -GitLab group at build time: +Instead of writing one page per project by hand, drop a single directive on a +**folder's index page** and let the plugin generate a child page per project in +a GitLab group at build time. The generated pages become **children of the +declaring page** in the sidebar. + +Put the directive on the folder's index doc — `index.mdx`, `README.mdx`, or a +doc named after its folder (Docusaurus's [category index +convention](https://docusaurus.io/docs/sidebar/autogenerated#category-index-convention)): ```mdx --- title: Group projects --- +# docs/team/index.mdx # Our GitLab projects @@ -287,14 +293,29 @@ title: Group projects | `topics` | string | — | Comma-separated topic filter; only projects with **all** listed topics are included | | `includeSubgroups` | boolean | `false` | Include projects from subgroups | | `includeArchived` | boolean | `false` | Include archived projects | -| `basePath` | string | `"projects"` | Folder name (relative to the index page) the generated child pages are written into | -Generated files land in a **git-ignored** `/` folder next to the -index page (e.g. `docs/generate/projects/`) and are regenerated on every -build — never hand-edit them, and never commit them. Generation runs once at -plugin init, before the docs plugin scans the filesystem, so the generated -tree and its `_category_.json` files feed the autogenerated sidebar like any -other doc. +The plugin writes one `.mdx` per project **as a sibling of the +declaring page** (subgroups become nested folders with their own +`_category_.json`), so the autogenerated sidebar nests them under the declaring +page: + +```text +docs/team/ + index.mdx <- the declaring page (parent) + acme-web.mdx <- generated child + acme-api.mdx <- generated child + frontend/ <- generated subgroup + _category_.json + web-app.mdx +``` + +Generated files are **git-ignored** (the plugin writes a scoped `.gitignore` in +the folder that ignores only what it generated — never your index page) and are +regenerated on every build, tracked via a `.gitlab-generated` manifest so stale +pages are removed on regeneration. Never hand-edit or commit them. Generation +runs once at plugin init, before the docs plugin scans the filesystem, so the +generated pages feed the autogenerated sidebar like any other doc. Keep the +declaring page's folder dedicated to this generation. You can also (re)generate the pages without a full build: @@ -307,11 +328,11 @@ npx docusaurus gitlab:generate > …) requires restarting `docusaurus start` to regenerate — it is not > re-evaluated on hot reload. -On the index page itself, the directive is replaced with a `` -card grid — one card per project, linking to its generated child page. Card -links are built as `/` and assume Docusaurus's default -(no-trailing-slash) doc URLs; if your site is configured with -`trailingSlash: true`, adjust routing accordingly. +On the declaring page itself, the directive is replaced with a +`` card grid — one card per project, linking to its generated +child page. Card links assume Docusaurus's default (no-trailing-slash) doc URLs; +if your site is configured with `trailingSlash: true`, adjust routing +accordingly. ### `::include` directives inside included markdown diff --git a/examples/site/docs/generate/projects.mdx b/examples/site/docs/generate/index.mdx similarity index 58% rename from examples/site/docs/generate/projects.mdx rename to examples/site/docs/generate/index.mdx index 6ac38d2..d8b4f75 100644 --- a/examples/site/docs/generate/projects.mdx +++ b/examples/site/docs/generate/index.mdx @@ -4,4 +4,7 @@ title: Group projects # Our GitLab projects +This page is a folder index, so the generated per-project pages become its +children in the sidebar. + {@generateGitlabPages group="my-group" sections="info,readme" includeSubgroups=false} diff --git a/src/components/GitlabProjectGrid.test.tsx b/src/components/GitlabProjectGrid.test.tsx index 4b15872..c116abb 100644 --- a/src/components/GitlabProjectGrid.test.tsx +++ b/src/components/GitlabProjectGrid.test.tsx @@ -8,17 +8,17 @@ const projects = [ ]; describe("GitlabProjectGrid", () => { - it("renders a card per project linking to its generated page under basePath", () => { - render(); + it("renders a card per project linking to its generated page under linkBase", () => { + render(); const web = screen.getByRole("link", { name: /Acme Web/ }); - expect(web).toHaveAttribute("href", "apps/acme-web"); + expect(web).toHaveAttribute("href", "team/acme-web"); const mobile = screen.getByRole("link", { name: /Mobile/ }); - expect(mobile).toHaveAttribute("href", "apps/team-x/acme-mobile"); + expect(mobile).toHaveAttribute("href", "team/team-x/acme-mobile"); }); - it("defaults basePath to 'projects' and shows description + star count", () => { + it("links relative to the slug when no linkBase is given, and shows description + star count", () => { render(); - expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "projects/acme-web"); + expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "acme-web"); expect(screen.getByText("web app")).toBeInTheDocument(); expect(screen.getByText("4")).toBeInTheDocument(); }); diff --git a/src/components/GitlabProjectGrid.tsx b/src/components/GitlabProjectGrid.tsx index f78f478..9291261 100644 --- a/src/components/GitlabProjectGrid.tsx +++ b/src/components/GitlabProjectGrid.tsx @@ -3,16 +3,23 @@ import { Fallback } from "./Fallback.js"; import type { ComponentPayload, GroupProjectData } from "./types.js"; interface GridProps extends ComponentPayload { - basePath?: string; + /** + * The declaring page's folder name. Each card links to `/`, + * which resolves to the generated child page (a sibling of the declaring page) + * under Docusaurus's default no-trailing-slash doc routes. Empty means the + * children sit at the same URL depth as the declaring page. + */ + linkBase?: string; } -export function GitlabProjectGrid({ data, error, basePath = "projects" }: GridProps) { +export function GitlabProjectGrid({ data, error, linkBase = "" }: GridProps) { if (error) return ; if (!data) return null; + const href = (slug: string) => (linkBase ? `${linkBase}/${slug}` : slug); return (
{data.map((p) => ( - + {p.name} {p.description ? ( {p.description} diff --git a/src/generate/directive.test.ts b/src/generate/directive.test.ts index 6237c98..65c709d 100644 --- a/src/generate/directive.test.ts +++ b/src/generate/directive.test.ts @@ -4,7 +4,7 @@ import { parseGeneratePages, SECTION_NAMES } from "./directive.js"; describe("parseGeneratePages", () => { it("parses all attributes with quoted and bare values", () => { const spec = parseGeneratePages( - `group=1 sections="info,readme,releases" topics="public-docs" includeSubgroups=true includeArchived=false basePath="projects"`, + `group=1 sections="info,readme,releases" topics="public-docs" includeSubgroups=true includeArchived=false`, ); expect(spec).toEqual({ group: "1", @@ -12,18 +12,16 @@ describe("parseGeneratePages", () => { topics: ["public-docs"], includeSubgroups: true, includeArchived: false, - basePath: "projects", }); }); - it("applies defaults: sections=[readme], no topics, flags false, basePath=projects", () => { + it("applies defaults: sections=[readme], no topics, flags false", () => { expect(parseGeneratePages(`group=42`)).toEqual({ group: "42", sections: ["readme"], topics: [], includeSubgroups: false, includeArchived: false, - basePath: "projects", }); }); diff --git a/src/generate/directive.ts b/src/generate/directive.ts index aa57561..dc59086 100644 --- a/src/generate/directive.ts +++ b/src/generate/directive.ts @@ -7,7 +7,6 @@ export interface GeneratePagesSpec { topics: string[]; includeSubgroups: boolean; includeArchived: boolean; - basePath: string; } /** Tokenize `key=value` pairs; value may be "double"/'single' quoted or bare. */ @@ -52,6 +51,5 @@ export function parseGeneratePages(attrString: string): GeneratePagesSpec { topics: splitList(raw.topics), includeSubgroups: raw.includeSubgroups === "true", includeArchived: raw.includeArchived === "true", - basePath: raw.basePath || "projects", }; } diff --git a/src/generate/index.test.ts b/src/generate/index.test.ts index 44c2f75..a4dd7ee 100644 --- a/src/generate/index.test.ts +++ b/src/generate/index.test.ts @@ -22,17 +22,19 @@ function ctx() { } describe("generateAll", () => { - it("generates a page tree for each directive using the group name as label", async () => { + it("generates a child page as a sibling of the declaring page for each directive", async () => { const c = ctx(); const root = mkdtempSync(join(tmpdir(), "glsite-")); const docs = join(root, "docs"); - mkdirSync(docs, { recursive: true }); - writeFileSync(join(docs, "index.mdx"), `{@generateGitlabPages group=1 sections="readme"}`); + mkdirSync(join(docs, "team"), { recursive: true }); + writeFileSync(join(docs, "team", "index.mdx"), `{@generateGitlabPages group=1 sections="readme"}`); const result = await generateAll(c, docs, { strict: true }); expect(result.pagesWritten).toBe(1); - expect(existsSync(join(docs, "projects", "acme-web.mdx"))).toBe(true); + // Child page sits next to the declaring page (docs/team/index.mdx), not in a subfolder. + expect(existsSync(join(docs, "team", "acme-web.mdx"))).toBe(true); + expect(existsSync(join(docs, "team", "index.mdx"))).toBe(true); expect(c.client.getGroupProjects).toHaveBeenCalled(); }); diff --git a/src/generate/index.ts b/src/generate/index.ts index ed77a78..440976c 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -1,5 +1,5 @@ import type { GitLabContext } from "../gitlab/fetchers.js"; -import { fetchGroup, fetchGroupProjects } from "../gitlab/fetchers.js"; +import { fetchGroupProjects } from "../gitlab/fetchers.js"; import { scanGeneratePages } from "./scan.js"; import { writeProjectPages } from "./write.js"; @@ -29,11 +29,9 @@ export async function generateAll( includeArchived: spec.includeArchived, topics: spec.topics, }); - const info = await fetchGroup(ctx, spec.group); const written = writeProjectPages(projects, { targetDir: hit.targetDir, sections: spec.sections, - groupLabel: String(info.name ?? spec.group), }); pagesWritten += written.length; } catch (err) { diff --git a/src/generate/rewrite.test.ts b/src/generate/rewrite.test.ts index 2ac0d98..2996601 100644 --- a/src/generate/rewrite.test.ts +++ b/src/generate/rewrite.test.ts @@ -2,16 +2,22 @@ import { describe, it, expect } from "vitest"; import { rewriteGeneratePages } from "./rewrite.js"; describe("rewriteGeneratePages", () => { - it("rewrites the directive into a GitlabProjectGrid element with literal attrs", () => { + it("rewrites the directive into a GitlabProjectGrid element with literal attrs and the given linkBase", () => { const out = rewriteGeneratePages( - `# Projects\n\n{@generateGitlabPages group=1 sections="info,readme" topics="x" includeSubgroups=true basePath="apps"}\n`, + `# Projects\n\n{@generateGitlabPages group=1 sections="info,readme" topics="x" includeSubgroups=true}\n`, + "team", ); expect(out).toContain( - ``, + ``, ); expect(out).not.toContain("{@generateGitlabPages"); }); + it("emits an empty linkBase when none is provided", () => { + const out = rewriteGeneratePages(`{@generateGitlabPages group=1}`); + expect(out).toContain(`linkBase="" />`); + }); + it("returns the source unchanged when no directive is present", () => { const src = `# Just docs\n`; expect(rewriteGeneratePages(src)).toBe(src); diff --git a/src/generate/rewrite.ts b/src/generate/rewrite.ts index 80cdc33..1b4a77c 100644 --- a/src/generate/rewrite.ts +++ b/src/generate/rewrite.ts @@ -1,8 +1,15 @@ import { parseGeneratePages } from "./directive.js"; import { GENERATE_RE } from "./scan.js"; -/** Replace each `{@generateGitlabPages …}` with a `` element. */ -export function rewriteGeneratePages(source: string): string { +/** + * Replace each `{@generateGitlabPages …}` with a `` element. + * + * `linkBase` is the name of the folder the declaring page lives in; the grid uses + * it to build each card's relative link to the generated child page + * (`/`), which resolves correctly from the declaring page's URL + * under Docusaurus's default (no-trailing-slash) doc routes. + */ +export function rewriteGeneratePages(source: string, linkBase = ""): string { if (!source.includes("{@generateGitlabPages")) return source; const esc = (v: string) => v.replace(/"/g, """); return source.replace(GENERATE_RE, (_full, attrs: string) => { @@ -14,7 +21,7 @@ export function rewriteGeneratePages(source: string): string { `topics="${esc(spec.topics.join(","))}" ` + `includeSubgroups={${spec.includeSubgroups}} ` + `includeArchived={${spec.includeArchived}} ` + - `basePath="${esc(spec.basePath)}" />` + `linkBase="${esc(linkBase)}" />` ); }); } diff --git a/src/generate/scan.test.ts b/src/generate/scan.test.ts index a1602b2..d03223e 100644 --- a/src/generate/scan.test.ts +++ b/src/generate/scan.test.ts @@ -12,23 +12,23 @@ function site() { } describe("scanGeneratePages", () => { - it("finds the directive and computes its target dir from basePath", () => { + it("finds the directive and targets the declaring page's own folder", () => { const { docs } = site(); - writeFileSync(join(docs, "index.mdx"), `# Projects\n\n{@generateGitlabPages group=1 basePath="apps"}\n`); + writeFileSync(join(docs, "index.mdx"), `# Projects\n\n{@generateGitlabPages group=1}\n`); writeFileSync(join(docs, "sub", "other.md"), `no directive here`); const hits = scanGeneratePages(docs); expect(hits).toHaveLength(1); expect(hits[0].file).toBe(join(docs, "index.mdx")); expect(hits[0].spec.group).toBe("1"); - expect(hits[0].targetDir).toBe(join(docs, "apps")); + expect(hits[0].targetDir).toBe(docs); }); - it("defaults the target dir to /projects", () => { + it("targets the folder containing a nested declaring page", () => { const { docs } = site(); - writeFileSync(join(docs, "sub", "page.mdx"), `{@generateGitlabPages group=7}`); + writeFileSync(join(docs, "sub", "index.mdx"), `{@generateGitlabPages group=7}`); const hits = scanGeneratePages(docs); - expect(hits[0].targetDir).toBe(join(docs, "sub", "projects")); + expect(hits[0].targetDir).toBe(join(docs, "sub")); }); it("returns nothing when the docs dir has no directive", () => { diff --git a/src/generate/scan.ts b/src/generate/scan.ts index 028d559..67fb6ec 100644 --- a/src/generate/scan.ts +++ b/src/generate/scan.ts @@ -8,7 +8,12 @@ export const GENERATE_RE = /\{@generateGitlabPages\s([^}]*)\}/g; export interface GeneratePagesHit { file: string; spec: GeneratePagesSpec; - /** Directory the generated tree is written into (`/`). */ + /** + * Directory the generated pages are written into — the declaring page's own + * folder. Generated pages become siblings of the declaring page so that, when + * the declaring page is a Docusaurus category index (`index.mdx`/`README.mdx`), + * they render as its children in the autogenerated sidebar. + */ targetDir: string; } @@ -30,7 +35,7 @@ export function scanGeneratePages(docsDir: string): GeneratePagesHit[] { if (!source.includes("{@generateGitlabPages")) continue; for (const m of source.matchAll(GENERATE_RE)) { const spec = parseGeneratePages(m[1]); - hits.push({ file, spec, targetDir: join(dirname(file), spec.basePath) }); + hits.push({ file, spec, targetDir: dirname(file) }); } } return hits; diff --git a/src/generate/write.test.ts b/src/generate/write.test.ts index 7e17f06..9109179 100644 --- a/src/generate/write.test.ts +++ b/src/generate/write.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect } from "vitest"; @@ -9,30 +9,53 @@ const projects = [ { id: 2, name: "Mobile", path: "acme-mobile", pathWithNamespace: "mygroup/team-x/acme-mobile", slug: "team-x/acme-mobile", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, ]; +// The target is the declaring page's own folder; seed it with the hand-authored +// index page that the writer must never touch. function target() { - return join(mkdtempSync(join(tmpdir(), "glgen-")), "projects"); + const dir = mkdtempSync(join(tmpdir(), "glgen-")); + writeFileSync(join(dir, "index.mdx"), "# Our projects\n\n{@generateGitlabPages group=1}\n"); + return dir; } describe("writeProjectPages", () => { - it("writes marker, gitignore, root category, nested pages, and subgroup category", () => { + it("writes children as siblings, a manifest, and a scoped gitignore — never touching the index page", () => { const dir = target(); - writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "My Group" }); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"] }); - expect(existsSync(join(dir, ".gitlab-generated"))).toBe(true); - expect(readFileSync(join(dir, ".gitignore"), "utf8").trim()).toBe("*"); - expect(JSON.parse(readFileSync(join(dir, "_category_.json"), "utf8")).label).toBe("My Group"); + // Hand-authored index page is preserved. + expect(readFileSync(join(dir, "index.mdx"), "utf8")).toContain("Our projects"); + // Children are written as siblings; the subgroup is mirrored with its category. expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(true); expect(JSON.parse(readFileSync(join(dir, "team-x", "_category_.json"), "utf8")).label).toBe("team-x"); expect(readFileSync(join(dir, "acme-web.mdx"), "utf8")).toContain(''); + // No root _category_.json — the declaring page provides the category. + expect(existsSync(join(dir, "_category_.json"))).toBe(false); + // Manifest lists generated files; gitignore scopes to them, not the index. + const manifest = JSON.parse(readFileSync(join(dir, ".gitlab-generated"), "utf8")); + expect(manifest.files).toContain("acme-web.mdx"); + const ignore = readFileSync(join(dir, ".gitignore"), "utf8"); + expect(ignore).toContain("/acme-web.mdx"); + expect(ignore).toContain("/team-x/"); + expect(ignore).not.toContain("/index.mdx"); }); - it("is idempotent: regenerating removes stale files from a previously-owned dir", () => { + it("is idempotent: regenerating removes stale generated files but keeps the index page", () => { const dir = target(); - writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); - writeProjectPages([projects[0]] as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"] }); + writeProjectPages([projects[0]] as any, { targetDir: dir, sections: ["readme"] }); expect(existsSync(join(dir, "acme-web.mdx"))).toBe(true); expect(existsSync(join(dir, "team-x", "acme-mobile.mdx"))).toBe(false); + expect(existsSync(join(dir, "team-x"))).toBe(false); // empty subgroup dir pruned + expect(existsSync(join(dir, "index.mdx"))).toBe(true); // author file survives + }); + + it("refuses to overwrite a hand-authored file whose name collides with a project", () => { + const dir = target(); + writeFileSync(join(dir, "acme-web.mdx"), "hand written"); + expect(() => writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"] })).toThrow( + /not generated by this plugin/i, + ); }); it("places two projects sharing a subgroup in one dir with a single category file", () => { @@ -41,16 +64,9 @@ describe("writeProjectPages", () => { { id: 3, name: "A", path: "a", pathWithNamespace: "mygroup/team-x/a", slug: "team-x/a", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, { id: 4, name: "B", path: "b", pathWithNamespace: "mygroup/team-x/b", slug: "team-x/b", description: null, webUrl: "", starCount: 0, defaultBranch: "main", topics: [] }, ]; - writeProjectPages(shared as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" }); + writeProjectPages(shared as any, { targetDir: dir, sections: ["readme"] }); expect(existsSync(join(dir, "team-x", "a.mdx"))).toBe(true); expect(existsSync(join(dir, "team-x", "b.mdx"))).toBe(true); expect(JSON.parse(readFileSync(join(dir, "team-x", "_category_.json"), "utf8")).label).toBe("team-x"); }); - - it("refuses to overwrite a directory it does not own", () => { - const dir = target(); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "handwritten.mdx"), "keep me"); - expect(() => writeProjectPages(projects as any, { targetDir: dir, sections: ["readme"], groupLabel: "G" })).toThrow(/not generated by/i); - }); }); diff --git a/src/generate/write.ts b/src/generate/write.ts index e85f5c3..50521d6 100644 --- a/src/generate/write.ts +++ b/src/generate/write.ts @@ -1,60 +1,121 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { GroupProjectData } from "../gitlab/types.js"; import type { SectionName } from "./directive.js"; import { renderChildPage } from "./render-page.js"; +/** JSON manifest of everything this plugin generated in a target folder. */ const MARKER = ".gitlab-generated"; +const GITIGNORE = ".gitignore"; export interface WriteOptions { + /** The declaring page's own folder; generated pages are written as siblings. */ targetDir: string; sections: SectionName[]; - /** Label for the root `_category_.json` (e.g. the GitLab group name). */ - groupLabel: string; } -function writeCategory(dir: string, label: string): void { - writeFileSync(join(dir, "_category_.json"), `${JSON.stringify({ label }, null, 2)}\n`); +interface Manifest { + /** Files we generated, relative to targetDir (child `.mdx` + subgroup `_category_.json`). */ + files: string[]; + /** Subgroup dirs we created, relative to targetDir. */ + dirs: string[]; } -/** Ensure every intermediate dir of `slug` exists and carries a `_category_.json`. */ -function ensureSlugDirs(targetDir: string, slug: string): void { - const segments = slug.split("/"); - segments.pop(); // drop the file segment - let current = targetDir; - for (const seg of segments) { - current = join(current, seg); - if (!existsSync(current)) { - mkdirSync(current, { recursive: true }); - writeCategory(current, seg); +function readManifest(targetDir: string): Manifest | null { + const p = join(targetDir, MARKER); + if (!existsSync(p)) return null; + try { + const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial; + if (Array.isArray(parsed.files) && Array.isArray(parsed.dirs)) { + return { files: parsed.files, dirs: parsed.dirs }; } + } catch { + // Unreadable/legacy marker → treat as an empty owned manifest. } + return { files: [], dirs: [] }; +} + +/** Remove everything a previous run generated, without touching hand-authored files. */ +function cleanup(targetDir: string, manifest: Manifest): void { + for (const rel of manifest.files) { + rmSync(join(targetDir, rel), { force: true }); + } + // Prune generated subgroup dirs deepest-first, but only if now empty (an author + // could have added their own files into one). + for (const rel of [...manifest.dirs].sort((a, b) => b.length - a.length)) { + const abs = join(targetDir, rel); + if (existsSync(abs) && readdirSync(abs).length === 0) { + rmSync(abs, { recursive: true, force: true }); + } + } +} + +/** Build a `.gitignore` that ignores only the generated entries (never the author's index). */ +function buildGitignore(manifest: Manifest): string { + const topFiles: string[] = []; + const topDirs = new Set(); + for (const f of manifest.files) { + if (f.includes("/")) topDirs.add(f.slice(0, f.indexOf("/"))); + else topFiles.push(f); + } + for (const d of manifest.dirs) topDirs.add(d.slice(0, d.includes("/") ? d.indexOf("/") : d.length)); + const lines = [ + "# @ebuildy/docusaurus-plugin-gitlab — generated files, do not edit", + `/${MARKER}`, + `/${GITIGNORE}`, + ...topFiles.sort().map((f) => `/${f}`), + ...[...topDirs].sort().map((d) => `/${d}/`), + ]; + return `${lines.join("\n")}\n`; } export function writeProjectPages(projects: GroupProjectData[], opts: WriteOptions): string[] { const { targetDir } = opts; + mkdirSync(targetDir, { recursive: true }); + + // Regeneration is idempotent: remove what we generated last time (tracked in the + // manifest), never the declaring page or other hand-authored files in the folder. + const previous = readManifest(targetDir); + if (previous) cleanup(targetDir, previous); - if (existsSync(targetDir)) { - if (!existsSync(join(targetDir, MARKER))) { + const files: string[] = []; + const dirs: string[] = []; + const written: string[] = []; + + for (const project of projects) { + // Create each intermediate subgroup dir (mirroring the namespace) with a + // `_category_.json` labeled by the path segment — only when we create it. + const segments = project.slug.split("/"); + segments.pop(); // drop the file segment + let relDir = ""; + let absDir = targetDir; + for (const seg of segments) { + relDir = relDir ? `${relDir}/${seg}` : seg; + absDir = join(absDir, seg); + if (!existsSync(absDir)) { + mkdirSync(absDir, { recursive: true }); + dirs.push(relDir); + const catRel = `${relDir}/_category_.json`; + writeFileSync(join(targetDir, catRel), `${JSON.stringify({ label: seg }, null, 2)}\n`); + files.push(catRel); + } + } + + const relFile = `${project.slug}.mdx`; + const abs = join(targetDir, relFile); + if (existsSync(abs)) { throw new Error( - `@ebuildy/docusaurus-plugin-gitlab: refusing to overwrite "${targetDir}" — ` + - `it was not generated by this plugin (missing ${MARKER}).`, + `@ebuildy/docusaurus-plugin-gitlab: refusing to overwrite "${abs}" — ` + + `it already exists and was not generated by this plugin.`, ); } - rmSync(targetDir, { recursive: true, force: true }); + writeFileSync(abs, renderChildPage(project, opts.sections)); + files.push(relFile); + written.push(abs); } - mkdirSync(targetDir, { recursive: true }); - writeFileSync(join(targetDir, MARKER), ""); - writeFileSync(join(targetDir, ".gitignore"), "*\n"); - writeCategory(targetDir, opts.groupLabel); - const written: string[] = []; - for (const project of projects) { - ensureSlugDirs(targetDir, project.slug); - const file = join(targetDir, `${project.slug}.mdx`); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, renderChildPage(project, opts.sections)); - written.push(file); - } + const manifest: Manifest = { files, dirs }; + writeFileSync(join(targetDir, MARKER), `${JSON.stringify(manifest, null, 2)}\n`); + writeFileSync(join(targetDir, GITIGNORE), buildGitignore(manifest)); return written; } diff --git a/src/include/loader.ts b/src/include/loader.ts index 48bae0b..de72f3a 100644 --- a/src/include/loader.ts +++ b/src/include/loader.ts @@ -1,3 +1,4 @@ +import { basename, dirname } from "node:path"; import { rewriteGeneratePages } from "../generate/rewrite.js"; import type { ResolvedOptions } from "../options.js"; import { getContext } from "./context.js"; @@ -7,14 +8,19 @@ import { transformIncludes } from "./transform.js"; interface LoaderThis { async: () => (err: Error | null, content?: string) => void; getOptions: () => { resolved: ResolvedOptions; processorsId?: string }; + /** Absolute path of the file being compiled (provided by webpack). */ + resourcePath?: string; } export default function gitlabIncludeLoader(this: LoaderThis, source: string): void { const callback = this.async(); const { resolved, processorsId } = this.getOptions(); + // The declaring page's folder name; the project grid builds card links relative + // to it (`/`) so they resolve to the generated child pages. + const linkBase = this.resourcePath ? basename(dirname(this.resourcePath)) : ""; // Directive-syntax errors here intentionally fail the build fast (unlike the // include path's `strict` degrade): a malformed directive is an authoring bug. - const rewritten = rewriteGeneratePages(source); + const rewritten = rewriteGeneratePages(source, linkBase); if (!rewritten.includes("{@includeGitlab")) { callback(null, rewritten); diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index 33c78a8..2df58bd 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { startGitlabStub } from "./fixtures"; @@ -7,6 +7,15 @@ import { startGitlabStub } from "./fixtures"; const siteDir = join(process.cwd(), "examples/site"); let stub: Awaited>; +// Remove the files the plugin generates into the example's `docs/generate/` folder +// (they sit alongside the committed `index.mdx`, which must be kept). +function cleanGeneratedPages() { + const dir = join(siteDir, "docs", "generate"); + for (const f of ["repo.mdx", ".gitlab-generated", ".gitignore"]) { + rmSync(join(dir, f), { force: true }); + } +} + /** * Runs `npm run build` ASYNCHRONOUSLY and awaits it. We must NOT use * execFileSync here: the GitLab stub server runs in this same (vitest) process, @@ -32,7 +41,7 @@ describe("e2e: docusaurus build", () => { recursive: true, force: true, }); - rmSync(join(siteDir, "docs", "generate", "projects"), { recursive: true, force: true }); + cleanGeneratedPages(); await runBuild({ ...process.env, GITLAB_HOST: stub.url, GITLAB_TOKEN: "" }); }, 180_000); @@ -40,7 +49,7 @@ describe("e2e: docusaurus build", () => { await stub?.stop(); rmSync(join(siteDir, "build"), { recursive: true, force: true }); rmSync(join(siteDir, "static", "gitlab-assets"), { recursive: true, force: true }); - rmSync(join(siteDir, "docs", "generate", "projects"), { recursive: true, force: true }); + cleanGeneratedPages(); }); it("bakes project info, releases, and issues into the static html", () => { @@ -96,19 +105,22 @@ describe("e2e: docusaurus build", () => { expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); }); - it("generates a page per group project and a card grid on the index page", () => { - // The generator wrote the child page into the docs tree during the build. - const childSource = join(siteDir, "docs", "generate", "projects", "repo.mdx"); + it("generates a child page nested under the declaring index page, with a card grid", () => { + // The generator wrote the child page as a SIBLING of the declaring index page + // (docs/generate/index.mdx), so Docusaurus nests it under that page. + const childSource = join(siteDir, "docs", "generate", "repo.mdx"); expect(readFileSync(childSource, "utf8")).toContain(''); + // No leftover subfolder from the old basePath model. + expect(existsSync(join(siteDir, "docs", "generate", "projects"))).toBe(false); - // The child page built and baked in the README. - const childHtml = readFileSync(join(siteDir, "build", "generate", "projects", "repo", "index.html"), "utf8"); + // The child page built at /generate/repo and baked in the README. + const childHtml = readFileSync(join(siteDir, "build", "generate", "repo", "index.html"), "utf8"); expect(childHtml).toContain("Readme body"); - // The index page rendered the card grid linking to the generated child page. - const indexHtml = readFileSync(join(siteDir, "build", "generate", "projects", "index.html"), "utf8"); + // The declaring page (/generate) rendered the card grid linking to the child. + const indexHtml = readFileSync(join(siteDir, "build", "generate", "index.html"), "utf8"); expect(indexHtml).toContain("gitlab-project-grid"); - expect(indexHtml).toContain('href="projects/repo"'); + expect(indexHtml).toContain('href="generate/repo"'); expect(indexHtml).toContain("Repo"); }); }); From 49464b26ef39c12b895f2ec66c8c1727571f2819 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 17:58:06 +0200 Subject: [PATCH 20/22] chore: example --- examples/gitlab/docs/generates/index.mdx | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/gitlab/docs/generates/index.mdx diff --git a/examples/gitlab/docs/generates/index.mdx b/examples/gitlab/docs/generates/index.mdx new file mode 100644 index 0000000..84a2200 --- /dev/null +++ b/examples/gitlab/docs/generates/index.mdx @@ -0,0 +1,3 @@ +My projects: + +{@generateGitlabPages group="gitlab-org/api" sections="info,readme" includeSubgroups=false} From ec12a4acdbeab8dce183454be5732515d533f77d Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 18:03:06 +0200 Subject: [PATCH 21/22] fix(generate): card links use a bare slug for trailing-slash folder-index URLs The declaring page is a folder index, served at a directory URL with a trailing slash (e.g. /team/), so a linkBase-prefixed href double-counted the folder (/team/team/). Cards now link with the bare project slug, which resolves correctly against the trailing-slash URL. Removes the linkBase machinery from the grid, rewrite, and loader. --- README.md | 7 ++++--- src/components/GitlabProjectGrid.test.tsx | 15 +++++++-------- src/components/GitlabProjectGrid.tsx | 18 +++++------------- src/generate/rewrite.test.ts | 10 ++-------- src/generate/rewrite.ts | 14 +++----------- src/include/loader.ts | 8 +------- test/e2e/build.test.ts | 6 ++++-- 7 files changed, 26 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e286f57..6ffcf73 100644 --- a/README.md +++ b/README.md @@ -330,9 +330,10 @@ npx docusaurus gitlab:generate On the declaring page itself, the directive is replaced with a `` card grid — one card per project, linking to its generated -child page. Card links assume Docusaurus's default (no-trailing-slash) doc URLs; -if your site is configured with `trailingSlash: true`, adjust routing -accordingly. +child page. Because the declaring page is a folder index, it is served at a +directory URL with a trailing slash (e.g. `/team/`), so each card links to the +child with a bare relative slug (`` → `/team/`). If your site sets +`trailingSlash: false`, adjust routing accordingly. ### `::include` directives inside included markdown diff --git a/src/components/GitlabProjectGrid.test.tsx b/src/components/GitlabProjectGrid.test.tsx index c116abb..a58e048 100644 --- a/src/components/GitlabProjectGrid.test.tsx +++ b/src/components/GitlabProjectGrid.test.tsx @@ -8,17 +8,16 @@ const projects = [ ]; describe("GitlabProjectGrid", () => { - it("renders a card per project linking to its generated page under linkBase", () => { - render(); - const web = screen.getByRole("link", { name: /Acme Web/ }); - expect(web).toHaveAttribute("href", "team/acme-web"); - const mobile = screen.getByRole("link", { name: /Mobile/ }); - expect(mobile).toHaveAttribute("href", "team/team-x/acme-mobile"); + it("links each card to the project's slug, relative to the declaring folder-index page", () => { + render(); + // Bare slug resolves against the declaring page's trailing-slash directory URL + // (e.g. `/team/` + `acme-web` → `/team/acme-web`). + expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "acme-web"); + expect(screen.getByRole("link", { name: /Mobile/ })).toHaveAttribute("href", "team-x/acme-mobile"); }); - it("links relative to the slug when no linkBase is given, and shows description + star count", () => { + it("shows description and star count", () => { render(); - expect(screen.getByRole("link", { name: /Acme Web/ })).toHaveAttribute("href", "acme-web"); expect(screen.getByText("web app")).toBeInTheDocument(); expect(screen.getByText("4")).toBeInTheDocument(); }); diff --git a/src/components/GitlabProjectGrid.tsx b/src/components/GitlabProjectGrid.tsx index 9291261..53bc8e7 100644 --- a/src/components/GitlabProjectGrid.tsx +++ b/src/components/GitlabProjectGrid.tsx @@ -2,24 +2,16 @@ import React from "react"; import { Fallback } from "./Fallback.js"; import type { ComponentPayload, GroupProjectData } from "./types.js"; -interface GridProps extends ComponentPayload { - /** - * The declaring page's folder name. Each card links to `/`, - * which resolves to the generated child page (a sibling of the declaring page) - * under Docusaurus's default no-trailing-slash doc routes. Empty means the - * children sit at the same URL depth as the declaring page. - */ - linkBase?: string; -} - -export function GitlabProjectGrid({ data, error, linkBase = "" }: GridProps) { +export function GitlabProjectGrid({ data, error }: ComponentPayload) { if (error) return ; if (!data) return null; - const href = (slug: string) => (linkBase ? `${linkBase}/${slug}` : slug); + // The declaring page is a folder index, served at a directory URL with a + // trailing slash (e.g. `/team/`), so each generated child page (a sibling) is + // reached with a bare relative slug (`` → `/team/`). return (
{data.map((p) => ( - + {p.name} {p.description ? ( {p.description} diff --git a/src/generate/rewrite.test.ts b/src/generate/rewrite.test.ts index 2996601..9bbeb99 100644 --- a/src/generate/rewrite.test.ts +++ b/src/generate/rewrite.test.ts @@ -2,22 +2,16 @@ import { describe, it, expect } from "vitest"; import { rewriteGeneratePages } from "./rewrite.js"; describe("rewriteGeneratePages", () => { - it("rewrites the directive into a GitlabProjectGrid element with literal attrs and the given linkBase", () => { + it("rewrites the directive into a GitlabProjectGrid element with literal attrs", () => { const out = rewriteGeneratePages( `# Projects\n\n{@generateGitlabPages group=1 sections="info,readme" topics="x" includeSubgroups=true}\n`, - "team", ); expect(out).toContain( - ``, + ``, ); expect(out).not.toContain("{@generateGitlabPages"); }); - it("emits an empty linkBase when none is provided", () => { - const out = rewriteGeneratePages(`{@generateGitlabPages group=1}`); - expect(out).toContain(`linkBase="" />`); - }); - it("returns the source unchanged when no directive is present", () => { const src = `# Just docs\n`; expect(rewriteGeneratePages(src)).toBe(src); diff --git a/src/generate/rewrite.ts b/src/generate/rewrite.ts index 1b4a77c..9618910 100644 --- a/src/generate/rewrite.ts +++ b/src/generate/rewrite.ts @@ -1,15 +1,8 @@ import { parseGeneratePages } from "./directive.js"; import { GENERATE_RE } from "./scan.js"; -/** - * Replace each `{@generateGitlabPages …}` with a `` element. - * - * `linkBase` is the name of the folder the declaring page lives in; the grid uses - * it to build each card's relative link to the generated child page - * (`/`), which resolves correctly from the declaring page's URL - * under Docusaurus's default (no-trailing-slash) doc routes. - */ -export function rewriteGeneratePages(source: string, linkBase = ""): string { +/** Replace each `{@generateGitlabPages …}` with a `` element. */ +export function rewriteGeneratePages(source: string): string { if (!source.includes("{@generateGitlabPages")) return source; const esc = (v: string) => v.replace(/"/g, """); return source.replace(GENERATE_RE, (_full, attrs: string) => { @@ -20,8 +13,7 @@ export function rewriteGeneratePages(source: string, linkBase = ""): string { `sections="${esc(spec.sections.join(","))}" ` + `topics="${esc(spec.topics.join(","))}" ` + `includeSubgroups={${spec.includeSubgroups}} ` + - `includeArchived={${spec.includeArchived}} ` + - `linkBase="${esc(linkBase)}" />` + `includeArchived={${spec.includeArchived}} />` ); }); } diff --git a/src/include/loader.ts b/src/include/loader.ts index de72f3a..48bae0b 100644 --- a/src/include/loader.ts +++ b/src/include/loader.ts @@ -1,4 +1,3 @@ -import { basename, dirname } from "node:path"; import { rewriteGeneratePages } from "../generate/rewrite.js"; import type { ResolvedOptions } from "../options.js"; import { getContext } from "./context.js"; @@ -8,19 +7,14 @@ import { transformIncludes } from "./transform.js"; interface LoaderThis { async: () => (err: Error | null, content?: string) => void; getOptions: () => { resolved: ResolvedOptions; processorsId?: string }; - /** Absolute path of the file being compiled (provided by webpack). */ - resourcePath?: string; } export default function gitlabIncludeLoader(this: LoaderThis, source: string): void { const callback = this.async(); const { resolved, processorsId } = this.getOptions(); - // The declaring page's folder name; the project grid builds card links relative - // to it (`/`) so they resolve to the generated child pages. - const linkBase = this.resourcePath ? basename(dirname(this.resourcePath)) : ""; // Directive-syntax errors here intentionally fail the build fast (unlike the // include path's `strict` degrade): a malformed directive is an authoring bug. - const rewritten = rewriteGeneratePages(source, linkBase); + const rewritten = rewriteGeneratePages(source); if (!rewritten.includes("{@includeGitlab")) { callback(null, rewritten); diff --git a/test/e2e/build.test.ts b/test/e2e/build.test.ts index 2df58bd..bbbcb32 100644 --- a/test/e2e/build.test.ts +++ b/test/e2e/build.test.ts @@ -117,10 +117,12 @@ describe("e2e: docusaurus build", () => { const childHtml = readFileSync(join(siteDir, "build", "generate", "repo", "index.html"), "utf8"); expect(childHtml).toContain("Readme body"); - // The declaring page (/generate) rendered the card grid linking to the child. + // The declaring page (/generate/) rendered the card grid linking to the child + // via a bare slug, which resolves against the page's trailing-slash URL + // (`/generate/` + `repo` → `/generate/repo`). const indexHtml = readFileSync(join(siteDir, "build", "generate", "index.html"), "utf8"); expect(indexHtml).toContain("gitlab-project-grid"); - expect(indexHtml).toContain('href="generate/repo"'); + expect(indexHtml).toContain('class="gitlab-project-card" href="repo"'); expect(indexHtml).toContain("Repo"); }); }); From eebea5fb710a51d4101051c00c091d9bebb580c9 Mon Sep 17 00:00:00 2001 From: Thomas Decaux Date: Fri, 10 Jul 2026 18:41:20 +0200 Subject: [PATCH 22/22] chore: example --- examples/gitlab/docs/generates/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gitlab/docs/generates/index.mdx b/examples/gitlab/docs/generates/index.mdx index 84a2200..bf0bcae 100644 --- a/examples/gitlab/docs/generates/index.mdx +++ b/examples/gitlab/docs/generates/index.mdx @@ -1,3 +1,3 @@ My projects: -{@generateGitlabPages group="gitlab-org/api" sections="info,readme" includeSubgroups=false} +{@generateGitlabPages group="gitlab-org/sbom" sections="info,readme" includeSubgroups=true}