diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b9a0aed..e7453cf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react' -import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' import { anonymize, EVENTS, identify, track } from './lib/analytics' import { useLastState } from './lib/useLastState' import { brandTitle } from './lib/brand' -import { entryPath, proposalPath } from './lib/entryPaths' +import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths' import { useDeployment } from './context/DeploymentProvider' import ProjectLayout from './components/ProjectLayout.jsx' import Directory from './components/Directory.jsx' @@ -360,10 +360,21 @@ export default function App() { />
- } /> - } /> - } /> - setCatalogVersion(v => v + 1)} />} /> + {/* §22 three-tier (C3.7): the project landing redirects into + its sole/default collection (S1: the `default` one). */} + } /> + {/* Backcompat: the shipped v0.35.0 corpus URLs without a + /c// segment redirect into the default + collection, so old bookmarks keep working. */} + } /> + } /> + } /> + {/* Collection-scoped corpus. Serving stays project-scoped in + S1 (collection = default); collection-aware serving is S2. */} + } /> + } /> + } /> + setCatalogVersion(v => v + 1)} />} />
@@ -403,6 +414,28 @@ export default function App() { ) } +// §22 three-tier — corpus redirects mounted under /p/:projectId/*. +// C3.7: the project landing skips the (single-collection) directory and lands in +// the project's default collection. +function DefaultCollectionRedirect() { + const { projectId } = useParams() + return +} + +// Backcompat for the shipped v0.35.0 corpus URLs that lacked the +// /c// segment: redirect into the default collection, preserving any +// query string (e.g. ?branch=). +function LegacyCorpusRedirect({ kind }) { + const { projectId, slug, prNumber } = useParams() + const { search } = useLocation() + const base = `/p/${projectId}/c/${DEFAULT_COLLECTION}` + let to = `${base}/` + if (kind === 'entry') to = `${base}/e/${slug}` + else if (kind === 'entryPr') to = `${base}/e/${slug}/pr/${prNumber}` + else if (kind === 'proposal') to = `${base}/proposals/${prNumber}` + return +} + function DeploymentLanding() { // §22.10 + design decision 2 — N=1 lands in the single visible project so // OHM's "land in the corpus" UX is preserved; the directory appears only diff --git a/frontend/src/lib/entryPaths.js b/frontend/src/lib/entryPaths.js index 840bf59..15c5f53 100644 --- a/frontend/src/lib/entryPaths.js +++ b/frontend/src/lib/entryPaths.js @@ -1,22 +1,29 @@ -// §22.10 — project-scoped path builders. After M3 every entry/proposal link -// lives under `/p//…`. Until Plan B serves multiple corpora, that -// project id is the deployment's corpus-served default for chrome surfaces, or -// the contextual project when a component renders inside a project subtree -// (ProjectContext). Components build links via these helpers so the later -// per-project-serving slice flips them in one place. +// §22 three-tier — project + collection-scoped path builders. The canonical +// entry route now carries the collection segment: `/p//c//…`. +// In S1 each project has a single (default) collection and serving stays +// project-scoped, so the builders emit the default collection segment; the +// collection-aware link layer (named collections) lands in S2. Components build +// links via these helpers so that flip happens in one place. import { useProject } from '../components/ProjectLayout.jsx' import { useDeployment } from '../context/DeploymentProvider' -export function entryPath(pid, slug) { - return `/p/${pid}/e/${slug}` +// The default collection id (migration 029 seeds one per project at this id). +export const DEFAULT_COLLECTION = 'default' + +export function entryPath(pid, slug, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/e/${slug}` } -export function entryPrPath(pid, slug, prNumber) { - return `/p/${pid}/e/${slug}/pr/${prNumber}` +export function entryPrPath(pid, slug, prNumber, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/e/${slug}/pr/${prNumber}` } -export function proposalPath(pid, prNumber) { - return `/p/${pid}/proposals/${prNumber}` +export function proposalPath(pid, prNumber, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/proposals/${prNumber}` +} + +export function collectionHome(pid, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/` } export function projectHome(pid) { diff --git a/frontend/src/lib/entryPaths.test.js b/frontend/src/lib/entryPaths.test.js new file mode 100644 index 0000000..5cbd7e9 --- /dev/null +++ b/frontend/src/lib/entryPaths.test.js @@ -0,0 +1,36 @@ +// §22 three-tier — the canonical corpus path now carries the /c// +// segment. These builders default to the project's `default` collection (S1). +import { describe, it, expect } from 'vitest' +import { + entryPath, entryPrPath, proposalPath, collectionHome, projectHome, DEFAULT_COLLECTION, +} from './entryPaths.js' + +describe('entryPaths — collection-scoped corpus URLs', () => { + it('entryPath defaults to the default collection segment', () => { + expect(entryPath('ohm', 'human')).toBe('/p/ohm/c/default/e/human') + }) + + it('entryPath honours an explicit collection id', () => { + expect(entryPath('ohm', 'login', 'features')).toBe('/p/ohm/c/features/e/login') + }) + + it('entryPrPath carries the collection segment', () => { + expect(entryPrPath('ohm', 'human', 7)).toBe('/p/ohm/c/default/e/human/pr/7') + }) + + it('proposalPath carries the collection segment', () => { + expect(proposalPath('ohm', 42)).toBe('/p/ohm/c/default/proposals/42') + }) + + it('collectionHome targets the collection root', () => { + expect(collectionHome('ohm')).toBe('/p/ohm/c/default/') + }) + + it('projectHome stays at the project root (redirects into the collection)', () => { + expect(projectHome('ohm')).toBe('/p/ohm/') + }) + + it('exposes the default collection id constant', () => { + expect(DEFAULT_COLLECTION).toBe('default') + }) +})