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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ yarn --cwd packages/backend add @rwdocs/backstage-plugin-rw-backend
yarn --cwd packages/backend add @rwdocs/backstage-plugin-search-backend-module-rw
```

## RW 0.1.36 compatibility

The viewer requires Node `^22.22.2 || >=24.15.0`. Update the RW backend,
search collator, and frontend dependencies together to pick up the core and
viewer fixes in [RW 0.1.36](https://github.com/rwdocs/rw/releases/tag/v0.1.36).

For S3, deploy upgraded **backend and search collator readers before publishing
bundles that declare `name`**. Older readers accept these bundles but ignore the
explicit name; upgraded readers can still read existing bundles.

RW metadata can now declare `name` alongside `kind` to choose a section identity
independently of its URL and title. This does not create catalog entities: align
catalog annotations and references with the intended section identity. Changing
an existing identity creates no aliases and does not migrate comments.

Homepage names and kinds are resolved from the site's navigation; they do not
need to use the implicit `section:<namespace>/root` spelling. Root links prefer a
matching catalog entity exposing the same site's root. Otherwise they fall back
to the source entity's Docs tab, which must expose the whole site, either unscoped
or scoped to the actual homepage root.

Rename legacy RW frontmatter and sidecar `type` fields to `kind`; `type` is now
ignored there. Unrelated Backstage configuration fields named `type` are unchanged.
See [RW page metadata](https://github.com/rwdocs/rw/blob/v0.1.36/docs/metadata.md)
for naming rules and migration details.

## Backend Setup

Add the plugin to your backend in `packages/backend/src/index.ts`:
Expand Down Expand Up @@ -102,3 +128,11 @@ The backend persists its SQLite database (catalog, search index, …) under a
`.data/` directory at the repo root, so state survives restarts instead of being
rebuilt from scratch each time. The directory is git-ignored. Delete it
(`rm -rf .data`) for a clean database.

## Duplicate section identities

Full section refs should be unique. For collisions, registry and search follow
RW's canonical section-root lookup and log a warning. Pages whose identity
resolves to another path are omitted from these indexes; independently named
child sections can remain indexed. Direct site-path reads are unchanged. Fix the
conflicting metadata to restore omitted pages to identity-based surfaces.
2 changes: 1 addition & 1 deletion plugins/rw-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@backstage/types": "^1.2.2",
"@rwdocs/backstage-plugin-rw-common": "workspace:^",
"@rwdocs/backstage-plugin-rw-node": "workspace:^",
"@rwdocs/core": "^0.1.35",
"@rwdocs/core": "^0.1.36",
"express": "^4.21.0",
"express-promise-router": "^4.1.0",
"luxon": "^3.7.2",
Expand Down
144 changes: 144 additions & 0 deletions plugins/rw-backend/src/router.core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as http from "http";
import { mkdtemp, mkdir, writeFile, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { mockServices } from "@backstage/backend-test-utils";
import type { RwSite } from "@rwdocs/core";
import express from "express";
import request from "supertest";
import { Hub } from "./hub";
import { createRouter } from "./router";
import { SiteAuthorizer } from "./authorizeSite";

const sectionRef = "system:commerce/payments-api";
const sectionMarkdown = [
"---",
"kind: system",
"namespace: commerce",
"name: payments-api",
"title: Payments documentation",
"---",
"# Payments",
"",
].join("\n");
const runbookMarkdown = "# Operations\n\nRestart the payment worker.\n";

describe("createRouter with real core", () => {
let projectDir: string | undefined;
let site: RwSite;
let server: http.Server;

beforeAll(async () => {
projectDir = await mkdtemp(join(tmpdir(), "rw-router-core-"));
await mkdir(join(projectDir, "docs/payments-guide"), { recursive: true });
await writeFile(join(projectDir, "rw.toml"), "");
await writeFile(
join(projectDir, "docs/index.md"),
"---\nkind: section\nnamespace: commerce\nname: handbook\n---\n# Home\n",
);
await writeFile(join(projectDir, "docs/payments-guide/index.md"), sectionMarkdown);
await writeFile(join(projectDir, "docs/payments-guide/runbook.md"), runbookMarkdown);

const hub = new Hub({ projectDir, entity: "component:default/test" });
site = hub.getSite("default/component/test")!;
const httpAuth = mockServices.httpAuth.mock();
const authorizer = new SiteAuthorizer({
permissions: mockServices.permissions.mock(),
httpAuth,
auditor: mockServices.auditor.mock(),
});
jest.spyOn(authorizer, "assertReadable").mockResolvedValue(undefined);
const router = await createRouter({
logger: mockServices.logger.mock(),
httpAuth,
hub,
authorizer,
});
const app = express().use(router);
server = http.createServer(app);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
});

afterAll(async () => {
try {
if (server?.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
} finally {
try {
if (projectDir) await rm(projectDir, { recursive: true, force: true });
} finally {
jest.restoreAllMocks();
}
}
});

it("serves the named homepage identity in unscoped navigation", async () => {
const response = await request(server).get("/site/default/component/test/navigation");
expect(response.status).toBe(200);
expect(response.body.scope).toMatchObject({
path: "/",
section: { kind: "section", namespace: "commerce", name: "handbook" },
});
});

it("lists and resolves explicit identities independently of page paths", async () => {
expect(await site.listSections()).toEqual(
expect.arrayContaining([expect.objectContaining({ sectionRef, path: "payments-guide" })]),
);
const pages = await site.listPages();
expect(pages).toEqual(
expect.arrayContaining([
expect.objectContaining({
sectionRef,
subpath: "",
path: "payments-guide",
title: "Payments documentation",
}),
expect.objectContaining({
sectionRef,
subpath: "runbook",
path: "payments-guide/runbook",
anchors: expect.arrayContaining([{ sectionRef, subpath: "runbook" }]),
}),
]),
);
await expect(site.pagePathFor(sectionRef, "")).resolves.toBe("payments-guide");
await expect(site.pagePathFor(sectionRef, "runbook")).resolves.toBe("payments-guide/runbook");
});

it("serves the scoped section root at its unchanged page path", async () => {
const response = await request(server)
.get("/site/default/component/test/pages/")
.query({ sectionRef });
expect(response.status).toBe(200);
expect(response.body.meta).toMatchObject({
sectionRef,
subpath: "",
path: "/payments-guide",
title: "Payments documentation",
});
});

it.each([
["section root", undefined, sectionMarkdown],
["nested page", "runbook", runbookMarkdown],
] as const)(
"reads %s Markdown using the explicit identity",
async (_label, subpath, markdown) => {
const response = await request(server)
.get("/site/default/component/test/markdown")
.query(subpath === undefined ? { sectionRef } : { sectionRef, subpath });
expect(response.status).toBe(200);
expect(response.body).toEqual({ markdown });
},
);
});
Loading