diff --git a/packages/shared/src/document-references.test.ts b/packages/shared/src/document-references.test.ts new file mode 100644 index 00000000000..53bedab1c2b --- /dev/null +++ b/packages/shared/src/document-references.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentReferenceHref, parseDocumentReferenceHref } from "./document-references.js"; + +describe("parseDocumentReferenceHref", () => { + it("parses a company-prefixed documents path", () => { + expect(parseDocumentReferenceHref("/PAP/documents/abc-123")).toEqual({ + documentId: "abc-123", + fromIssueKey: null, + }); + }); + + it("parses a company-relative documents path", () => { + expect(parseDocumentReferenceHref("/documents/abc-123")).toEqual({ + documentId: "abc-123", + fromIssueKey: null, + }); + }); + + it("extracts the originating issue key from ?from=issue:", () => { + expect(parseDocumentReferenceHref("/documents/abc-123?from=issue:plan")).toEqual({ + documentId: "abc-123", + fromIssueKey: "plan", + }); + }); + + it("parses absolute urls", () => { + expect(parseDocumentReferenceHref("https://app.example.com/ACME/documents/doc_9")).toEqual({ + documentId: "doc_9", + fromIssueKey: null, + }); + }); + + it("returns null for non-document paths", () => { + expect(parseDocumentReferenceHref("/PAP/issues/PAP-1")).toBeNull(); + expect(parseDocumentReferenceHref("/documents")).toBeNull(); + expect(parseDocumentReferenceHref("not a url at all ::::")).toBeNull(); + }); + + it("round-trips with buildDocumentReferenceHref", () => { + expect(buildDocumentReferenceHref("abc-123")).toBe("/documents/abc-123"); + expect(buildDocumentReferenceHref("abc-123", "plan")).toBe("/documents/abc-123?from=issue:plan"); + const built = buildDocumentReferenceHref("abc-123", "spec"); + expect(parseDocumentReferenceHref(built)).toEqual({ documentId: "abc-123", fromIssueKey: "spec" }); + }); +}); diff --git a/packages/shared/src/document-references.ts b/packages/shared/src/document-references.ts new file mode 100644 index 00000000000..f1305a51e43 --- /dev/null +++ b/packages/shared/src/document-references.ts @@ -0,0 +1,60 @@ +/** + * Path-based references to company documents, e.g. `/PAP/documents/` or the + * company-relative `/documents/`. Mirrors `issue-references.ts` so comment + * bodies can render a cross-issue document mention chip. The `` segment is + * the company document id used by the documents library detail route. + */ + +export interface ParsedDocumentReference { + documentId: string; + /** When the href carries `?from=issue:`, the originating issue document key. */ + fromIssueKey: string | null; +} + +/** Document ids are UUIDs in practice; accept any non-empty url-safe slug to stay forgiving. */ +const DOCUMENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function parseFromParam(value: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + const match = /^issue:(.+)$/i.exec(trimmed); + if (!match) return null; + const key = match[1]?.trim(); + return key ? key : null; +} + +export function buildDocumentReferenceHref(documentId: string, fromIssueKey?: string | null): string { + const id = documentId.trim(); + const key = fromIssueKey?.trim(); + return key ? `/documents/${id}?from=issue:${encodeURIComponent(key)}` : `/documents/${id}`; +} + +export function parseDocumentReferenceHref(href: string): ParsedDocumentReference | null { + const raw = href.trim(); + if (!raw) return null; + + let url: URL; + try { + url = raw.startsWith("/") ? new URL(raw, "https://paperclip.invalid") : new URL(raw); + } catch { + return null; + } + + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + for (let index = 0; index < segments.length - 1; index += 1) { + if (segments[index]?.toLowerCase() !== "documents") continue; + const candidate = segments[index + 1] ?? ""; + if (DOCUMENT_ID_RE.test(candidate)) { + return { + documentId: candidate, + fromIssueKey: parseFromParam(url.searchParams.get("from")), + }; + } + } + + return null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5ae9d2a0ce9..d1504a2e9fd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -813,6 +813,12 @@ export { type IssueReferenceMatch, } from "./issue-references.js"; +export { + buildDocumentReferenceHref, + parseDocumentReferenceHref, + type ParsedDocumentReference, +} from "./document-references.js"; + export { anchorSnapshotToSelector, createDocumentAnchorSelector, diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 6278224db81..18c8d0547f4 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -65,6 +65,20 @@ describe("company routes", () => { ); }); + // Regression for PAP-10569: document deep-links (`/documents/?from=issue:`) + // and the library nav must keep the company prefix so they resolve under + // `/:companyPrefix` instead of 404-ing on the unprefixed catch-all. + it("treats /documents as a board route that needs a company prefix", () => { + expect(isBoardPathWithoutPrefix("/documents")).toBe(true); + expect(isBoardPathWithoutPrefix("/documents/doc-123")).toBe(true); + expect(extractCompanyPrefixFromPath("/documents/doc-123")).toBeNull(); + expect(applyCompanyPrefix("/documents", "PAP")).toBe("/PAP/documents"); + expect(applyCompanyPrefix("/documents/doc-123?from=issue:plan", "PAP")).toBe( + "/PAP/documents/doc-123?from=issue:plan", + ); + expect(toCompanyRelativePath("/PAP/documents/doc-123")).toBe("/documents/doc-123"); + }); + it("treats /artifacts as a board route that needs a company prefix", () => { expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true); expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull(); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 27119d6a579..97bbf179de6 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -10,6 +10,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "workspaces", "execution-workspaces", "issues", + "documents", "routines", "goals", "artifacts",