Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,33 +36,46 @@ yarn workspace @rwdocs/backstage-plugin-rw run lint
yarn workspace @rwdocs/backstage-plugin-rw-backend run lint
```

Tests use `backstage-cli package test` (Jest). No tests have been written yet.
Tests use `backstage-cli package test` (Jest). Note: `backstage-cli` forces `--watch` mode by default, ignoring jest config. Always pass `--watchAll=false` when running tests:

```bash
yarn workspace @rwdocs/backstage-plugin-rw run test --watchAll=false
yarn workspace @rwdocs/backstage-plugin-rw-backend run test --watchAll=false
```

## Architecture

### Frontend Plugin (`plugins/rw/`)

Defines three Backstage extensions in `plugin.tsx`:
1. **rwApi** — `ApiBlueprint` providing `RwClient` (wraps `discoveryApi` + `fetchApi`)
2. **rwPage** — Standalone page mounted at `/docs` via `PageBlueprint`
3. **rwEntityContent** — Catalog entity tab ("Documentation") via `EntityContentBlueprint`
2. **rwPage** — Standalone page mounted at `/docs` via `PageBlueprint`, renders `RwStandaloneViewer`
3. **rwEntityContent** — Catalog entity tab ("Documentation") via `EntityContentBlueprint`, renders `RwEntityDocsViewer` (filtered by `rwdocs.org/ref` annotation)

`RwDocsViewer` is the main component. It mounts the `@rwdocs/viewer` into a DOM ref and maintains two-way navigation sync between React Router and the RW viewer instance. A `rwNavigatingRef` flag prevents infinite nav loops.
Three viewer components:
- **`RwDocsViewer`** — Core component that mounts `@rwdocs/viewer` into a DOM ref, maintains two-way navigation sync between React Router and the RW viewer instance, and resolves cross-entity section refs. A `rwNavigatingRef` flag prevents infinite nav loops.
- **`RwEntityDocsViewer`** — Wrapper for catalog entity pages. Reads the entity's `rwdocs.org/ref` annotation via `parseAnnotation` and passes the resolved API base URL and section ref to `RwDocsViewer`.
- **`RwStandaloneViewer`** — Wrapper for the standalone `/docs` page. Reads `rw.rootEntity` from config to determine which entity to render.

### Backend Plugin (`plugins/rw-backend/`)

`plugin.ts` reads config (`rw.projectDir` or `rw.s3`, mutually exclusive) and creates an Express router.
`plugin.ts` reads config (`rw.projectDir` or `rw.s3`, mutually exclusive) and creates a `Hub` for managing `RwSite` instances.

Key backend classes:
- **`Hub`** — Manages multiple `RwSite` instances with LRU eviction. In `projectDir` mode, serves a single pre-configured site. In `s3` mode, creates sites on demand.

`router.ts` exposes entity-scoped endpoints under `/site/:namespace/:kind/:name/`:
- `GET /health` — unauthenticated health check (not entity-scoped)
- `GET /site/:namespace/:kind/:name/config` — returns `{ liveReloadEnabled: false }`
- `GET /site/:namespace/:kind/:name/navigation?sectionRef=` — navigation tree from `RwSite`
- `GET /site/:namespace/:kind/:name/pages/:path(*)` — rendered page content (with path traversal protection)

`router.ts` exposes four endpoints:
- `GET /health` — unauthenticated health check
- `GET /config` — returns `{ liveReloadEnabled: false }`
- `GET /navigation?scope=` — navigation tree from `RwSite`
- `GET /pages/:path(*)` — rendered page content (with path traversal protection)
Middleware resolves the entity path from URL params to look up the corresponding `RwSite` from the Hub.

### Plugin Communication

Frontend `RwClient` discovers the backend URL via `discoveryApi.getBaseUrl("rw")` and passes `fetchApi.fetch` to the RW viewer library, which makes HTTP calls to the backend endpoints.
Frontend `RwClient` discovers the backend URL via `discoveryApi.getBaseUrl("rw")` and constructs entity-scoped URLs (e.g., `/site/default/component/my-docs/`). It passes `fetchApi.fetch` to the RW viewer library, which makes HTTP calls to the backend endpoints.

### Configuration Schema

Defined in `plugins/rw-backend/config.d.ts`. Two modes: local filesystem (`rw.projectDir`) for development, S3 storage (`rw.s3`) for production. Optional `rw.linkPrefix` for URL prefixing.
Defined in `plugins/rw-backend/config.d.ts` and `plugins/rw/config.d.ts`. Two modes: local filesystem (`rw.projectDir` + `rw.entity`) for development, S3 storage (`rw.s3`) for production. Optional `rw.rootEntity` (frontend-visible) sets the entity for the standalone `/docs` page.
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@backstage/plugin-auth-backend": "^0.27.0",
"@backstage/plugin-auth-backend-module-guest-provider": "^0.2.16",
"@backstage/plugin-catalog-backend": "^3.4.0",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.17",
"@backstage/plugin-permission-backend": "^0.7.9",
"@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.16",
"@rwdocs/backstage-plugin-rw-backend": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'));
backend.add(import('@backstage/plugin-permission-backend'));
backend.add(import('@backstage/plugin-permission-backend-module-allow-all-policy'));
backend.add(import('@rwdocs/backstage-plugin-rw-backend'));
Expand Down
29 changes: 22 additions & 7 deletions plugins/rw-backend/config.d.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
export interface Config {
/** @visibility backend */
rw?: {
/** Local directory containing documentation source files. */
/**
* Local directory containing documentation source files.
* Mutually exclusive with `s3`.
*/
projectDir?: string;
/** URL prefix for generated links (e.g. "/rw-docs"). */
linkPrefix?: string;
/** S3 storage configuration for deployed environments. */
/**
* Entity ref that the local projectDir serves as (required when projectDir is set).
* Standard Backstage entity ref format: "kind:namespace/name" (e.g. "component:default/my-docs")
*/
entity?: string;
/**
* Entity ref for the standalone /docs page.
* Standard Backstage entity ref format: "kind:namespace/name" (e.g. "component:default/my-docs")
* @visibility frontend
*/
rootEntity?: string;
/** Maximum number of cached RwSite instances. Default: 20. */
cacheSize?: number;
/**
* S3 storage configuration. Shared across all entity sites.
* Mutually exclusive with `projectDir`.
*/
s3?: {
/** S3 bucket name. */
bucket: string;
/** Entity identifier (prefix) within the bucket. */
entity: string;
/** AWS region. */
region?: string;
/** Custom S3 endpoint URL. */
Expand All @@ -25,7 +40,7 @@ export interface Config {
*/
secretAccessKey?: string;
};
/** Diagram rendering configuration. */
/** Diagram rendering configuration. Shared across all sites. */
diagrams?: {
/** Kroki server URL for rendering diagrams. */
krokiUrl?: string;
Expand Down
3 changes: 2 additions & 1 deletion plugins/rw-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/catalog-model": "^1.7.6",
"@backstage/errors": "^1.2.7",
"@rwdocs/core": "^0.1.17",
"@rwdocs/core": "^0.1.18",
"express": "^4.21.0",
"express-promise-router": "^4.1.0"
},
Expand Down
42 changes: 42 additions & 0 deletions plugins/rw-backend/src/entityPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { toEntityPath, fromEntityPath } from "./entityPath";

describe("toEntityPath", () => {
it("converts colon format to slash format", () => {
expect(toEntityPath("component:default/arch")).toBe("default/component/arch");
});

it("lowercases the result", () => {
expect(toEntityPath("Component:Default/Arch")).toBe("default/component/arch");
});

it("defaults namespace to 'default' when omitted", () => {
expect(toEntityPath("component:arch")).toBe("default/component/arch");
});

it("throws on invalid entity ref", () => {
expect(() => toEntityPath("")).toThrow();
});
});

describe("fromEntityPath", () => {
it("converts slash format to colon format", () => {
expect(fromEntityPath("default/component/arch")).toBe("component:default/arch");
});

it("round-trips with toEntityPath", () => {
const original = "component:default/arch";
expect(fromEntityPath(toEntityPath(original))).toBe(original);
});

it("throws on path with too few segments", () => {
expect(() => fromEntityPath("default/component")).toThrow(/Invalid entity path/);
});

it("throws on path with too many segments", () => {
expect(() => fromEntityPath("default/component/arch/extra")).toThrow(/Invalid entity path/);
});

it("throws on path with empty segments", () => {
expect(() => fromEntityPath("default//arch")).toThrow(/Invalid entity path/);
});
});
33 changes: 33 additions & 0 deletions plugins/rw-backend/src/entityPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { parseEntityRef } from "@backstage/catalog-model";
import { InputError } from "@backstage/errors";

/**
* Converts a Backstage entity ref (e.g. "component:default/arch") to the
* slash-delimited, lowercased path format used in URLs and cache keys
* (e.g. "default/component/arch").
*
* Uses namespace/kind/name ordering to match Backstage catalog URL convention.
*
* NOTE: The frontend plugin has a similar utility at
* plugins/rw/src/components/entityPath.ts — keep in sync if changing logic.
*/
export function toEntityPath(entityRef: string): string {
const ref = parseEntityRef(entityRef);
return `${ref.namespace}/${ref.kind}/${ref.name}`.toLocaleLowerCase("en-US");
}

/**
* Converts a slash-delimited entity path (e.g. "default/component/arch")
* back to the standard Backstage entity ref format (e.g. "component:default/arch").
*
* This is the inverse of `toEntityPath`. Note that the round-trip always
* produces lowercased refs since `toEntityPath` lowercases its output.
*/
export function fromEntityPath(path: string): string {
const parts = path.split("/");
if (parts.length !== 3 || parts.some((p) => !p)) {
throw new InputError(`Invalid entity path: "${path}" (expected "namespace/kind/name")`);
}
const [namespace, kind, name] = parts;
return `${kind}:${namespace}/${name}`;
}
175 changes: 175 additions & 0 deletions plugins/rw-backend/src/hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Hub } from "./hub";
import { createSite } from "@rwdocs/core";

jest.mock("@rwdocs/core");

const mockCreateSite = createSite as jest.MockedFunction<typeof createSite>;

function mockSite() {
return {
getNavigation: jest.fn(),
renderPage: jest.fn(),
reload: jest.fn(),
} as any;
}

describe("Hub", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("S3 mode", () => {
it("creates a site on first access for an entity ref", () => {
const site = mockSite();
mockCreateSite.mockReturnValue(site);

const hub = new Hub({
s3: { bucket: "my-bucket", region: "us-east-1" },
});

const result = hub.getSite("default/component/arch");

expect(result).toBe(site);
expect(mockCreateSite).toHaveBeenCalledWith({
s3: {
bucket: "my-bucket",
region: "us-east-1",
entity: "default/component/arch",
},
});
});

it("returns cached site on second access", () => {
const site = mockSite();
mockCreateSite.mockReturnValue(site);

const hub = new Hub({
s3: { bucket: "my-bucket" },
});

const first = hub.getSite("default/component/arch");
const second = hub.getSite("default/component/arch");

expect(first).toBe(second);
expect(mockCreateSite).toHaveBeenCalledTimes(1);
});

it("creates separate sites for different entity refs", () => {
const site1 = mockSite();
const site2 = mockSite();
mockCreateSite.mockReturnValueOnce(site1).mockReturnValueOnce(site2);

const hub = new Hub({
s3: { bucket: "my-bucket" },
});

const first = hub.getSite("default/component/arch");
const second = hub.getSite("default/component/billing");

expect(first).toBe(site1);
expect(second).toBe(site2);
expect(mockCreateSite).toHaveBeenCalledTimes(2);
});

it("evicts least recently used site when cache is full", () => {
const sites = [mockSite(), mockSite(), mockSite()];
let i = 0;
mockCreateSite.mockImplementation(() => sites[i++]);

const hub = new Hub({
s3: { bucket: "my-bucket" },
cacheSize: 2,
});

hub.getSite("a/b/one");
hub.getSite("a/b/two");
hub.getSite("a/b/three"); // evicts "one"

expect(mockCreateSite).toHaveBeenCalledTimes(3);

// "one" was evicted, accessing it creates a new site
mockCreateSite.mockReturnValue(mockSite());
hub.getSite("a/b/one");
expect(mockCreateSite).toHaveBeenCalledTimes(4);
});

it("passes shared config fields to every site", () => {
mockCreateSite.mockReturnValue(mockSite());

const hub = new Hub({
s3: {
bucket: "my-bucket",
region: "us-east-1",
endpoint: "http://localhost:4566",
bucketRootPath: "docs",
accessKeyId: "key",
secretAccessKey: "secret",
},
diagrams: { krokiUrl: "http://kroki:8080" },
});

hub.getSite("default/component/arch");

expect(mockCreateSite).toHaveBeenCalledWith({
s3: {
bucket: "my-bucket",
region: "us-east-1",
endpoint: "http://localhost:4566",
bucketRootPath: "docs",
accessKeyId: "key",
secretAccessKey: "secret",
entity: "default/component/arch",
},
diagrams: { krokiUrl: "http://kroki:8080" },
});
});
});

describe("projectDir mode", () => {
it("returns site for the configured entity ref", () => {
const site = mockSite();
mockCreateSite.mockReturnValue(site);

const hub = new Hub({
projectDir: "/path/to/docs",
entity: "component:default/arch",
diagrams: { krokiUrl: "http://kroki:8080" },
});

const result = hub.getSite("default/component/arch");

expect(result).toBe(site);
expect(mockCreateSite).toHaveBeenCalledWith({
projectDir: "/path/to/docs",
diagrams: { krokiUrl: "http://kroki:8080" },
});
});

it("returns undefined for non-matching entity ref", () => {
mockCreateSite.mockReturnValue(mockSite());

const hub = new Hub({
projectDir: "/path/to/docs",
entity: "component:default/arch",
});

const result = hub.getSite("default/component/other");

expect(result).toBeUndefined();
});

it("normalizes entity ref passed in constructor", () => {
const site = mockSite();
mockCreateSite.mockReturnValue(site);

const hub = new Hub({
projectDir: "/path/to/docs",
entity: "Component:default/arch",
});

const result = hub.getSite("default/component/arch");

expect(result).toBe(site);
});
});
});
Loading