diff --git a/README.md b/README.md index b5e3b3b..6ffcf73 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,77 @@ 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 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 + +{@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 | + +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: + +```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 declaring page itself, the directive is replaced with a +`` card grid — one card per project, linking to its generated +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 When a fetched GitLab README or markdown file contains a GitLab 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. 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 | diff --git a/examples/gitlab/docs/generates/index.mdx b/examples/gitlab/docs/generates/index.mdx new file mode 100644 index 0000000..bf0bcae --- /dev/null +++ b/examples/gitlab/docs/generates/index.mdx @@ -0,0 +1,3 @@ +My projects: + +{@generateGitlabPages group="gitlab-org/sbom" sections="info,readme" includeSubgroups=true} diff --git a/examples/site/docs/generate/index.mdx b/examples/site/docs/generate/index.mdx new file mode 100644 index 0000000..d8b4f75 --- /dev/null +++ b/examples/site/docs/generate/index.mdx @@ -0,0 +1,10 @@ +--- +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 new file mode 100644 index 0000000..a58e048 --- /dev/null +++ b/src/components/GitlabProjectGrid.test.tsx @@ -0,0 +1,34 @@ +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("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("shows description and star count", () => { + render(); + 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..53bc8e7 --- /dev/null +++ b/src/components/GitlabProjectGrid.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Fallback } from "./Fallback.js"; +import type { ComponentPayload, GroupProjectData } from "./types.js"; + +export function GitlabProjectGrid({ data, error }: ComponentPayload) { + if (error) return ; + if (!data) return null; + // 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 ( + + ); +} 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/generate/directive.test.ts b/src/generate/directive.test.ts new file mode 100644 index 0000000..65c709d --- /dev/null +++ b/src/generate/directive.test.ts @@ -0,0 +1,39 @@ +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`, + ); + expect(spec).toEqual({ + group: "1", + sections: ["info", "readme", "releases"], + topics: ["public-docs"], + includeSubgroups: true, + includeArchived: false, + }); + }); + + it("applies defaults: sections=[readme], no topics, flags false", () => { + expect(parseGeneratePages(`group=42`)).toEqual({ + group: "42", + sections: ["readme"], + topics: [], + includeSubgroups: false, + includeArchived: false, + }); + }); + + 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..dc59086 --- /dev/null +++ b/src/generate/directive.ts @@ -0,0 +1,55 @@ +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; +} + +/** 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", + }; +} diff --git a/src/generate/index.test.ts b/src/generate/index.test.ts new file mode 100644 index 0000000..a4dd7ee --- /dev/null +++ b/src/generate/index.test.ts @@ -0,0 +1,69 @@ +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 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(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); + // 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(); + }); + + 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..440976c --- /dev/null +++ b/src/generate/index.ts @@ -0,0 +1,50 @@ +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 written = writeProjectPages(projects, { + targetDir: hit.targetDir, + sections: spec.sections, + }); + 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/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`; +} diff --git a/src/generate/rewrite.test.ts b/src/generate/rewrite.test.ts new file mode 100644 index 0000000..9bbeb99 --- /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}\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..9618910 --- /dev/null +++ b/src/generate/rewrite.ts @@ -0,0 +1,19 @@ +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/generate/scan.test.ts b/src/generate/scan.test.ts new file mode 100644 index 0000000..d03223e --- /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 targets the declaring page's own folder", () => { + const { docs } = site(); + 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(docs); + }); + + it("targets the folder containing a nested declaring page", () => { + const { docs } = site(); + writeFileSync(join(docs, "sub", "index.mdx"), `{@generateGitlabPages group=7}`); + const hits = scanGeneratePages(docs); + expect(hits[0].targetDir).toBe(join(docs, "sub")); + }); + + 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..67fb6ec --- /dev/null +++ b/src/generate/scan.ts @@ -0,0 +1,42 @@ +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 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; +} + +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: dirname(file) }); + } + } + return hits; +} diff --git a/src/generate/write.test.ts b/src/generate/write.test.ts new file mode 100644 index 0000000..9109179 --- /dev/null +++ b/src/generate/write.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, 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: [] }, +]; + +// 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() { + 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 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"] }); + + // 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 generated files but keeps the index page", () => { + const dir = target(); + 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", () => { + 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"] }); + 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"); + }); +}); diff --git a/src/generate/write.ts b/src/generate/write.ts new file mode 100644 index 0000000..50521d6 --- /dev/null +++ b/src/generate/write.ts @@ -0,0 +1,121 @@ +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[]; +} + +interface Manifest { + /** Files we generated, relative to targetDir (child `.mdx` + subgroup `_category_.json`). */ + files: string[]; + /** Subgroup dirs we created, relative to targetDir. */ + dirs: string[]; +} + +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); + + 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 "${abs}" — ` + + `it already exists and was not generated by this plugin.`, + ); + } + writeFileSync(abs, renderChildPage(project, opts.sections)); + files.push(relFile); + written.push(abs); + } + + 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/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; 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..1c49084 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,58 @@ 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 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) { + 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 fetchGroup(ctx, 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); 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[]; +} 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))), ); 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/plugin/index.test.ts b/src/plugin/index.test.ts index b61eac5..9f79f55 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,90 @@ 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), + }); + }); + + 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 dea01ae..9c8a96f 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,14 +10,37 @@ 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(); + 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 @@ -25,6 +50,14 @@ 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. 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", @@ -33,6 +66,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: { 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/test/e2e/build.test.ts b/test/e2e/build.test.ts index 99890ef..bbbcb32 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,6 +41,7 @@ describe("e2e: docusaurus build", () => { recursive: true, force: true, }); + cleanGeneratedPages(); await runBuild({ ...process.env, GITLAB_HOST: stub.url, GITLAB_TOKEN: "" }); }, 180_000); @@ -39,6 +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 }); + cleanGeneratedPages(); }); it("bakes project info, releases, and issues into the static html", () => { @@ -93,4 +104,25 @@ describe("e2e: docusaurus build", () => { // group label with the group issues link expect(html).toContain("/groups/my-group/-/issues?label_name[]=epic"); }); + + 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 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 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('class="gitlab-project-card" href="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( 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"); }); }); 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); +}