§22 S1: frontend /c/<collection>/ route layer + C3.7 + legacy-URL redirects
- entryPaths builders carry the /c/<collection>/ segment (default collection in S1) - /p/:projectId/* gains c/:collectionId/ corpus routes; serving stays project-scoped - DefaultCollectionRedirect (C3.7: project landing -> default collection) - LegacyCorpusRedirect (v0.35.0 /p/<p>/e/<slug> bookmarks -> /c/default/, query preserved) - entryPaths unit test; build + vitest green (18 tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-6
@@ -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() {
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="e/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
{/* §22 three-tier (C3.7): the project landing redirects into
|
||||
its sole/default collection (S1: the `default` one). */}
|
||||
<Route path="" element={<DefaultCollectionRedirect />} />
|
||||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||||
/c/<collection>/ segment redirect into the default
|
||||
collection, so old bookmarks keep working. */}
|
||||
<Route path="e/:slug" element={<LegacyCorpusRedirect kind="entry" />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<LegacyCorpusRedirect kind="entryPr" />} />
|
||||
<Route path="proposals/:prNumber" element={<LegacyCorpusRedirect kind="proposal" />} />
|
||||
{/* Collection-scoped corpus. Serving stays project-scoped in
|
||||
S1 (collection = default); collection-aware serving is S2. */}
|
||||
<Route path="c/:collectionId" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</ProjectLayout>
|
||||
@@ -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 <Navigate to={`/p/${projectId}/c/${DEFAULT_COLLECTION}/`} replace />
|
||||
}
|
||||
|
||||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||||
// /c/<collection>/ 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 <Navigate to={to + (search || '')} replace />
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
// §22.10 — project-scoped path builders. After M3 every entry/proposal link
|
||||
// lives under `/p/<project>/…`. 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/<project>/c/<collection>/…`.
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// §22 three-tier — the canonical corpus path now carries the /c/<collection>/
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user