From 4eadae5d2768b9029ae024ec73d49dc5a965421d Mon Sep 17 00:00:00 2001 From: Mike Yumatov Date: Mon, 23 Mar 2026 20:24:27 +0300 Subject: [PATCH] feat: support multiple entity-scoped documentation sites Refactor both plugins to serve and render docs per catalog entity instead of a single global site. Backend: add Hub class with LRU eviction for managing multiple RwSite instances, entity-scoped routes (/site/:ns/:kind/:name/*), and support for both local projectDir and S3 storage modes. Frontend: split monolithic viewer into RwEntityDocsViewer (reads rwdocs.org/ref annotation) and RwStandaloneViewer (reads rw.rootEntity config), add cross-entity section ref resolution via catalog API lookups with client-side caching, and wire scopePath into the RW viewer for scope-relative URL handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 37 ++-- packages/backend/package.json | 1 + packages/backend/src/index.ts | 1 + plugins/rw-backend/config.d.ts | 29 ++- plugins/rw-backend/package.json | 3 +- plugins/rw-backend/src/entityPath.test.ts | 42 ++++ plugins/rw-backend/src/entityPath.ts | 33 +++ plugins/rw-backend/src/hub.test.ts | 175 ++++++++++++++++ plugins/rw-backend/src/hub.ts | 81 ++++++++ plugins/rw-backend/src/plugin.ts | 70 ++++--- plugins/rw-backend/src/router.test.ts | 95 ++++++--- plugins/rw-backend/src/router.ts | 76 +++---- plugins/rw/config.d.ts | 18 ++ plugins/rw/package.json | 8 +- plugins/rw/src/api/RwClient.ts | 6 + .../rw/src/components/RwDocsViewer.test.tsx | 114 +++++++---- plugins/rw/src/components/RwDocsViewer.tsx | 97 +++++---- .../components/RwEntityDocsViewer.test.tsx | 140 +++++++++++++ .../rw/src/components/RwEntityDocsViewer.tsx | 60 ++++++ .../components/RwStandaloneViewer.test.tsx | 104 ++++++++++ .../rw/src/components/RwStandaloneViewer.tsx | 70 +++++++ plugins/rw/src/components/constants.ts | 2 + plugins/rw/src/components/entityPath.ts | 19 ++ .../rw/src/components/parseAnnotation.test.ts | 78 ++++++++ plugins/rw/src/components/parseAnnotation.ts | 49 +++++ .../components/useSectionRefResolver.test.tsx | 188 ++++++++++++++++++ .../src/components/useSectionRefResolver.ts | 61 ++++++ plugins/rw/src/plugin.tsx | 6 +- yarn.lock | 63 +++--- 29 files changed, 1507 insertions(+), 219 deletions(-) create mode 100644 plugins/rw-backend/src/entityPath.test.ts create mode 100644 plugins/rw-backend/src/entityPath.ts create mode 100644 plugins/rw-backend/src/hub.test.ts create mode 100644 plugins/rw-backend/src/hub.ts create mode 100644 plugins/rw/config.d.ts create mode 100644 plugins/rw/src/components/RwEntityDocsViewer.test.tsx create mode 100644 plugins/rw/src/components/RwEntityDocsViewer.tsx create mode 100644 plugins/rw/src/components/RwStandaloneViewer.test.tsx create mode 100644 plugins/rw/src/components/RwStandaloneViewer.tsx create mode 100644 plugins/rw/src/components/constants.ts create mode 100644 plugins/rw/src/components/entityPath.ts create mode 100644 plugins/rw/src/components/parseAnnotation.test.ts create mode 100644 plugins/rw/src/components/parseAnnotation.ts create mode 100644 plugins/rw/src/components/useSectionRefResolver.test.tsx create mode 100644 plugins/rw/src/components/useSectionRefResolver.ts diff --git a/CLAUDE.md b/CLAUDE.md index b265a55..999c0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,12 @@ 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 @@ -44,25 +49,33 @@ Tests use `backstage-cli package test` (Jest). No tests have been written yet. 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. diff --git a/packages/backend/package.json b/packages/backend/package.json index cb0bb0f..72c71ff 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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:*", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 82f112c..22e25ba 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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')); diff --git a/plugins/rw-backend/config.d.ts b/plugins/rw-backend/config.d.ts index 23f4454..a09dba1 100644 --- a/plugins/rw-backend/config.d.ts +++ b/plugins/rw-backend/config.d.ts @@ -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. */ @@ -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; diff --git a/plugins/rw-backend/package.json b/plugins/rw-backend/package.json index 49d4102..736cac0 100644 --- a/plugins/rw-backend/package.json +++ b/plugins/rw-backend/package.json @@ -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" }, diff --git a/plugins/rw-backend/src/entityPath.test.ts b/plugins/rw-backend/src/entityPath.test.ts new file mode 100644 index 0000000..d717dc5 --- /dev/null +++ b/plugins/rw-backend/src/entityPath.test.ts @@ -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/); + }); +}); diff --git a/plugins/rw-backend/src/entityPath.ts b/plugins/rw-backend/src/entityPath.ts new file mode 100644 index 0000000..b81fc66 --- /dev/null +++ b/plugins/rw-backend/src/entityPath.ts @@ -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}`; +} diff --git a/plugins/rw-backend/src/hub.test.ts b/plugins/rw-backend/src/hub.test.ts new file mode 100644 index 0000000..77ff303 --- /dev/null +++ b/plugins/rw-backend/src/hub.test.ts @@ -0,0 +1,175 @@ +import { Hub } from "./hub"; +import { createSite } from "@rwdocs/core"; + +jest.mock("@rwdocs/core"); + +const mockCreateSite = createSite as jest.MockedFunction; + +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); + }); + }); +}); diff --git a/plugins/rw-backend/src/hub.ts b/plugins/rw-backend/src/hub.ts new file mode 100644 index 0000000..26a4151 --- /dev/null +++ b/plugins/rw-backend/src/hub.ts @@ -0,0 +1,81 @@ +import { createSite, type RwSite, type SiteConfig, type DiagramsConfig } from "@rwdocs/core"; +import { toEntityPath } from "./entityPath"; + +export interface S3SharedConfig { + bucket: string; + region?: string; + endpoint?: string; + bucketRootPath?: string; + accessKeyId?: string; + secretAccessKey?: string; +} + +export interface HubOptions { + s3?: S3SharedConfig; + projectDir?: string; + /** Entity ref in any format accepted by parseEntityRef. Normalized internally. */ + entity?: string; + diagrams?: DiagramsConfig; + cacheSize?: number; +} + +export class Hub { + private readonly options: HubOptions; + private readonly cache: Map = new Map(); + private readonly maxSize: number; + + constructor(options: HubOptions) { + this.options = { + ...options, + entity: options.entity ? toEntityPath(options.entity) : undefined, + }; + this.maxSize = options.cacheSize ?? 20; + } + + getSite(entityRef: string): RwSite | undefined { + if (this.options.projectDir) { + return this.getLocalSite(entityRef); + } + return this.getS3Site(entityRef); + } + + private getLocalSite(entityRef: string): RwSite | undefined { + if (entityRef !== this.options.entity) { + return undefined; + } + + const cached = this.cache.get(entityRef); + if (cached) return cached; + + const site = createSite({ + projectDir: this.options.projectDir, + diagrams: this.options.diagrams, + }); + this.cache.set(entityRef, site); + return site; + } + + private getS3Site(entityRef: string): RwSite { + const cached = this.cache.get(entityRef); + if (cached) { + this.cache.delete(entityRef); + this.cache.set(entityRef, cached); + return cached; + } + + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value!; + this.cache.delete(firstKey); + } + + const s3 = this.options.s3!; + const config: SiteConfig = { + s3: { ...s3, entity: entityRef }, + diagrams: this.options.diagrams, + }; + + const site = createSite(config); + this.cache.set(entityRef, site); + return site; + } +} diff --git a/plugins/rw-backend/src/plugin.ts b/plugins/rw-backend/src/plugin.ts index 669daa1..6da4215 100644 --- a/plugins/rw-backend/src/plugin.ts +++ b/plugins/rw-backend/src/plugin.ts @@ -1,5 +1,7 @@ import { coreServices, createBackendPlugin } from "@backstage/backend-plugin-api"; -import { createRouter, type S3Options, type DiagramsOptions } from "./router"; +import { createRouter } from "./router"; +import { Hub, type HubOptions } from "./hub"; +import { toEntityPath } from "./entityPath"; export const rwPlugin = createBackendPlugin({ pluginId: "rw", @@ -13,46 +15,56 @@ export const rwPlugin = createBackendPlugin({ }, async init({ httpRouter, httpAuth, logger, config }) { const projectDir = config.getOptionalString("rw.projectDir"); + const entity = config.getOptionalString("rw.entity"); + const cacheSize = config.getOptionalNumber("rw.cacheSize"); - let s3: S3Options | undefined; const s3Config = config.getOptionalConfig("rw.s3"); - if (s3Config) { - s3 = { - bucket: s3Config.getString("bucket"), - entity: s3Config.getString("entity"), - region: s3Config.getOptionalString("region"), - endpoint: s3Config.getOptionalString("endpoint"), - bucketRootPath: s3Config.getOptionalString("bucketRootPath"), - accessKeyId: s3Config.getOptionalString("accessKeyId"), - secretAccessKey: s3Config.getOptionalString("secretAccessKey"), - }; - } + const s3 = s3Config + ? { + bucket: s3Config.getString("bucket"), + region: s3Config.getOptionalString("region"), + endpoint: s3Config.getOptionalString("endpoint"), + bucketRootPath: s3Config.getOptionalString("bucketRootPath"), + accessKeyId: s3Config.getOptionalString("accessKeyId"), + secretAccessKey: s3Config.getOptionalString("secretAccessKey"), + } + : undefined; if (!projectDir && !s3) { throw new Error("Either rw.projectDir or rw.s3 must be configured"); } - let diagrams: DiagramsOptions | undefined; - const diagramsConfig = config.getOptionalConfig("rw.diagrams"); - if (diagramsConfig) { - diagrams = { - krokiUrl: diagramsConfig.getOptionalString("krokiUrl"), - dpi: diagramsConfig.getOptionalNumber("dpi"), - }; + if (projectDir && !entity) { + throw new Error("rw.entity is required when rw.projectDir is set"); } - const linkPrefix = config.getOptionalString("rw.linkPrefix"); - if (linkPrefix) { - logger.info(`Using link prefix: ${linkPrefix}`); - } - const router = await createRouter({ - logger, - httpAuth, + const diagramsConfig = config.getOptionalConfig("rw.diagrams"); + const diagrams = diagramsConfig + ? { + krokiUrl: diagramsConfig.getOptionalString("krokiUrl"), + dpi: diagramsConfig.getOptionalNumber("dpi"), + } + : undefined; + + const hubOptions: HubOptions = { projectDir, + entity, s3, - linkPrefix, diagrams, - }); + cacheSize, + }; + + const hub = new Hub(hubOptions); + + if (s3) { + logger.info(`Hub: S3 mode (bucket: ${s3.bucket}, cache size: ${cacheSize ?? 20})`); + } else { + logger.info( + `Hub: local mode (${projectDir}, entity: ${entity ? toEntityPath(entity) : entity})`, + ); + } + + const router = await createRouter({ logger, httpAuth, hub }); httpRouter.use(router); httpRouter.addAuthPolicy({ path: "/health", diff --git a/plugins/rw-backend/src/router.test.ts b/plugins/rw-backend/src/router.test.ts index 587d2b3..89a4e96 100644 --- a/plugins/rw-backend/src/router.test.ts +++ b/plugins/rw-backend/src/router.test.ts @@ -2,43 +2,57 @@ import { mockServices } from "@backstage/backend-test-utils"; import express from "express"; import request from "supertest"; import { createRouter } from "./router"; +import { Hub } from "./hub"; import { createSite } from "@rwdocs/core"; jest.mock("@rwdocs/core"); const mockCreateSite = createSite as jest.MockedFunction; -describe("createRouter", () => { - let app: express.Express; +function makeApp(hub: Hub) { + return createRouter({ + logger: mockServices.logger.mock(), + httpAuth: mockServices.httpAuth.mock(), + hub, + }).then((router) => { + const app = express().use(router); + app.use( + (err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const statusByName: Record = { InputError: 400, NotFoundError: 404 }; + const status = statusByName[err.name] ?? 500; + res.status(status).json({ error: { name: err.name, message: err.message } }); + }, + ); + return app; + }); +} +describe("createRouter", () => { const mockSite = { getNavigation: jest.fn(), renderPage: jest.fn(), + reload: jest.fn(), }; + let app: express.Express; + beforeAll(async () => { mockCreateSite.mockReturnValue(mockSite as any); - - const router = await createRouter({ - logger: mockServices.logger.mock(), - httpAuth: mockServices.httpAuth.mock(), + const hub = new Hub({ projectDir: "/test/docs", + entity: "component:default/test", }); - - app = express().use(router); - app.use( - (err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const statusByName: Record = { InputError: 400, NotFoundError: 404 }; - const status = statusByName[err.name] ?? 500; - res.status(status).json({ error: { name: err.name, message: err.message } }); - }, - ); + app = await makeApp(hub); }); beforeEach(() => { - jest.clearAllMocks(); + mockSite.getNavigation.mockReset(); + mockSite.renderPage.mockReset(); + mockCreateSite.mockReturnValue(mockSite as any); }); + const prefix = "/site/default/component/test"; + describe("GET /health", () => { it("returns ok", async () => { const res = await request(app).get("/health"); @@ -47,39 +61,39 @@ describe("createRouter", () => { }); }); - describe("GET /config", () => { + describe(`GET ${prefix}/config`, () => { it("returns config with liveReloadEnabled false", async () => { - const res = await request(app).get("/config"); + const res = await request(app).get(`${prefix}/config`); expect(res.status).toBe(200); expect(res.body).toEqual({ liveReloadEnabled: false }); }); }); - describe("GET /navigation", () => { + describe(`GET ${prefix}/navigation`, () => { const mockNav = [{ title: "Home", path: "/" }]; it("returns navigation with null scope when no query param", async () => { mockSite.getNavigation.mockReturnValue(mockNav); - const res = await request(app).get("/navigation"); + const res = await request(app).get(`${prefix}/navigation`); expect(res.status).toBe(200); expect(res.body).toEqual(mockNav); expect(mockSite.getNavigation).toHaveBeenCalledWith(null); }); - it("passes scope query param to getNavigation", async () => { + it("passes sectionRef query param to getNavigation", async () => { mockSite.getNavigation.mockReturnValue(mockNav); - const res = await request(app).get("/navigation?scope=api"); + const res = await request(app).get(`${prefix}/navigation?sectionRef=api`); expect(res.status).toBe(200); expect(mockSite.getNavigation).toHaveBeenCalledWith("api"); }); }); - describe("GET /pages", () => { + describe(`GET ${prefix}/pages`, () => { const mockPage = { title: "Home", content: "

Welcome

" }; it("renders root page", async () => { mockSite.renderPage.mockResolvedValue(mockPage); - const res = await request(app).get("/pages/"); + const res = await request(app).get(`${prefix}/pages/`); expect(res.status).toBe(200); expect(res.body).toEqual(mockPage); expect(mockSite.renderPage).toHaveBeenCalledWith(""); @@ -87,28 +101,51 @@ describe("createRouter", () => { it("renders nested page", async () => { mockSite.renderPage.mockResolvedValue(mockPage); - const res = await request(app).get("/pages/getting-started"); + const res = await request(app).get(`${prefix}/pages/getting-started`); expect(res.status).toBe(200); expect(mockSite.renderPage).toHaveBeenCalledWith("getting-started"); }); it("rejects path traversal with 400", async () => { - // Encode slashes so the ".." segment is not normalized by the HTTP client - const res = await request(app).get("/pages/a%2F..%2Fb"); + const res = await request(app).get(`${prefix}/pages/a%2F..%2Fb`); expect(res.status).toBe(400); expect(mockSite.renderPage).not.toHaveBeenCalled(); }); it("returns 404 when page not found", async () => { mockSite.renderPage.mockRejectedValue(new Error("Content not found")); - const res = await request(app).get("/pages/nonexistent"); + const res = await request(app).get(`${prefix}/pages/nonexistent`); expect(res.status).toBe(404); }); it("returns 500 on unexpected render error", async () => { mockSite.renderPage.mockRejectedValue(new Error("disk read failed")); - const res = await request(app).get("/pages/broken"); + const res = await request(app).get(`${prefix}/pages/broken`); expect(res.status).toBe(500); }); + + it("serves scope root page when sectionRef query param is provided", async () => { + mockSite.getNavigation.mockReturnValue({ + items: [], + scope: { + path: "/domains/billing", + title: "Billing", + section: { kind: "domain", name: "billing" }, + }, + }); + + const mockScopedPage = { title: "Billing", content: "

Billing docs

" }; + mockSite.renderPage.mockResolvedValue(mockScopedPage); + + await request(app).get(`${prefix}/pages/?sectionRef=domain:default/billing`); + expect(mockSite.renderPage).toHaveBeenCalledWith("domains/billing"); + }); + }); + + describe("unknown entity ref", () => { + it("returns 404 for non-existent entity", async () => { + const res = await request(app).get("/site/default/component/unknown/config"); + expect(res.status).toBe(404); + }); }); }); diff --git a/plugins/rw-backend/src/router.ts b/plugins/rw-backend/src/router.ts index 1f5fbf8..4221ab8 100644 --- a/plugins/rw-backend/src/router.ts +++ b/plugins/rw-backend/src/router.ts @@ -1,65 +1,67 @@ import Router from "express-promise-router"; import type { HttpAuthService, LoggerService } from "@backstage/backend-plugin-api"; import { InputError, NotFoundError } from "@backstage/errors"; -import { createSite, type RwSite, type SiteConfig } from "@rwdocs/core"; - -export interface S3Options { - bucket: string; - entity: string; - region?: string; - endpoint?: string; - bucketRootPath?: string; - accessKeyId?: string; - secretAccessKey?: string; -} - -export interface DiagramsOptions { - krokiUrl?: string; - dpi?: number; -} +import type { RwSite } from "@rwdocs/core"; +import type { Hub } from "./hub"; export interface RouterOptions { logger: LoggerService; httpAuth: HttpAuthService; - projectDir?: string; - s3?: S3Options; - linkPrefix?: string; - diagrams?: DiagramsOptions; + hub: Hub; } export async function createRouter(options: RouterOptions) { - const { logger, projectDir, s3, linkPrefix, diagrams } = options; + const { hub } = options; const router = Router(); - const config: SiteConfig = { projectDir, s3, linkPrefix, diagrams }; - logger.info( - s3 - ? `Creating RW site from S3 (${s3.bucket}/${s3.entity})` - : `Creating RW site from ${projectDir}`, - ); - const site: RwSite = createSite(config); - router.get("/health", (_req, res) => { res.json({ status: "ok" }); }); - router.get("/config", (_req, res) => { + router.use("/site/:namespace/:kind/:name", (req, res, next) => { + const { namespace, kind, name } = req.params; + const siteRef = `${namespace}/${kind}/${name}`.toLowerCase(); + + const site = hub.getSite(siteRef); + if (!site) { + throw new NotFoundError(`No documentation site found for entity: ${siteRef}`); + } + + res.locals.rwSite = site; + next(); + }); + + router.get("/site/:namespace/:kind/:name/config", (_req, res) => { res.json({ liveReloadEnabled: false }); }); - router.get("/navigation", (req, res) => { - const scopeParam = req.query.scope; - const scope = typeof scopeParam === "string" ? scopeParam : undefined; - const nav = site.getNavigation(scope ?? null); + router.get("/site/:namespace/:kind/:name/navigation", (req, res) => { + const site: RwSite = res.locals.rwSite; + const sectionRefParam = req.query.sectionRef; + const sectionRef = typeof sectionRefParam === "string" ? sectionRefParam : null; + const nav = site.getNavigation(sectionRef); res.json(nav); }); - router.get("/pages/", async (_req, res) => { - const page = await renderPageOrThrow(site, ""); + router.get("/site/:namespace/:kind/:name/pages/", async (req, res) => { + const site: RwSite = res.locals.rwSite; + const sectionRefParam = req.query.sectionRef; + const sectionRef = typeof sectionRefParam === "string" ? sectionRefParam : undefined; + + let pagePath = ""; + if (sectionRef) { + const nav = site.getNavigation(sectionRef); + if (nav.scope?.path) { + pagePath = nav.scope.path.replace(/^\//, ""); + } + } + + const page = await renderPageOrThrow(site, pagePath); res.json(page); }); - router.get("/pages/:path(*)", async (req, res) => { + router.get("/site/:namespace/:kind/:name/pages/:path(*)", async (req, res) => { + const site: RwSite = res.locals.rwSite; const pagePath = req.params.path || ""; if (pagePath.split("/").includes("..")) { throw new InputError("Invalid path"); diff --git a/plugins/rw/config.d.ts b/plugins/rw/config.d.ts new file mode 100644 index 0000000..a9d7e9b --- /dev/null +++ b/plugins/rw/config.d.ts @@ -0,0 +1,18 @@ +export interface Config { + rw?: { + /** + * 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; + /** + * Section ref for the standalone /docs page. + * If not set, defaults to rootEntity value. + * + * @visibility frontend + */ + rootSectionRef?: string; + }; +} diff --git a/plugins/rw/package.json b/plugins/rw/package.json index e2b7672..e5ba3a9 100644 --- a/plugins/rw/package.json +++ b/plugins/rw/package.json @@ -27,7 +27,9 @@ "@rwdocs/backstage-plugin-rw-backend" ] }, + "configSchema": "config.d.ts", "files": [ + "config.d.ts", "dist", "LICENSE-MIT", "LICENSE-APACHE" @@ -44,14 +46,15 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@rwdocs/viewer": "^0.1.17" + "@backstage/catalog-model": "^1.7.6", + "@rwdocs/viewer": "^0.1.18" }, "peerDependencies": { "@backstage/core-components": "^0.18.0", "@backstage/core-plugin-api": "^1.0.0", "@backstage/frontend-plugin-api": "^0.14.0", "@backstage/plugin-catalog-react": "^2.0.0", - "@mui/material": "^5.0.0", + "@material-ui/core": "^4.12.2", "react": "^18.0.0", "react-dom": "^18.0.0", "react-router-dom": "^6.0.0" @@ -63,6 +66,7 @@ }, "devDependencies": { "@backstage/cli": "^0.35.0", + "@backstage/config": "^1.3.6", "@backstage/core-components": "^0.18.0", "@backstage/core-plugin-api": "^1.0.0", "@backstage/frontend-plugin-api": "^0.14.0", diff --git a/plugins/rw/src/api/RwClient.ts b/plugins/rw/src/api/RwClient.ts index ec6b30b..8ac5ea9 100644 --- a/plugins/rw/src/api/RwClient.ts +++ b/plugins/rw/src/api/RwClient.ts @@ -3,6 +3,7 @@ import type { DiscoveryApi, FetchApi } from "@backstage/core-plugin-api"; export interface RwApi { getBaseUrl(): Promise; + getSiteBaseUrl(entityRef: string): Promise; getFetch(): typeof fetch; } @@ -21,6 +22,11 @@ export class RwClient implements RwApi { return this.discoveryApi.getBaseUrl("rw"); } + async getSiteBaseUrl(entityRef: string): Promise { + const base = await this.discoveryApi.getBaseUrl("rw"); + return `${base}/site/${entityRef}`; + } + getFetch(): typeof fetch { return this.fetchApi.fetch; } diff --git a/plugins/rw/src/components/RwDocsViewer.test.tsx b/plugins/rw/src/components/RwDocsViewer.test.tsx index eefbaf4..c53aaf0 100644 --- a/plugins/rw/src/components/RwDocsViewer.test.tsx +++ b/plugins/rw/src/components/RwDocsViewer.test.tsx @@ -3,6 +3,7 @@ import { renderInTestApp, TestApiProvider } from "@backstage/test-utils"; import { RwDocsViewer } from "./RwDocsViewer"; import { rwApiRef } from "../api/RwClient"; import type { RwApi } from "../api/RwClient"; +import { catalogApiRef } from "@backstage/plugin-catalog-react"; import { mountRw } from "@rwdocs/viewer"; jest.mock("@rwdocs/viewer", () => ({ @@ -11,16 +12,51 @@ jest.mock("@rwdocs/viewer", () => ({ jest.mock("@rwdocs/viewer/embed.css", () => ({})); +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useRouteRef: () => + jest.fn(({ kind, namespace, name }: any) => `/catalog/${namespace}/${kind}/${name}`), +})); + const mockMountRw = mountRw as jest.MockedFunction; +const TEST_API_BASE_URL = "http://localhost:7007/api/rw/site/default/component/my-docs"; +const TEST_SOURCE_ENTITY_REF = "component:default/my-docs"; + +const mockCatalogApi = { + getEntityByRef: jest.fn().mockResolvedValue(undefined), +}; + function createMockRwApi(overrides?: Partial): RwApi { return { getBaseUrl: jest.fn().mockResolvedValue("http://localhost:7007/api/rw"), + getSiteBaseUrl: jest + .fn() + .mockImplementation((entityRef: string) => + Promise.resolve(`http://localhost:7007/api/rw/site/${entityRef}`), + ), getFetch: jest.fn().mockReturnValue(jest.fn()), ...overrides, }; } +function renderViewer(mockApi: RwApi, props?: { sectionRef?: string; sourceEntityRef?: string }) { + return renderInTestApp( + + + , + ); +} + describe("RwDocsViewer", () => { const mockDestroy = jest.fn(); @@ -34,26 +70,17 @@ describe("RwDocsViewer", () => { }); it("renders a container element", async () => { - const mockApi = createMockRwApi(); - await renderInTestApp( - - - , - ); + await renderViewer(createMockRwApi()); expect(document.querySelector(".rw-root")).toBeInTheDocument(); }); - it("calls mountRw with correct options after resolving base URL", async () => { + it("calls mountRw with correct options", async () => { const mockFetch = jest.fn() as unknown as typeof fetch; const mockApi = createMockRwApi({ getFetch: jest.fn().mockReturnValue(mockFetch), }); - await renderInTestApp( - - - , - ); + await renderViewer(mockApi); await waitFor(() => { expect(mockMountRw).toHaveBeenCalledTimes(1); @@ -61,20 +88,15 @@ describe("RwDocsViewer", () => { const [container, options] = mockMountRw.mock.calls[0]; expect(container).toBeInstanceOf(HTMLDivElement); - expect(options.apiBaseUrl).toBe("http://localhost:7007/api/rw"); + expect(options.apiBaseUrl).toBe(TEST_API_BASE_URL); expect(options.fetchFn).toBe(mockFetch); expect(options.initialPath).toBe("/"); + expect(options.sectionRef).toBe(TEST_SOURCE_ENTITY_REF); expect(typeof options.onNavigate).toBe("function"); }); it("passes colorScheme matching the Backstage theme to mountRw", async () => { - const mockApi = createMockRwApi(); - - await renderInTestApp( - - - , - ); + await renderViewer(createMockRwApi()); await waitFor(() => { expect(mockMountRw).toHaveBeenCalledTimes(1); @@ -84,30 +106,50 @@ describe("RwDocsViewer", () => { expect(options.colorScheme).toBe("light"); }); - it("shows ErrorPanel when getBaseUrl rejects", async () => { - const mockApi = createMockRwApi({ - getBaseUrl: jest.fn().mockRejectedValue(new Error("discovery failed")), + it("shows ErrorPanel when mountRw throws", async () => { + mockMountRw.mockImplementation(() => { + throw new Error("mount failed"); }); - await renderInTestApp( - - - , - ); + await renderViewer(createMockRwApi()); await waitFor(() => { - expect(screen.getAllByText(/discovery failed/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/mount failed/).length).toBeGreaterThan(0); }); }); - it("calls destroy on unmount", async () => { - const mockApi = createMockRwApi(); + it("passes resolveSectionRefs to mountRw", async () => { + await renderViewer(createMockRwApi()); + + await waitFor(() => { + expect(mockMountRw).toHaveBeenCalledTimes(1); + }); - const { unmount } = await renderInTestApp( - - - , - ); + const [, options] = mockMountRw.mock.calls[0]; + expect(typeof options.resolveSectionRefs).toBe("function"); + }); + + it("overrides self sectionRef with current basePath in resolveSectionRefs", async () => { + await renderViewer(createMockRwApi()); + + await waitFor(() => { + expect(mockMountRw).toHaveBeenCalledTimes(1); + }); + + const [, options] = mockMountRw.mock.calls[0]; + const result = await options.resolveSectionRefs!([ + TEST_SOURCE_ENTITY_REF, + "component:default/other", + ]); + + // Self ref should map to the current basePath, not a catalog route + expect(result[TEST_SOURCE_ENTITY_REF]).toBe("/"); + // Other ref not in catalog → omitted + expect(result["component:default/other"]).toBeUndefined(); + }); + + it("calls destroy on unmount", async () => { + const { unmount } = await renderViewer(createMockRwApi()); await waitFor(() => { expect(mockMountRw).toHaveBeenCalledTimes(1); diff --git a/plugins/rw/src/components/RwDocsViewer.tsx b/plugins/rw/src/components/RwDocsViewer.tsx index 9fb5662..a7b5fdd 100644 --- a/plugins/rw/src/components/RwDocsViewer.tsx +++ b/plugins/rw/src/components/RwDocsViewer.tsx @@ -1,18 +1,26 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import { useApi } from "@backstage/core-plugin-api"; import { ErrorPanel } from "@backstage/core-components"; -import { useTheme } from "@mui/material/styles"; +import { useTheme } from "@material-ui/core/styles"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { rwApiRef } from "../api/RwClient"; +import { useSectionRefResolver } from "./useSectionRefResolver"; import { mountRw } from "@rwdocs/viewer"; import type { RwInstance } from "@rwdocs/viewer"; import "@rwdocs/viewer/embed.css"; -export function RwDocsViewer() { +interface RwDocsViewerProps { + apiBaseUrl: string; + sectionRef: string; + sourceEntityRef: string; +} + +export function RwDocsViewer({ apiBaseUrl, sectionRef, sourceEntityRef }: RwDocsViewerProps) { const ref = useRef(null); const rwApi = useApi(rwApiRef); const theme = useTheme(); const [error, setError] = useState(null); + const catalogResolver = useSectionRefResolver(sourceEntityRef); const location = useLocation(); const navigate = useNavigate(); @@ -20,61 +28,69 @@ export function RwDocsViewer() { navigateRef.current = navigate; const { "*": subPath = "" } = useParams(); - // Derive the plugin's base path by stripping the sub-path (and its leading slash) from the URL. - // e.g. URL="/rw-docs/getting-started", subPath="getting-started" → base="/rw-docs" const basePath = subPath ? location.pathname.slice(0, -(subPath.length + 1)) : location.pathname; const basePathRef = useRef(basePath); basePathRef.current = basePath; + const resolveSectionRefs = useCallback( + async (refs: string[]): Promise> => { + const otherRefs = refs.filter((r) => r !== sectionRef); + const result = otherRefs.length > 0 ? await catalogResolver(otherRefs) : {}; + if (refs.includes(sectionRef)) { + result[sectionRef] = basePathRef.current; + } + return result; + }, + [catalogResolver, sectionRef], + ); + const instanceRef = useRef(null); const prevSubPathRef = useRef(subPath); const rwNavigatingRef = useRef(false); useEffect(() => { - let cancelled = false; - - rwApi - .getBaseUrl() - .then((baseUrl) => { - if (cancelled || !ref.current) return; - - const base = basePathRef.current; - const initialPath = subPath ? `/${subPath}` : "/"; - - instanceRef.current = mountRw(ref.current, { - apiBaseUrl: baseUrl, - initialPath, - basePath: base, - fetchFn: rwApi.getFetch(), - colorScheme: theme.palette.mode, - onNavigate: (rwPath: string) => { - const browserPath = rwPath === "/" ? base : `${base}${rwPath}`; - if (window.location.pathname !== browserPath) { - rwNavigatingRef.current = true; - navigateRef.current(browserPath, { replace: false }); - } - }, - }); - }) - .catch((err) => { - if (!cancelled) setError(err); + if (!ref.current) { + return undefined; + } + + try { + let initialPath = "/"; + if (subPath) { + initialPath = `/${subPath}`; + } + if (location.hash) { + initialPath += location.hash; + } + + instanceRef.current = mountRw(ref.current, { + apiBaseUrl, + initialPath, + sectionRef, + fetchFn: rwApi.getFetch(), + colorScheme: theme.palette.type, + resolveSectionRefs, + onNavigate: (href: string) => { + if (window.location.pathname !== href) { + rwNavigatingRef.current = true; + navigateRef.current(href, { replace: false }); + } + }, }); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } return () => { - cancelled = true; instanceRef.current?.destroy(); instanceRef.current = null; }; - // Re-mount when API changes — theme and navigation sync are handled by effects below // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rwApi]); + }, [apiBaseUrl, sectionRef]); - // Sync Backstage theme to the RW viewer useEffect(() => { - instanceRef.current?.setColorScheme(theme.palette.mode); - }, [theme.palette.mode]); + instanceRef.current?.setColorScheme(theme.palette.type); + }, [theme.palette.type]); - // Sync external navigation (browser back/forward) to the RW app useEffect(() => { if (subPath === prevSubPathRef.current) return; prevSubPathRef.current = subPath; @@ -84,7 +100,6 @@ export function RwDocsViewer() { return; } - // External navigation — tell RW to navigate const rwPath = subPath ? `/${subPath}` : "/"; instanceRef.current?.navigateTo(rwPath); }, [subPath]); @@ -93,5 +108,5 @@ export function RwDocsViewer() { return ; } - return
; + return
; } diff --git a/plugins/rw/src/components/RwEntityDocsViewer.test.tsx b/plugins/rw/src/components/RwEntityDocsViewer.test.tsx new file mode 100644 index 0000000..8281529 --- /dev/null +++ b/plugins/rw/src/components/RwEntityDocsViewer.test.tsx @@ -0,0 +1,140 @@ +import { screen, waitFor } from "@testing-library/react"; +import { renderInTestApp, TestApiProvider } from "@backstage/test-utils"; +import { catalogApiRef, EntityProvider } from "@backstage/plugin-catalog-react"; +import { Entity } from "@backstage/catalog-model"; +import { RwEntityDocsViewer } from "./RwEntityDocsViewer"; +import { rwApiRef } from "../api/RwClient"; +import type { RwApi } from "../api/RwClient"; + +const mockCatalogApi = { + getEntityByRef: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock("@rwdocs/viewer/embed.css", () => ({})); + +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useRouteRef: () => + jest.fn(({ kind, namespace, name }: any) => `/catalog/${namespace}/${kind}/${name}`), +})); + +function createMockRwApi(overrides?: Partial): RwApi { + return { + getBaseUrl: jest.fn().mockResolvedValue("http://localhost:7007/api/rw"), + getSiteBaseUrl: jest + .fn() + .mockImplementation((entityRef: string) => + Promise.resolve(`http://localhost:7007/api/rw/site/${entityRef}`), + ), + getFetch: jest.fn().mockReturnValue(jest.fn()), + ...overrides, + }; +} + +function makeEntity(annotations?: Record): Entity { + return { + apiVersion: "backstage.io/v1alpha1", + kind: "Component", + metadata: { + name: "my-service", + namespace: "default", + annotations: annotations ?? {}, + }, + }; +} + +describe("RwEntityDocsViewer", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows error when annotation is missing", async () => { + const mockApi = createMockRwApi(); + const entity = makeEntity(); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(screen.getAllByText(/rwdocs.org\/ref/).length).toBeGreaterThan(0); + }); + }); + + it("resolves base URL and renders viewer for self-ref annotation", async () => { + const mockApi = createMockRwApi(); + const entity = makeEntity({ "rwdocs.org/ref": "." }); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(mockApi.getSiteBaseUrl).toHaveBeenCalledWith("default/component/my-service"); + }); + }); + + it("resolves base URL using source entity ref from annotation", async () => { + const mockApi = createMockRwApi(); + const entity = makeEntity({ "rwdocs.org/ref": "component:default/other-docs" }); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(mockApi.getSiteBaseUrl).toHaveBeenCalledWith("default/component/other-docs"); + }); + }); + + it("shows error when getSiteBaseUrl rejects", async () => { + const mockApi = createMockRwApi({ + getSiteBaseUrl: jest.fn().mockRejectedValue(new Error("network error")), + }); + const entity = makeEntity({ "rwdocs.org/ref": "." }); + + await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(screen.getAllByText(/network error/).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/plugins/rw/src/components/RwEntityDocsViewer.tsx b/plugins/rw/src/components/RwEntityDocsViewer.tsx new file mode 100644 index 0000000..0db3511 --- /dev/null +++ b/plugins/rw/src/components/RwEntityDocsViewer.tsx @@ -0,0 +1,60 @@ +import { useEffect, useMemo, useState } from "react"; +import { useApi } from "@backstage/core-plugin-api"; +import { useEntity } from "@backstage/plugin-catalog-react"; +import { getCompoundEntityRef } from "@backstage/catalog-model"; +import { ErrorPanel, Progress } from "@backstage/core-components"; +import { rwApiRef } from "../api/RwClient"; +import { toEntityPath } from "./entityPath"; +import { ANNOTATION_KEY } from "./constants"; +import { parseAnnotation } from "./parseAnnotation"; +import { RwDocsViewer } from "./RwDocsViewer"; + +export function RwEntityDocsViewer() { + const { entity } = useEntity(); + const rwApi = useApi(rwApiRef); + const [apiBaseUrl, setApiBaseUrl] = useState(null); + const [fetchError, setFetchError] = useState(null); + + const annotationValue = entity.metadata.annotations?.[ANNOTATION_KEY]; + const selfEntityRef = useMemo(() => toEntityPath(getCompoundEntityRef(entity)), [entity]); + const parsed = parseAnnotation(annotationValue, selfEntityRef); + + useEffect(() => { + if (!parsed) return undefined; + + let cancelled = false; + rwApi + .getSiteBaseUrl(parsed.entityPath) + .then((url) => { + if (!cancelled) setApiBaseUrl(url); + }) + .catch((err) => { + if (!cancelled) setFetchError(err); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- parsed is derived from annotationValue+selfEntityRef; using entityPath avoids object-identity churn + }, [rwApi, parsed?.entityPath]); + + if (!parsed) { + return ; + } + + if (fetchError) { + return ; + } + + if (!apiBaseUrl) { + return ; + } + + const sectionRef = parsed.sectionRef ?? selfEntityRef; + return ( + + ); +} diff --git a/plugins/rw/src/components/RwStandaloneViewer.test.tsx b/plugins/rw/src/components/RwStandaloneViewer.test.tsx new file mode 100644 index 0000000..9f6dc17 --- /dev/null +++ b/plugins/rw/src/components/RwStandaloneViewer.test.tsx @@ -0,0 +1,104 @@ +import { screen, waitFor } from "@testing-library/react"; +import { renderInTestApp, TestApiProvider } from "@backstage/test-utils"; +import { configApiRef } from "@backstage/core-plugin-api"; +import { ConfigReader } from "@backstage/config"; +import { catalogApiRef } from "@backstage/plugin-catalog-react"; +import { RwStandaloneViewer } from "./RwStandaloneViewer"; +import { rwApiRef } from "../api/RwClient"; +import type { RwApi } from "../api/RwClient"; + +const mockCatalogApi = { + getEntityByRef: jest.fn().mockResolvedValue(undefined), +}; + +jest.mock("@rwdocs/viewer/embed.css", () => ({})); + +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useRouteRef: () => + jest.fn(({ kind, namespace, name }: any) => `/catalog/${namespace}/${kind}/${name}`), +})); + +function createMockRwApi(overrides?: Partial): RwApi { + return { + getBaseUrl: jest.fn().mockResolvedValue("http://localhost:7007/api/rw"), + getSiteBaseUrl: jest + .fn() + .mockImplementation((entityRef: string) => + Promise.resolve(`http://localhost:7007/api/rw/site/${entityRef}`), + ), + getFetch: jest.fn().mockReturnValue(jest.fn()), + ...overrides, + }; +} + +describe("RwStandaloneViewer", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows error when rw.rootEntity is not configured", async () => { + const mockApi = createMockRwApi(); + const configApi = new ConfigReader({}); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.getAllByText(/rw.rootEntity must be configured/).length).toBeGreaterThan(0); + }); + }); + + it("resolves base URL when rootEntity is configured", async () => { + const mockApi = createMockRwApi(); + const configApi = new ConfigReader({ rw: { rootEntity: "component:default/main-docs" } }); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(mockApi.getSiteBaseUrl).toHaveBeenCalledWith("default/component/main-docs"); // parsed from "component:default/main-docs" + }); + }); + + it("shows error when getSiteBaseUrl rejects", async () => { + const mockApi = createMockRwApi({ + getSiteBaseUrl: jest.fn().mockRejectedValue(new Error("discovery failed")), + }); + const configApi = new ConfigReader({ rw: { rootEntity: "component:default/main-docs" } }); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.getAllByText(/discovery failed/).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/plugins/rw/src/components/RwStandaloneViewer.tsx b/plugins/rw/src/components/RwStandaloneViewer.tsx new file mode 100644 index 0000000..bc536d4 --- /dev/null +++ b/plugins/rw/src/components/RwStandaloneViewer.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from "react"; +import { useApi, configApiRef } from "@backstage/core-plugin-api"; +import { ErrorPanel, Progress } from "@backstage/core-components"; +import { rwApiRef } from "../api/RwClient"; +import { toEntityPath } from "./entityPath"; +import { RwDocsViewer } from "./RwDocsViewer"; + +export function RwStandaloneViewer() { + const rwApi = useApi(rwApiRef); + const configApi = useApi(configApiRef); + const [apiBaseUrl, setApiBaseUrl] = useState(null); + const [fetchError, setFetchError] = useState(null); + + const rootEntityRaw = configApi.getOptionalString("rw.rootEntity"); + const rootSectionRefRaw = configApi.getOptionalString("rw.rootSectionRef"); + + const { entityPath, sectionRef, configError } = useMemo(() => { + if (!rootEntityRaw) { + return { + entityPath: undefined, + sectionRef: undefined, + configError: new Error("rw.rootEntity must be configured for the standalone /docs page"), + }; + } + try { + const ep = toEntityPath(rootEntityRaw); + return { + entityPath: ep, + sectionRef: rootSectionRefRaw ?? rootEntityRaw, + configError: undefined, + }; + } catch (err) { + return { entityPath: undefined, sectionRef: undefined, configError: err as Error }; + } + }, [rootEntityRaw, rootSectionRefRaw]); + + useEffect(() => { + if (!entityPath) return undefined; + + let cancelled = false; + rwApi + .getSiteBaseUrl(entityPath) + .then((url) => { + if (!cancelled) setApiBaseUrl(url); + }) + .catch((err) => { + if (!cancelled) setFetchError(err); + }); + return () => { + cancelled = true; + }; + }, [rwApi, entityPath]); + + const error = configError ?? fetchError; + if (error) { + return ; + } + + if (!apiBaseUrl || !sectionRef) { + return ; + } + + return ( + + ); +} diff --git a/plugins/rw/src/components/constants.ts b/plugins/rw/src/components/constants.ts new file mode 100644 index 0000000..e480489 --- /dev/null +++ b/plugins/rw/src/components/constants.ts @@ -0,0 +1,2 @@ +export const ANNOTATION_KEY = "rwdocs.org/ref"; +export const ROOT_SECTION_REF = "section:default/root"; diff --git a/plugins/rw/src/components/entityPath.ts b/plugins/rw/src/components/entityPath.ts new file mode 100644 index 0000000..01dd882 --- /dev/null +++ b/plugins/rw/src/components/entityPath.ts @@ -0,0 +1,19 @@ +import { parseEntityRef } from "@backstage/catalog-model"; + +/** + * Converts an entity ref (e.g. "component:default/arch") or a compound ref + * object to the slash-delimited, lowercased path used in API URLs + * (e.g. "default/component/arch"). + * + * Uses namespace/kind/name ordering to match Backstage catalog URL convention. + * + * NOTE: The backend plugin has a similar utility at + * plugins/rw-backend/src/entityPath.ts — keep in sync if changing logic. + */ +export function toEntityPath( + ref: string | { kind: string; namespace?: string; name: string }, +): string { + const parsed = typeof ref === "string" ? parseEntityRef(ref) : ref; + const ns = parsed.namespace ?? "default"; + return `${ns}/${parsed.kind}/${parsed.name}`.toLocaleLowerCase("en-US"); +} diff --git a/plugins/rw/src/components/parseAnnotation.test.ts b/plugins/rw/src/components/parseAnnotation.test.ts new file mode 100644 index 0000000..0269d38 --- /dev/null +++ b/plugins/rw/src/components/parseAnnotation.test.ts @@ -0,0 +1,78 @@ +import { parseAnnotation } from "./parseAnnotation"; + +describe("parseAnnotation", () => { + it("parses self-ref with no sectionRef", () => { + expect(parseAnnotation(".", "default/component/my-service")).toEqual({ + entityPath: "default/component/my-service", + entityRef: "component:default/my-service", + sectionRef: undefined, + }); + }); + + it("parses explicit entity ref with no sectionRef", () => { + expect(parseAnnotation("component:default/arch", "default/component/my-service")).toEqual({ + entityPath: "default/component/arch", + entityRef: "component:default/arch", + sectionRef: undefined, + }); + }); + + it("parses explicit entity ref with sectionRef", () => { + expect( + parseAnnotation("component:default/arch#domains/billing", "default/component/my-service"), + ).toEqual({ + entityPath: "default/component/arch", + entityRef: "component:default/arch", + sectionRef: "domains/billing", + }); + }); + + it("parses self-ref with sectionRef", () => { + expect(parseAnnotation(".#domains/billing", "default/component/my-service")).toEqual({ + entityPath: "default/component/my-service", + entityRef: "component:default/my-service", + sectionRef: "domains/billing", + }); + }); + + it("handles deeply nested sectionRef", () => { + expect( + parseAnnotation( + "component:default/arch#domains/billing/systems/wallets", + "default/component/x", + ), + ).toEqual({ + entityPath: "default/component/arch", + entityRef: "component:default/arch", + sectionRef: "domains/billing/systems/wallets", + }); + }); + + it("uses default namespace when not specified", () => { + expect(parseAnnotation("component:arch", "default/component/x")).toEqual({ + entityPath: "default/component/arch", + entityRef: "component:default/arch", + sectionRef: undefined, + }); + }); + + it("returns undefined for empty string", () => { + expect(parseAnnotation("", "default/component/x")).toBeUndefined(); + }); + + it("returns undefined for undefined input", () => { + expect(parseAnnotation(undefined, "default/component/x")).toBeUndefined(); + }); + + it("returns undefined for malformed entity ref", () => { + expect(parseAnnotation(":::", "default/component/x")).toBeUndefined(); + }); + + it("treats empty hash as no sectionRef", () => { + expect(parseAnnotation("component:default/arch#", "default/component/x")).toEqual({ + entityPath: "default/component/arch", + entityRef: "component:default/arch", + sectionRef: undefined, + }); + }); +}); diff --git a/plugins/rw/src/components/parseAnnotation.ts b/plugins/rw/src/components/parseAnnotation.ts new file mode 100644 index 0000000..83e051f --- /dev/null +++ b/plugins/rw/src/components/parseAnnotation.ts @@ -0,0 +1,49 @@ +import { parseEntityRef, stringifyEntityRef } from "@backstage/catalog-model"; +import { toEntityPath } from "./entityPath"; + +export interface ParsedAnnotation { + /** Slash-delimited path for API URLs (e.g. "default/component/arch"). */ + entityPath: string; + /** Standard Backstage entity ref (e.g. "component:default/arch"). */ + entityRef: string; + sectionRef: string | undefined; +} + +export function parseAnnotation( + value: string | undefined, + selfEntityRef: string, +): ParsedAnnotation | undefined { + if (!value) return undefined; + + const hashIndex = value.indexOf("#"); + let entity: string; + let sectionRef: string | undefined; + + if (hashIndex === -1) { + entity = value; + sectionRef = undefined; + } else { + entity = value.slice(0, hashIndex); + sectionRef = value.slice(hashIndex + 1) || undefined; + } + + if (entity === ".") { + return { entityPath: selfEntityRef, entityRef: fromEntityPath(selfEntityRef), sectionRef }; + } + + try { + return { + entityPath: toEntityPath(entity), + entityRef: stringifyEntityRef(parseEntityRef(entity)), + sectionRef, + }; + } catch { + return undefined; + } +} + +/** Convert slash-delimited path (namespace/kind/name) back to colon-format entity ref. */ +function fromEntityPath(path: string): string { + const [namespace, kind, name] = path.split("/"); + return stringifyEntityRef({ kind, namespace, name }); +} diff --git a/plugins/rw/src/components/useSectionRefResolver.test.tsx b/plugins/rw/src/components/useSectionRefResolver.test.tsx new file mode 100644 index 0000000..0832075 --- /dev/null +++ b/plugins/rw/src/components/useSectionRefResolver.test.tsx @@ -0,0 +1,188 @@ +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { TestApiProvider } from "@backstage/test-utils"; +import { catalogApiRef } from "@backstage/plugin-catalog-react"; +import { useSectionRefResolver } from "./useSectionRefResolver"; +import { ANNOTATION_KEY } from "./constants"; +import type { Entity } from "@backstage/catalog-model"; + +const mockEntityRoute = jest.fn( + ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => + `/catalog/${namespace}/${kind}/${name}`, +); + +jest.mock("@backstage/plugin-catalog-react", () => ({ + ...jest.requireActual("@backstage/plugin-catalog-react"), + entityRouteRef: { id: "mock-entity-route-ref" }, +})); + +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useRouteRef: () => mockEntityRoute, +})); + +const SOURCE_ENTITY_REF = "component:default/arch"; + +function makeEntity(annotations?: Record): Entity { + return { + apiVersion: "backstage.io/v1alpha1", + kind: "Domain", + metadata: { name: "billing", namespace: "default", annotations }, + }; +} + +function createMockCatalogApi(entities: Record) { + return { + getEntitiesByRefs: jest + .fn() + .mockImplementation(({ entityRefs }: { entityRefs: string[] }) => + Promise.resolve({ items: entityRefs.map((ref) => entities[ref] ?? undefined) }), + ), + }; +} + +function renderWithCatalog(catalogApi: { getEntitiesByRefs: jest.Mock }) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useSectionRefResolver(SOURCE_ENTITY_REF), { wrapper }); +} + +describe("useSectionRefResolver", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("resolves section refs to catalog URLs via catalog API", async () => { + const entity = makeEntity({ [ANNOTATION_KEY]: "." }); + const catalogApi = createMockCatalogApi({ "domain:default/billing": entity }); + const { result } = renderWithCatalog(catalogApi); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/billing"]); + }); + + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ + entityRefs: ["domain:default/billing"], + }); + expect(resolved).toEqual({ + "domain:default/billing": "/catalog/default/domain/billing/docs", + }); + }); + + it("resolves root section ref to the source entity", async () => { + const catalogApi = createMockCatalogApi({}); + const { result } = renderWithCatalog(catalogApi); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["section:default/root"]); + }); + + expect(catalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); + expect(resolved).toEqual({ + "section:default/root": "/catalog/default/component/arch/docs", + }); + }); + + it("returns empty map for entities without rwdocs annotation", async () => { + const entity = makeEntity({}); + const catalogApi = createMockCatalogApi({ "domain:default/billing": entity }); + const { result } = renderWithCatalog(catalogApi); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/billing"]); + }); + + expect(resolved).toEqual({}); + }); + + it("returns empty map for entities not in catalog", async () => { + const catalogApi = createMockCatalogApi({}); + const { result } = renderWithCatalog(catalogApi); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/nonexistent"]); + }); + + expect(resolved).toEqual({}); + }); + + it("caches results and does not re-fetch known refs", async () => { + const entity = makeEntity({ [ANNOTATION_KEY]: "." }); + const catalogApi = createMockCatalogApi({ "domain:default/billing": entity }); + const { result } = renderWithCatalog(catalogApi); + + await act(async () => { + await result.current(["domain:default/billing"]); + }); + + catalogApi.getEntitiesByRefs.mockClear(); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/billing"]); + }); + + expect(catalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); + expect(resolved).toEqual({ + "domain:default/billing": "/catalog/default/domain/billing/docs", + }); + }); + + it("does not re-fetch refs that resolved to null (missing)", async () => { + const catalogApi = createMockCatalogApi({}); + const { result } = renderWithCatalog(catalogApi); + + await act(async () => { + await result.current(["domain:default/gone"]); + }); + + catalogApi.getEntitiesByRefs.mockClear(); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/gone"]); + }); + + expect(catalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); + expect(resolved).toEqual({}); + }); + + it("returns empty map for empty input", async () => { + const catalogApi = createMockCatalogApi({}); + const { result } = renderWithCatalog(catalogApi); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current([]); + }); + + expect(catalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); + expect(resolved).toEqual({}); + }); + + it("returns cached results when catalog API fails for new refs", async () => { + const entity = makeEntity({ [ANNOTATION_KEY]: "." }); + const catalogApi = createMockCatalogApi({ "domain:default/billing": entity }); + const { result } = renderWithCatalog(catalogApi); + + await act(async () => { + await result.current(["domain:default/billing"]); + }); + + catalogApi.getEntitiesByRefs.mockRejectedValue(new Error("network error")); + + let resolved: Record = {}; + await act(async () => { + resolved = await result.current(["domain:default/billing", "system:default/pay"]); + }); + + expect(resolved).toEqual({ + "domain:default/billing": "/catalog/default/domain/billing/docs", + }); + }); +}); diff --git a/plugins/rw/src/components/useSectionRefResolver.ts b/plugins/rw/src/components/useSectionRefResolver.ts new file mode 100644 index 0000000..b3ce782 --- /dev/null +++ b/plugins/rw/src/components/useSectionRefResolver.ts @@ -0,0 +1,61 @@ +import { useCallback, useRef } from "react"; +import { useApi, useRouteRef } from "@backstage/core-plugin-api"; +import { catalogApiRef, entityRouteRef } from "@backstage/plugin-catalog-react"; +import { parseEntityRef } from "@backstage/catalog-model"; +import { ANNOTATION_KEY, ROOT_SECTION_REF } from "./constants"; + +const DOCS_PATH_SUFFIX = "/docs"; + +export function useSectionRefResolver( + sourceEntityRef: string, +): (refs: string[]) => Promise> { + const catalogApi = useApi(catalogApiRef); + const entityRoute = useRouteRef(entityRouteRef); + const cache = useRef(new Map()); + + return useCallback( + async (refs: string[]): Promise> => { + const unknown = refs.filter((r) => !cache.current.has(r)); + + const catalogRefs: string[] = []; + for (const ref of unknown) { + if (ref === ROOT_SECTION_REF) { + const { kind, namespace, name } = parseEntityRef(sourceEntityRef); + const routeUrl = entityRoute({ kind, namespace, name }) + DOCS_PATH_SUFFIX; + cache.current.set(ref, routeUrl); + } else { + catalogRefs.push(ref); + } + } + + if (catalogRefs.length > 0) { + try { + const { items } = await catalogApi.getEntitiesByRefs({ entityRefs: catalogRefs }); + for (let i = 0; i < catalogRefs.length; i++) { + const ref = catalogRefs[i]; + const entity = items[i]; + if (entity?.metadata.annotations?.[ANNOTATION_KEY]) { + const { kind, namespace, name } = parseEntityRef(ref); + const routeUrl = entityRoute({ kind, namespace, name }) + DOCS_PATH_SUFFIX; + cache.current.set(ref, routeUrl); + } else { + cache.current.set(ref, null); + } + } + } catch { + // On failure, leave uncached so they can be retried + } + } + + const result: Record = {}; + for (const ref of refs) { + const url = cache.current.get(ref); + if (url !== null && url !== undefined) { + result[ref] = url; + } + } + return result; + }, + [catalogApi, entityRoute, sourceEntityRef], + ); +} diff --git a/plugins/rw/src/plugin.tsx b/plugins/rw/src/plugin.tsx index 844ce4a..e9e0e4b 100644 --- a/plugins/rw/src/plugin.tsx +++ b/plugins/rw/src/plugin.tsx @@ -7,6 +7,7 @@ import { import { createApiFactory, discoveryApiRef, fetchApiRef } from "@backstage/core-plugin-api"; import { EntityContentBlueprint } from "@backstage/plugin-catalog-react/alpha"; import { rwApiRef, RwClient } from "./api/RwClient"; +import { ANNOTATION_KEY } from "./components/constants"; const rootRouteRef = createRouteRef(); @@ -25,7 +26,7 @@ const rwPage = PageBlueprint.make({ params: { path: "/docs", routeRef: rootRouteRef, - loader: () => import("./components/RwDocsViewer").then((m) => ), + loader: () => import("./components/RwStandaloneViewer").then((m) => ), }, }); @@ -34,7 +35,8 @@ const rwEntityContent = EntityContentBlueprint.make({ path: "docs", title: "Documentation", group: "documentation", - loader: () => import("./components/RwDocsViewer").then((m) => ), + filter: (entity) => Boolean(entity.metadata.annotations?.[ANNOTATION_KEY]), + loader: () => import("./components/RwEntityDocsViewer").then((m) => ), }, }); diff --git a/yarn.lock b/yarn.lock index 98edb2b..93e9684 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2446,6 +2446,19 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-catalog-backend-module-scaffolder-entity-model@npm:^0.2.17": + version: 0.2.17 + resolution: "@backstage/plugin-catalog-backend-module-scaffolder-entity-model@npm:0.2.17" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.7.0" + "@backstage/catalog-model": "npm:^1.7.6" + "@backstage/plugin-catalog-common": "npm:^1.1.8" + "@backstage/plugin-catalog-node": "npm:^2.0.0" + "@backstage/plugin-scaffolder-common": "npm:^1.7.6" + checksum: 10c0/6087d57d8a8b0b1fca425636702a5d3256d2f87de25fa0a822226522e5efbc30a1e134609de59299a5bac66ab90c60f9914857915a0ed5cd3bf50cd6e5a772e3 + languageName: node + linkType: hard + "@backstage/plugin-catalog-backend@npm:^3.4.0": version: 3.4.0 resolution: "@backstage/plugin-catalog-backend@npm:3.4.0" @@ -7943,10 +7956,11 @@ __metadata: dependencies: "@backstage/backend-plugin-api": "npm:^1.0.0" "@backstage/backend-test-utils": "npm:^1.11.0" + "@backstage/catalog-model": "npm:^1.7.6" "@backstage/cli": "npm:^0.35.0" "@backstage/errors": "npm:^1.2.7" "@jest/environment-jsdom-abstract": "npm:^30.2.0" - "@rwdocs/core": "npm:^0.1.17" + "@rwdocs/core": "npm:^0.1.18" "@types/express": "npm:^4.17.0" "@types/jest": "npm:^30.0.0" "@types/jsdom": "npm:^28" @@ -7967,13 +7981,15 @@ __metadata: version: 0.0.0-use.local resolution: "@rwdocs/backstage-plugin-rw@workspace:plugins/rw" dependencies: + "@backstage/catalog-model": "npm:^1.7.6" "@backstage/cli": "npm:^0.35.0" + "@backstage/config": "npm:^1.3.6" "@backstage/core-components": "npm:^0.18.0" "@backstage/core-plugin-api": "npm:^1.0.0" "@backstage/frontend-plugin-api": "npm:^0.14.0" "@backstage/plugin-catalog-react": "npm:^2.0.0" "@backstage/test-utils": "npm:^1.7.0" - "@rwdocs/viewer": "npm:^0.1.17" + "@rwdocs/viewer": "npm:^0.1.18" "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" @@ -7990,41 +8006,41 @@ __metadata: "@backstage/core-plugin-api": ^1.0.0 "@backstage/frontend-plugin-api": ^0.14.0 "@backstage/plugin-catalog-react": ^2.0.0 - "@mui/material": ^5.0.0 + "@material-ui/core": ^4.12.2 react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.0.0 languageName: unknown linkType: soft -"@rwdocs/core-darwin-arm64@npm:0.1.17": - version: 0.1.17 - resolution: "@rwdocs/core-darwin-arm64@npm:0.1.17" +"@rwdocs/core-darwin-arm64@npm:0.1.18": + version: 0.1.18 + resolution: "@rwdocs/core-darwin-arm64@npm:0.1.18" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rwdocs/core-linux-x64-gnu@npm:0.1.17": - version: 0.1.17 - resolution: "@rwdocs/core-linux-x64-gnu@npm:0.1.17" +"@rwdocs/core-linux-x64-gnu@npm:0.1.18": + version: 0.1.18 + resolution: "@rwdocs/core-linux-x64-gnu@npm:0.1.18" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rwdocs/core-linux-x64-musl@npm:0.1.17": - version: 0.1.17 - resolution: "@rwdocs/core-linux-x64-musl@npm:0.1.17" +"@rwdocs/core-linux-x64-musl@npm:0.1.18": + version: 0.1.18 + resolution: "@rwdocs/core-linux-x64-musl@npm:0.1.18" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rwdocs/core@npm:^0.1.17": - version: 0.1.17 - resolution: "@rwdocs/core@npm:0.1.17" +"@rwdocs/core@npm:^0.1.18": + version: 0.1.18 + resolution: "@rwdocs/core@npm:0.1.18" dependencies: - "@rwdocs/core-darwin-arm64": "npm:0.1.17" - "@rwdocs/core-linux-x64-gnu": "npm:0.1.17" - "@rwdocs/core-linux-x64-musl": "npm:0.1.17" + "@rwdocs/core-darwin-arm64": "npm:0.1.18" + "@rwdocs/core-linux-x64-gnu": "npm:0.1.18" + "@rwdocs/core-linux-x64-musl": "npm:0.1.18" dependenciesMeta: "@rwdocs/core-darwin-arm64": optional: true @@ -8032,17 +8048,17 @@ __metadata: optional: true "@rwdocs/core-linux-x64-musl": optional: true - checksum: 10c0/67651179a0d29c5d8d33b187b9d466d499fcda9ae5e8343a5b02b1fcd15d92f1e5bd8a02d14db6d6841dc0e0bfd24791e984e6d412cb684d706e795b21cb5eed + checksum: 10c0/cde530d6d0fa05e69f7e5ba3855c5e87519532ba128b8aff03889d186a239ab25af4bb7d2ceec9a6c8d021e780519b641cfb94b6f0068605a52c2adb148f0472 languageName: node linkType: hard -"@rwdocs/viewer@npm:^0.1.17": - version: 0.1.17 - resolution: "@rwdocs/viewer@npm:0.1.17" +"@rwdocs/viewer@npm:^0.1.18": + version: 0.1.18 + resolution: "@rwdocs/viewer@npm:0.1.18" dependencies: "@fontsource/jetbrains-mono": "npm:^5.2.8" "@fontsource/roboto": "npm:^5.2.9" - checksum: 10c0/4861071e82644788661623d4d8cedf7e290bd902b6916ed9aa50d2dc3ca3f734450e0e6e71654f2693397c8f07682090570a88e86a64de36f9cbe0a99d26b15d + checksum: 10c0/5ed199671bae1136f1ab8ce6c4b0ada8676512faf4db7b3e406934933724f9f7ea8fd4b1a366105ca3b9b4cb60f1bc3822786c0ff9cb5c8ef427722f8b5cc4f9 languageName: node linkType: hard @@ -10929,6 +10945,7 @@ __metadata: "@backstage/plugin-auth-backend": "npm:^0.27.0" "@backstage/plugin-auth-backend-module-guest-provider": "npm:^0.2.16" "@backstage/plugin-catalog-backend": "npm:^3.4.0" + "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "npm:^0.2.17" "@backstage/plugin-permission-backend": "npm:^0.7.9" "@backstage/plugin-permission-backend-module-allow-all-policy": "npm:^0.2.16" "@rwdocs/backstage-plugin-rw-backend": "workspace:*"