diff --git a/CHANGELOG.md b/CHANGELOG.md index 67144ef..e2ae0e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.20.0 — 2026-05-28 + +Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor: + +1. **Specs on `/docs/specs/`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list). + +2. **Nested flyout nav hierarchy** — `/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint. + +3. **`/docs/sessions/` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone. + +Upgrade steps: + +MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS=''` to override the configured spec set. Each entry is `{"name": "", "title": "", "url": ""}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value. + +MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL. + +Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move. + ## 0.19.0 — 2026-05-28 Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/` (per-session index), `/docs/sessions//` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects. diff --git a/VERSION b/VERSION index 1cf0537..5a03fb7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.20.0 diff --git a/backend/app/api.py b/backend/app/api.py index 5ee9d57..4e36942 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -31,6 +31,7 @@ from . import ( device_trust as device_trust_mod, docs as docs_mod, docs_sessions, + docs_specs, entry as entry_mod, cache, funder, @@ -261,6 +262,61 @@ def make_router( }, ) + # --------------------------------------------------------------- + # v0.20.0 — /api/docs/specs/* + # + # Sibling of the v0.19.0 docs-sessions surface: the framework + # mediates a fetch against the public gitea raw URL for each + # configured spec so the rendered `/docs/specs/*` route inherits + # the same chrome (and the same auth-less reach) as the user + # guide and the session-history browser. See + # backend/app/docs_specs.py for the manifest shape, the env + # knobs, and the cache. + # + # Status-to-HTTP mapping mirrors docs_sessions: + # "ok" → HTTP 200, payload as documented per endpoint + # "404" → HTTP 200 / 404 (manifest 404 doesn't apply here — + # the manifest is derived from env, never 404s; spec + # 404 returns HTTP 404 so the frontend can render + # "spec not yet published / unknown name") + # "error" → HTTP 502 + # --------------------------------------------------------------- + + @router.get("/api/docs/specs/manifest") + async def get_specs_manifest() -> dict[str, Any]: + # The manifest is derived from env (`OHM_DOCS_SPECS`) and + # never fails — a malformed value falls back to the framework + # default at parse time. So this endpoint always returns 200 + # + a list (the framework default is non-empty). + result = docs_specs.fetch_specs_manifest() + return {"specs": result["specs"]} + + @router.get("/api/docs/specs/{name}") + async def get_spec(name: str) -> Response: + # Slug validation before any network — refuses `..`, `/`, + # uppercase, whitespace, etc. Same defense-in-depth posture + # as the docs-sessions transcript endpoint. + if not docs_specs._is_valid_name(name): + raise HTTPException(status_code=400, detail="invalid spec name") + result = await docs_specs.fetch_spec(name) + if result["status"] == "ok": + return PlainTextResponse( + content=result["body"], + media_type="text/markdown; charset=utf-8", + ) + if result["status"] == "404": + raise HTTPException( + status_code=404, + detail="spec not found", + ) + raise HTTPException( + status_code=502, + detail={ + "error": "specs fetch failed", + "detail": result.get("detail", "unknown"), + }, + ) + # --------------------------------------------------------------- # Auth surface — reads role from our users table per §6. # --------------------------------------------------------------- diff --git a/backend/app/docs_specs.py b/backend/app/docs_specs.py new file mode 100644 index 0000000..08bb6a9 --- /dev/null +++ b/backend/app/docs_specs.py @@ -0,0 +1,326 @@ +"""v0.20.0 — on-site framework-specs surface source. + +Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the +framework mediates a gitea fetch on behalf of the browser so the +rendered `/docs/specs/*` surface inherits the same chrome as +`/docs/user-guide` and `/docs/sessions/*` and stays free of any +cross-origin gestures from the frontend. + +Two read endpoints, both anonymous-reachable: + + GET /api/docs/specs/manifest — the configured spec list + GET /api/docs/specs/ — a single spec body (markdown) + +The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md ++ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason +`docs_sessions.py`'s defaults are: OHM is the only deployment to +date. A deployment running its own spec set overrides the manifest +via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay). +History is intentionally not surfaced here — the operator-stated +intent is "current version only; git is the history surface". + +Per-spec entries carry three fields: + + name — URL-safe slug (`[a-z0-9-]+`) — the path segment + title — human-readable label shown in the nav and the page header + url — the upstream raw URL the framework fetches + +Validation: + + - The configured list must be a JSON array of `{name, title, url}` + objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong + shape, invalid slug) logs a warning and falls back to the default + so a typo in the overlay doesn't crash startup. + - Each `name` is checked against `^[a-z0-9-]+$` before the manifest + is accepted. The route layer also validates the path-bound `name` + parameter before any network call, so a malformed URL never + reaches the cache or the upstream. + +Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic +TTL check, negative results (404) cached, no external dep. The +manifest is cheap (parsed from an env var, no network), so it has no +TTL — every request re-derives it. Per-spec content has a 5-minute +default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`). + +§3 invariant 1 is preserved: the framework holds no secret bytes; +the upstream specs are public-repo raw URLs, the fetch carries no +auth header. +""" +from __future__ import annotations + +import json +import logging +import os +import re +import threading +import time +from typing import Any + +import httpx + +log = logging.getLogger(__name__) + + +# The framework-default spec set. OHM-flavored per the same precedent +# `docs_sessions.py` set: the only live deployment is OHM, so the +# default points there. A deployment running its own specs overrides +# `OHM_DOCS_SPECS` via the overlay. +_DEFAULT_SPECS: list[dict[str, str]] = [ + { + "name": "rfc-app", + "title": "rfc-app SPEC", + "url": ( + "https://git.wiggleverse.org/ben.stull/rfc-app/" + "raw/branch/main/SPEC.md" + ), + }, + { + "name": "flotilla", + "title": "flotilla SPEC", + "url": ( + "https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/" + "raw/branch/main/SPEC.md" + ), + }, +] + +_DEFAULT_CONTENT_TTL_SEC = 300.0 + +# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit +# (rejecting anything that could resolve outside the intended layout) +# but with the lowercase-alphanumeric-plus-dash shape the manifest +# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase, +# and whitespace all fail this regex; the route layer rejects 400 +# before any cache or network call. +_NAME_RE = re.compile(r"^[a-z0-9-]+$") + +_HTTP_TIMEOUT_SEC = 5.0 + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + log.warning("invalid %s=%r — falling back to %s", name, raw, default) + return default + + +def _content_ttl() -> float: + return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC) + + +def _is_valid_name(name: str) -> bool: + """Slug guard for path-bound `name` parameters. + + Mirrors `docs_sessions._is_valid_session_dir`'s contract: the + route layer calls this before any network or cache work, so a + malformed name never escapes the FastAPI surface. + """ + return bool(isinstance(name, str) and _NAME_RE.match(name)) + + +def _parse_spec_entry(entry: Any) -> dict[str, str] | None: + """Validate a single manifest entry; return None if invalid. + + Required fields: `name`, `title`, `url`. All three must be + non-empty strings; `name` must match `_NAME_RE`. The validator is + strict: an entry that fails any check is dropped from the manifest + (and the caller logs at warning level). + """ + if not isinstance(entry, dict): + return None + name = entry.get("name") + title = entry.get("title") + url = entry.get("url") + if not isinstance(name, str) or not _is_valid_name(name): + return None + if not isinstance(title, str) or not title.strip(): + return None + if not isinstance(url, str) or not url.strip(): + return None + return {"name": name, "title": title.strip(), "url": url.strip()} + + +def _load_configured_specs() -> list[dict[str, str]]: + """Parse `OHM_DOCS_SPECS` (if set) or return the default list. + + Malformed JSON or wrong-shape values log a warning and fall back + to the default — the deployment continues to render the spec + surface rather than crashing startup. The strict validation (each + entry's name slug, presence of all three fields) drops bad entries + one-by-one; if every entry is dropped, the default applies. + """ + raw = os.environ.get("OHM_DOCS_SPECS", "").strip() + if not raw: + return list(_DEFAULT_SPECS) + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + log.warning( + "OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e + ) + return list(_DEFAULT_SPECS) + if not isinstance(parsed, list): + log.warning( + "OHM_DOCS_SPECS must be a JSON array — falling back to default" + ) + return list(_DEFAULT_SPECS) + out: list[dict[str, str]] = [] + seen: set[str] = set() + for entry in parsed: + validated = _parse_spec_entry(entry) + if validated is None: + log.warning( + "OHM_DOCS_SPECS entry %r failed validation — dropped", entry + ) + continue + if validated["name"] in seen: + log.warning( + "OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"] + ) + continue + seen.add(validated["name"]) + out.append(validated) + if not out: + log.warning( + "OHM_DOCS_SPECS yielded no valid entries — falling back to default" + ) + return list(_DEFAULT_SPECS) + return out + + +# --------------------------------------------------------------------------- +# In-process TTL cache +# --------------------------------------------------------------------------- +# +# Same shape as `docs_sessions.py`: plain dict + `time.monotonic()` check, +# no external dep. The cache value is a `(stored_at, payload)` tuple; +# `payload` may carry an error-shape sentinel for negative caching (404s). +# Lock guards read-modify-write across worker tasks; entries are immutable +# once stored so reads under the lock are fast. + +_lock = threading.Lock() +_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + +def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None: + with _lock: + entry = _cache.get(key) + if entry is None: + return None + stored_at, payload = entry + if time.monotonic() - stored_at > ttl_sec: + return None + return payload + + +def _cache_put(key: str, payload: dict[str, Any]) -> None: + with _lock: + _cache[key] = (time.monotonic(), payload) + + +def reset_cache() -> None: + """Drop every cached entry. Test seam — not called in production.""" + with _lock: + _cache.clear() + + +# --------------------------------------------------------------------------- +# Public fetch surface +# --------------------------------------------------------------------------- +# +# Each fetcher returns a `{status, ...}` dict, same convention as +# `docs_sessions.py`: +# "ok" — payload field carries the body / manifest +# "404" — gitea returned 404 (or the configured name doesn't exist) +# "error" — gitea returned 5xx, timed out, or returned malformed data +# +# The route layer maps these onto HTTP responses; keeping the mapping +# out of this module makes the cache transparent to the test harness. + + +async def _http_get(url: str) -> tuple[int, str]: + """Perform a single GET against `url`; return (status_code, body). + + On timeout or network error, returns (599, error_message). The 599 + pseudo-status maps to a 502 at the route layer the same way an + upstream 5xx does — the caller doesn't care which leg of the + network broke. + """ + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: + r = await client.get(url) + return r.status_code, r.text + except httpx.HTTPError as e: + log.warning("specs fetch failed for %s: %s", url, e) + return 599, f"fetch error: {e}" + + +def fetch_specs_manifest() -> dict[str, Any]: + """Return the configured spec manifest. + + The manifest is derived from the `OHM_DOCS_SPECS` env var (or the + framework default if unset / malformed) and carries no network + work — it's safe to call on every request. The return shape mirrors + the docs_sessions manifest endpoint for frontend consistency: + + {"status": "ok", "specs": [{"name", "title", "url"}, ...]} + + The "url" field is exposed in the manifest so the frontend can + offer a "view source on gitea" affordance alongside each rendered + spec (operator-stated intent: "include the history so you can see + it in git" — that gesture lives in the source link, not on the + rendered page). + """ + specs = _load_configured_specs() + return {"status": "ok", "specs": specs} + + +async def fetch_spec(name: str) -> dict[str, Any]: + """Fetch a single spec body by its manifest `name`. + + The caller is expected to have validated `name` against + `_is_valid_name` before calling this — an invalid name shouldn't + reach the network. We re-check inside as defense-in-depth: a + bogus name here returns the same `{status: "404"}` shape so the + route layer's `404 → HTTP 404` mapping handles it uniformly. + + Returns one of: + {"status": "ok", "body": "..."} + {"status": "404"} — no such spec OR upstream 404 + {"status": "error", "detail": "..."} — upstream 5xx / timeout + """ + if not _is_valid_name(name): + return {"status": "404"} + + cache_key = f"spec:{name}" + cached = _cache_get(cache_key, _content_ttl()) + if cached is not None: + return cached + + specs = _load_configured_specs() + match = next((s for s in specs if s["name"] == name), None) + if match is None: + # Cache the negative — a deployment with an unstable manifest + # would still benefit from the TTL window, and the cached 404 + # is automatically displaced when the next request happens + # after TTL expiry. + payload: dict[str, Any] = {"status": "404"} + _cache_put(cache_key, payload) + return payload + + url = match["url"] + status, body = await _http_get(url) + + if status == 200: + payload = {"status": "ok", "body": body} + _cache_put(cache_key, payload) + return payload + if status == 404: + payload = {"status": "404"} + _cache_put(cache_key, payload) + return payload + return {"status": "error", "detail": f"upstream returned {status}"} diff --git a/backend/tests/test_docs_specs_vertical.py b/backend/tests/test_docs_specs_vertical.py new file mode 100644 index 0000000..f581c62 --- /dev/null +++ b/backend/tests/test_docs_specs_vertical.py @@ -0,0 +1,469 @@ +"""v0.20.0 — `/api/docs/specs/*` endpoints. + +Sibling of `test_docs_sessions_vertical.py`. The framework mediates +reads of the configured framework-spec URLs (default: rfc-app's own +SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the +`/docs/specs/*` surface inherits the same chrome as +`/docs/user-guide` and `/docs/sessions/*`. + +This file covers: + + - The manifest endpoint with the framework default + - The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value + - Slug validation at the route layer (rejects `..`, `/`, `~`, + uppercase, whitespace, path traversal attempts) + - Gitea 200 / 404 / 5xx response mapping + - Negative caching (404 is cached, not re-fetched within TTL) + - Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged + warning (asserted by caplog) + - A manifest entry that fails per-entry validation (bad slug, + missing field) is dropped, with the rest of the list retained + +Mocking approach: same as docs_sessions — `httpx.MockTransport` +substituted into `app.docs_specs.httpx.AsyncClient` via a fixture. +""" +from __future__ import annotations + +import json +import logging + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app import docs_specs + +# Reuse the proven app-construction fixtures from the proposal vertical +# (same shape every test file in this repo uses). +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Test scaffolding +# --------------------------------------------------------------------------- + + +class _UpstreamHandler: + """Records every URL the docs_specs module fetched and returns + canned responses keyed by URL substring. Lets the test assert on + call count (for cache verification) without booting a full upstream + simulator. + + `calls` tracks only URLs that hit a host configured in the spec + manifest under test — so unrelated httpx clients (gitea-side + fixtures, etc.) don't inflate the count we use for cache-hit + assertions. We marker-match on substrings the manifest carries. + """ + + def __init__( + self, + responses: dict[str, tuple[int, str]], + host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"), + ): + self.responses = responses + self.host_markers = host_markers + self.calls: list[str] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + url = str(request.url) + if any(m in url for m in self.host_markers): + self.calls.append(url) + for key, (status, body) in self.responses.items(): + if key in url: + return httpx.Response(status, text=body) + # Default: 404. Lets tests skip declaring "the rest is 404". + return httpx.Response(404, text="not found") + + +@pytest.fixture +def patched_httpx(monkeypatch): + """Provide a hook the test can call to install a MockTransport. + + Same shape as the docs_sessions fixture — `app_with_fake_gitea` + monkeypatches `httpx.AsyncClient` for the gitea side, so we + construct from the unpatched class directly to avoid the + FakeGitea wrapper. + """ + from httpx._client import AsyncClient as RealAsyncClient + + def install(handler): + def patched(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return RealAsyncClient(*args, **kwargs) + + monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched) + return handler + + yield install + + +@pytest.fixture +def app(app_with_fake_gitea): + """Reset the docs-specs cache so cross-test state can't leak.""" + docs_specs.reset_cache() + fastapi_app, _fake = app_with_fake_gitea + return fastapi_app + + +# --------------------------------------------------------------------------- +# Manifest endpoint +# --------------------------------------------------------------------------- + + +def test_manifest_default(app, monkeypatch): + """With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the + framework default (rfc-app + flotilla). + """ + monkeypatch.delenv("OHM_DOCS_SPECS", raising=False) + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + payload = r.json() + assert "specs" in payload + names = [s["name"] for s in payload["specs"]] + assert names == ["rfc-app", "flotilla"] + # The default URLs point at the OHM-canonical gitea raw paths. + assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"]) + + +def test_manifest_overridden(app, monkeypatch): + """A deployment overriding `OHM_DOCS_SPECS` gets its custom list. + + The manifest is parsed per-request from the env var (no startup + binding) so a runtime overlay change is visible without a + restart — same shape as the docs_sessions env knobs. + """ + custom = json.dumps( + [ + { + "name": "custom-spec", + "title": "Custom Spec", + "url": "https://specs.example.org/CUSTOM.md", + } + ] + ) + monkeypatch.setenv("OHM_DOCS_SPECS", custom) + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + payload = r.json() + assert payload == { + "specs": [ + { + "name": "custom-spec", + "title": "Custom Spec", + "url": "https://specs.example.org/CUSTOM.md", + } + ] + } + + +def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog): + """A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the + endpoint falls back to the framework default. Startup is + unaffected — the deployment continues to render the spec surface + rather than crashing on the typo. + """ + monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json") + with caplog.at_level(logging.WARNING, logger="app.docs_specs"): + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + payload = r.json() + names = [s["name"] for s in payload["specs"]] + assert names == ["rfc-app", "flotilla"] + assert any( + "OHM_DOCS_SPECS is not valid JSON" in rec.message + for rec in caplog.records + ), f"expected a logged warning; got {[r.message for r in caplog.records]}" + + +def test_manifest_non_list_falls_back(app, monkeypatch, caplog): + """`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any + non-list value) falls back to the default + logs a warning. + """ + monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"})) + with caplog.at_level(logging.WARNING, logger="app.docs_specs"): + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + names = [s["name"] for s in r.json()["specs"]] + assert names == ["rfc-app", "flotilla"] + assert any( + "must be a JSON array" in rec.message for rec in caplog.records + ) + + +def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog): + """Per-entry validation: an entry with a bad slug or missing field + is dropped; valid entries in the same list are retained. + """ + custom = json.dumps( + [ + {"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space + {"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal + {"name": "missing-url", "title": "Missing URL"}, # no url + { + "name": "good-spec", + "title": "Good", + "url": "https://specs.example.org/GOOD.md", + }, + ] + ) + monkeypatch.setenv("OHM_DOCS_SPECS", custom) + with caplog.at_level(logging.WARNING, logger="app.docs_specs"): + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + names = [s["name"] for s in r.json()["specs"]] + assert names == ["good-spec"] + # Three drop warnings (one per bad entry). + drops = [r for r in caplog.records if "failed validation" in r.message] + assert len(drops) == 3 + + +def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog): + """If every entry is dropped, the framework default applies (the + surface never goes empty due to a bad overlay). + """ + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]), + ) + with caplog.at_level(logging.WARNING, logger="app.docs_specs"): + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + assert r.status_code == 200, r.text + names = [s["name"] for s in r.json()["specs"]] + assert names == ["rfc-app", "flotilla"] + assert any( + "yielded no valid entries" in rec.message for rec in caplog.records + ) + + +def test_manifest_drops_duplicate_names(app, monkeypatch, caplog): + """A duplicate `name` is dropped (the first occurrence wins). The + route layer's `/api/docs/specs/{name}` path lookup is by name, so + duplicates would otherwise be ambiguous. + """ + custom = json.dumps( + [ + {"name": "x", "title": "First", "url": "https://specs.example/1"}, + {"name": "x", "title": "Second", "url": "https://specs.example/2"}, + ] + ) + monkeypatch.setenv("OHM_DOCS_SPECS", custom) + with caplog.at_level(logging.WARNING, logger="app.docs_specs"): + with TestClient(app) as client: + r = client.get("/api/docs/specs/manifest") + payload = r.json() + assert [s["title"] for s in payload["specs"]] == ["First"] + assert any("duplicate name" in rec.message for rec in caplog.records) + + +# --------------------------------------------------------------------------- +# Spec endpoint — happy + error paths +# --------------------------------------------------------------------------- + + +def test_spec_happy_path(app, patched_httpx, monkeypatch): + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "rfc-app", + "title": "rfc-app SPEC", + "url": "https://specs.example.org/rfc-app/SPEC.md", + } + ] + ), + ) + body = "# rfc-app SPEC\n\nSection 1...\n" + patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)})) + with TestClient(app) as client: + r = client.get("/api/docs/specs/rfc-app") + assert r.status_code == 200, r.text + assert "text/markdown" in r.headers["content-type"] + assert r.text == body + + +def test_spec_upstream_404(app, patched_httpx, monkeypatch): + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "missing-spec", + "title": "Missing", + "url": "https://specs.example.org/missing.md", + } + ] + ), + ) + patched_httpx(_UpstreamHandler({})) # everything 404s + with TestClient(app) as client: + r = client.get("/api/docs/specs/missing-spec") + assert r.status_code == 404, r.text + + +def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch): + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "broken-spec", + "title": "Broken", + "url": "https://specs.example.org/broken.md", + } + ] + ), + ) + patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")})) + with TestClient(app) as client: + r = client.get("/api/docs/specs/broken-spec") + assert r.status_code == 502, r.text + body = r.json() + assert body["detail"]["error"] == "specs fetch failed" + + +def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch): + """A name that doesn't appear in the manifest returns 404 without + touching the network. The handler treats "no such configured spec" + and "upstream 404" as the same outcome — both render the same + "spec not found" empty state on the frontend. + """ + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "rfc-app", + "title": "rfc-app", + "url": "https://specs.example.org/x.md", + } + ] + ), + ) + handler = _UpstreamHandler({}) + patched_httpx(handler) + with TestClient(app) as client: + r = client.get("/api/docs/specs/does-not-exist") + assert r.status_code == 404, r.text + assert handler.calls == [], "unknown-name lookup must not hit the network" + + +# --------------------------------------------------------------------------- +# Spec endpoint — slug validation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw_name", + [ + "UPPER", # uppercase + "spaces here", # whitespace (post-decoding) + "with~tilde", # tilde + "with.dot", # dot + "with_under", # underscore (not allowed by [a-z0-9-]+) + ], +) +def test_spec_rejects_invalid_name(app, patched_httpx, raw_name): + """Names that don't match `^[a-z0-9-]+$` are rejected with 400 at + the route layer before any network or cache work. + + Note: `..` is intentionally not in this list because the URL- + parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs` + before the handler is reached — the path-traversal protection is + therefore framework-level (httpx/urllib's path normalizer) rather + than route-layer. The slug-validation guard still rejects any + `..` that *would* reach the handler (e.g. via an env-configured + manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid` + for that path. + """ + handler = _UpstreamHandler({}) + patched_httpx(handler) + with TestClient(app) as client: + from urllib.parse import quote + + r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}") + assert r.status_code == 400, r.text + assert handler.calls == [], "rejected name must not hit the network" + + +def test_spec_rejects_slash_in_name(app, patched_httpx): + """A literal `/` in the path can't make it through the path + parameter — FastAPI routes it as a separate segment. The check + here is that the request never reaches an upstream fetch. + """ + handler = _UpstreamHandler({}) + patched_httpx(handler) + with TestClient(app) as client: + # `/api/docs/specs/sub/path` — the second segment makes this + # not match the `/{name}` route at all; FastAPI returns 404. + r = client.get("/api/docs/specs/sub/path") + assert r.status_code == 404, r.text + assert handler.calls == [], "non-matching path must not hit the network" + + +# --------------------------------------------------------------------------- +# Cache behavior +# --------------------------------------------------------------------------- + + +def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch): + monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300") + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "rfc-app", + "title": "rfc-app", + "url": "https://specs.example.org/rfc-app/SPEC.md", + } + ] + ), + ) + handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")}) + patched_httpx(handler) + with TestClient(app) as client: + r1 = client.get("/api/docs/specs/rfc-app") + r2 = client.get("/api/docs/specs/rfc-app") + assert r1.status_code == 200 + assert r2.status_code == 200 + assert len(handler.calls) == 1, ( + f"expected one upstream call, got {handler.calls}" + ) + + +def test_spec_404_is_cached(app, patched_httpx, monkeypatch): + """Negative caching: a 404 result is cached at the content TTL so + a deployment with a misconfigured spec URL doesn't hammer the + upstream on every navigation. + """ + monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300") + monkeypatch.setenv( + "OHM_DOCS_SPECS", + json.dumps( + [ + { + "name": "missing-spec", + "title": "Missing", + "url": "https://specs.example.org/missing.md", + } + ] + ), + ) + handler = _UpstreamHandler({}) # everything 404s + patched_httpx(handler) + with TestClient(app) as client: + r1 = client.get("/api/docs/specs/missing-spec") + r2 = client.get("/api/docs/specs/missing-spec") + assert r1.status_code == 404 + assert r2.status_code == 404 + assert len(handler.calls) == 1, "negative caching should suppress the 2nd call" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ad2b45f..5694736 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.15.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.15.0", + "version": "0.20.0", "dependencies": { "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", diff --git a/frontend/package.json b/frontend/package.json index 6a4eb16..0464168 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.19.0", + "version": "0.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 0ddc039..b7e647c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2281,6 +2281,24 @@ background: #e7e5e4; color: #111; font-weight: 600; } +/* v0.20.0 — nested transcript rows under each session row. The parent +
  • holds the session link; the nested
      holds the transcript + children. Indent + a softer font color so the hierarchy reads at a + glance. */ +.docs-nav-list--children { + list-style: none; padding: 0; margin: 2px 0 8px 14px; + display: flex; flex-direction: column; gap: 2px; + border-left: 1px solid #e5e7eb; +} +.docs-nav-list--children a { + padding: 4px 10px; + font-size: 13px; color: #4b5563; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.docs-nav-list--children a.active { + background: #e7e5e4; color: #111; font-weight: 600; +} + .docs-nav-skeleton .skeleton-row { display: block; height: 14px; margin: 8px 10px; @@ -2345,6 +2363,31 @@ background: #f3f4f6; border-color: #e5e7eb; } +/* v0.20.0 — session-overview body. Replaces the per-session body + transcript-link list; transcripts are now picked from the left + nav. The body just states the count + the nav hint. */ +.docs-session-overview { + padding: 16px; border-radius: 6px; + background: #fafaf9; border: 1px solid #f0f0ee; + font-size: 14px; line-height: 1.6; color: #4b5563; +} + +/* v0.20.0 — spec article header (title + "View source" link sit on + the same row, wrapping on narrow widths). */ +.docs-article-header { + display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; margin: 0 0 24px; + flex-wrap: wrap; +} +.docs-article-header .docs-article-title { margin: 0; } +.docs-source-link { + font-size: 13px; color: #4b5563; text-decoration: none; + padding: 4px 10px; border-radius: 4px; border: 1px solid #e5e7eb; + background: #fafaf9; + white-space: nowrap; +} +.docs-source-link:hover { background: #f3f4f6; color: #111; } + .docs-empty, .docs-error { padding: 16px; border-radius: 6px; background: #fafaf9; border: 1px solid #f0f0ee; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 99e15bc..3a5dca1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,8 @@ import DocsUserGuide from './components/DocsUserGuide.jsx' import DocsSessionsAbout from './components/DocsSessionsAbout.jsx' import DocsSessionIndex from './components/DocsSessionIndex.jsx' import DocsSessionTranscript from './components/DocsSessionTranscript.jsx' +import DocsSpec from './components/DocsSpec.jsx' +import DocsSpecsIndex from './components/DocsSpecsIndex.jsx' import NotificationSettings from './components/NotificationSettings.jsx' import Admin from './components/Admin.jsx' import AcceptInvitation from './components/AcceptInvitation.jsx' @@ -329,6 +331,11 @@ function DocsWithSidebar({ viewer }) { } /> } /> } /> + {/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec + at runtime via gitea raw). Bare /docs/specs lands on the + client-side redirect to the first configured spec. */} + } /> + } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 0214e8a..b5d9cb4 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -774,6 +774,31 @@ export async function getSessionIndex(nnnn) { )) } +// --------------------------------------------------------------------------- +// v0.20.0 — /api/docs/specs/* surface +// --------------------------------------------------------------------------- +// +// Sibling of the docs-sessions helpers above. The framework mediates +// reads against the configured spec URLs (default: rfc-app's own +// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the +// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide` +// and `/docs/sessions/*`. The manifest endpoint always returns 200 + +// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to +// the framework default at parse time on the backend. +// +// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`, +// matching the docs-sessions helper convention. + +export async function getSpecsManifest() { + return jsonOrThrow(await fetch('/api/docs/specs/manifest')) +} + +export async function getSpec(name) { + return _textOrThrow(await fetch( + `/api/docs/specs/${encodeURIComponent(name)}` + )) +} + // --------------------------------------------------------------------------- // Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute // typeahead). diff --git a/frontend/src/components/DocsLayout.jsx b/frontend/src/components/DocsLayout.jsx index c8abc98..6420c5a 100644 --- a/frontend/src/components/DocsLayout.jsx +++ b/frontend/src/components/DocsLayout.jsx @@ -1,40 +1,64 @@ -// DocsLayout.jsx — v0.19.0 / roadmap item #30. +// DocsLayout.jsx — v0.20.0 (was v0.19.0 / roadmap item #30). // // Left-side flyout nav + content area for the `/docs/*` route tree: // // /docs → redirect to /docs/user-guide // /docs/user-guide → DOCS.md (existing v0.14.0 content) +// /docs/specs → client-side redirect to first configured spec +// /docs/specs/:name → a single framework spec (v0.20.0) // /docs/sessions → redirect to /docs/sessions/about // /docs/sessions/about → README.md from the sessions repo -// /docs/sessions/:nnnn → per-session index page +// /docs/sessions/:nnnn → per-session overview (nav-only navigation) // /docs/sessions/:nnnn/:file → per-transcript view // +// v0.20.0 changes (Session 0018.0): +// - Adds a "Specs" section between User Guide and Sessions, driven +// by `/api/docs/specs/manifest`. +// - Sessions render a nested tree: each session row has the +// session's transcripts nested under it as their own nav rows +// (labeled by `.N` ordinal). The transcript list is fetched per +// session via `/api/docs/sessions/:nnnn/index` (cached server- +// side, so the manifest+index fan-out is cheap on subsequent +// loads). Always-expanded; no collapse toggle (current scale is +// under twenty sessions — well under the threshold where lazy +// expansion would pay). +// // The flyout is a persistent left sidebar on desktop and a slide-out // drawer on mobile (toggled by the icon button in the docs header). -// The session list is driven by the `/api/docs/sessions/manifest` -// fetch: -// - loading → skeleton in the nav (three placeholder rows) -// - manifest 502 → error banner in the nav with "Try again" -// - empty manifest → only "About" under Sessions; no NNNN rows // // Amplitude analytics (per SPEC §21): // - track('Doc Viewed', { section: '...' }) on each sub-route mount; -// the sub-route component owns the fire (it knows the section). +// the sub-route component owns the fire. // - flyout buttons + links carry `aria-label` + `data-amp-track-name` // so autocapture rows are readable rather than ":nth-child(7)". import { useEffect, useState, useCallback } from 'react' import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom' -import { getSessionsManifest } from '../api.js' +import { getSessionsManifest, getSessionIndex, getSpecsManifest } from '../api.js' + +// Extract the `.N` ordinal from a transcript filename: +// "SESSION-0014.1-TRANSCRIPT-...md" → "0014.1" +// "SESSION-0013.1.1-TRANSCRIPT-...md" → "0013.1.1" (nested subagent) +// Returns the bare filename as fallback if the expected shape isn't +// matched (which shouldn't happen — the backend index endpoint +// filters by the same regex). +function transcriptOrdinal(filename) { + const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename) + return m ? m[1] : filename +} export default function DocsLayout({ authenticated }) { const [manifest, setManifest] = useState(null) const [manifestState, setManifestState] = useState('loading') // loading | ok | error + const [sessionFiles, setSessionFiles] = useState({}) // { nnnn: [filename, ...] } + const [specs, setSpecs] = useState([]) + const [specsState, setSpecsState] = useState('loading') // loading | ok | error const [drawerOpen, setDrawerOpen] = useState(false) const [reloadTick, setReloadTick] = useState(0) const navigate = useNavigate() const location = useLocation() + // Manifest fetch — drives the Sessions section. useEffect(() => { let active = true setManifestState('loading') @@ -52,6 +76,45 @@ export default function DocsLayout({ authenticated }) { return () => { active = false } }, [reloadTick]) + // Per-session transcript lists — fan out from the manifest. Always- + // expanded means we pre-fetch every session's index alongside the + // manifest, gated on the manifest having loaded successfully. The + // backend's 5-minute content TTL makes the repeat cost negligible. + useEffect(() => { + if (manifestState !== 'ok' || !manifest) return + let active = true + const nnnnList = Object.keys(manifest).sort() + Promise.all( + nnnnList.map(nnnn => + getSessionIndex(nnnn) + .then(payload => [nnnn, (payload && payload.files) || []]) + .catch(() => [nnnn, []]) + ) + ).then(pairs => { + if (!active) return + setSessionFiles(Object.fromEntries(pairs)) + }) + return () => { active = false } + }, [manifest, manifestState]) + + // Specs fetch — drives the Specs section. Independent of sessions. + useEffect(() => { + let active = true + setSpecsState('loading') + getSpecsManifest() + .then(payload => { + if (!active) return + setSpecs((payload && payload.specs) || []) + setSpecsState('ok') + }) + .catch(() => { + if (!active) return + setSpecs([]) + setSpecsState('error') + }) + return () => { active = false } + }, [reloadTick]) + // Close the mobile drawer on every navigation so a click in the nav // doesn't strand the user on a drawer-open view. useEffect(() => { @@ -99,6 +162,9 @@ export default function DocsLayout({ authenticated }) { @@ -119,12 +185,18 @@ export default function DocsLayout({ authenticated }) { ) } -function DocsNav({ manifest, manifestState, onRetry, currentPath }) { +function DocsNav({ + manifest, + manifestState, + sessionFiles, + specs, + specsState, + onRetry, + currentPath, +}) { const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/') + const isExactly = (path) => currentPath === path - // Sort session keys ascending (newest sessions render last). The - // manifest's keys are zero-padded 4-digit strings so lexicographic - // order is the same as numeric. const sessionKeys = Object.keys(manifest || {}).sort() return ( @@ -145,13 +217,48 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
    +
    +
    Specs
    + {specsState === 'loading' && ( +
      +
    • +
    • +
    + )} + {specsState === 'error' && ( +
    + Couldn't load specs. +
    + )} + {specsState === 'ok' && specs.length > 0 && ( +
      + {specs.map(spec => { + const to = `/docs/specs/${spec.name}` + return ( +
    • + + {spec.title} + +
    • + ) + })} +
    + )} +
    +
    Sessions
    • @@ -183,23 +290,45 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) { )} {manifestState === 'ok' && sessionKeys.length > 0 && ( -
        +
          {sessionKeys.map(nnnn => { const entry = manifest[nnnn] || {} const title = entry.title || '' const label = title ? `${nnnn} — ${title}` : nnnn const to = `/docs/sessions/${nnnn}` + const files = sessionFiles[nnnn] || [] return (
        • {label} + {files.length > 0 && ( +
            + {files.map(f => { + const tTo = `/docs/sessions/${nnnn}/${f}` + return ( +
          • + + {transcriptOrdinal(f)} + +
          • + ) + })} +
          + )}
        • ) })} diff --git a/frontend/src/components/DocsSessionIndex.jsx b/frontend/src/components/DocsSessionIndex.jsx index 601933e..62df647 100644 --- a/frontend/src/components/DocsSessionIndex.jsx +++ b/frontend/src/components/DocsSessionIndex.jsx @@ -1,14 +1,16 @@ -// DocsSessionIndex.jsx — v0.19.0 / roadmap item #30. +// DocsSessionIndex.jsx — v0.20.0 (was v0.19.0 / roadmap item #30). // -// Per-session index page at `/docs/sessions/:nnnn`. Lists every -// transcript published under the session's NNNN/ folder, linked to -// the per-transcript view. +// Per-session landing at `/docs/sessions/:nnnn`. v0.19.0 listed the +// transcripts as body links; v0.20.0 drops the body list — navigation +// is via the left flyout nav (which renders each session's transcripts +// nested under the session row). The body now serves as a +// session-overview card with the title, file count, and a hint to +// pick a transcript from the nav. // -// The transcript list comes from `/api/docs/sessions/:nnnn/index`, -// which the framework derives via the gitea contents API (see -// backend/app/docs_sessions.fetch_session_index). We also read the -// session's `title` from the manifest fetch so the page header -// matches the flyout nav entry. +// The transcript count still comes from `/api/docs/sessions/:nnnn/index` +// so the empty-state ("no transcripts yet"), not-found, and error +// paths remain meaningful — the page still does something useful when +// the upstream is mid-publish or unreachable. import { useEffect, useState, useCallback } from 'react' import { Link, useParams } from 'react-router-dom' @@ -105,21 +107,14 @@ export default function DocsSessionIndex() {
    )} {status === 'ok' && files.length > 0 && ( -
      - {files.map(f => ( -
    • - - {f} - -
    • - ))} -
    +
    +

    + {files.length === 1 + ? '1 transcript in this session.' + : `${files.length} transcripts in this session.`}{' '} + Select one from the navigation on the left. +

    +
    )} ) diff --git a/frontend/src/components/DocsSpec.jsx b/frontend/src/components/DocsSpec.jsx new file mode 100644 index 0000000..a256986 --- /dev/null +++ b/frontend/src/components/DocsSpec.jsx @@ -0,0 +1,134 @@ +// DocsSpec.jsx — v0.20.0. +// +// Per-spec view at `/docs/specs/:name`. Fetches a configured spec +// body via the backend mediator (which proxies the gitea raw URL) +// and renders it through the shared MarkdownPreview. The page header +// pulls the spec's `title` from the manifest so the breadcrumb-free +// page still names what you're looking at. +// +// Empty-state contract: +// 404 → "This spec isn't published yet / unknown name" with a hint +// to pick a configured spec from the nav. +// 502 → "Couldn't reach the spec source" + retry button. +// +// History view is intentionally absent — the operator's framing for +// v0.20.0 is "current version only; git is the history surface". +// A small "View on gitea" link beside the title points at the +// upstream source URL the manifest carries so the history gesture +// remains one click away. + +import { useEffect, useState, useCallback } from 'react' +import { Link, useParams } from 'react-router-dom' +import MarkdownPreview from './MarkdownPreview.jsx' +import { getSpec, getSpecsManifest } from '../api.js' +import { EVENTS, track } from '../lib/analytics' + +export default function DocsSpec() { + const { name } = useParams() + const [title, setTitle] = useState('') + const [sourceUrl, setSourceUrl] = useState('') + const [body, setBody] = useState('') + const [status, setStatus] = useState('loading') // loading | ok | notfound | error + const [reloadTick, setReloadTick] = useState(0) + + useEffect(() => { + track(EVENTS.DOC_VIEWED, { section: `specs/${name}` }) + }, [name]) + + // Title + upstream URL from the manifest (cheap — the manifest is + // derived from an env var on the backend, no network). + useEffect(() => { + let active = true + getSpecsManifest() + .then(payload => { + if (!active) return + const entry = (payload && payload.specs || []).find(s => s.name === name) + setTitle((entry && entry.title) || '') + setSourceUrl((entry && entry.url) || '') + }) + .catch(() => { + // Title + source link are decorative; the body fetch below + // is the load-bearing one. A failed manifest fetch just + // leaves the page rendering the bare name. + }) + return () => { active = false } + }, [name]) + + useEffect(() => { + let active = true + setStatus('loading') + getSpec(name) + .then(text => { + if (!active) return + setBody(text || '') + setStatus('ok') + }) + .catch(e => { + if (!active) return + if (e.status === 404) { + setStatus('notfound') + } else { + setStatus('error') + } + }) + return () => { active = false } + }, [name, reloadTick]) + + const retry = useCallback(() => setReloadTick(t => t + 1), []) + + const header = title || `Spec: ${name}` + + return ( +
    +
    +

    {header}

    + {sourceUrl && ( + + View source + + )} +
    + {status === 'loading' &&

    Loading…

    } + {status === 'notfound' && ( +
    +

    This spec isn't available.

    +

    + + ← Back to the user guide + +

    +
    + )} + {status === 'error' && ( +
    +

    Couldn't reach the spec source.

    + +
    + )} + {status === 'ok' && ( +
    + +
    + )} +
    + ) +} diff --git a/frontend/src/components/DocsSpecsIndex.jsx b/frontend/src/components/DocsSpecsIndex.jsx new file mode 100644 index 0000000..8db3521 --- /dev/null +++ b/frontend/src/components/DocsSpecsIndex.jsx @@ -0,0 +1,82 @@ +// DocsSpecsIndex.jsx — v0.20.0. +// +// Landing route at `/docs/specs`. The operator-stated body shape for +// the parallel `/docs/sessions/:nnnn` page is "no body list — pick a +// transcript from the nav", and the same gesture applies here: the +// `/docs/specs` route either redirects to the first configured spec +// (the common case) or renders a "no specs configured" empty state +// (only reachable if a deployment overrides `OHM_DOCS_SPECS` to an +// empty list — the framework default has two entries). +// +// The redirect is client-side because the manifest is a single API +// call away; server-side redirect would require either a backend +// route for the bare `/docs/specs` path (out of scope for v0.20.0) +// or a build-time bake of the first spec name (which couples the +// frontend bundle to the deployment overlay, which we don't do). + +import { useEffect, useState } from 'react' +import { Navigate, Link } from 'react-router-dom' +import { getSpecsManifest } from '../api.js' +import { EVENTS, track } from '../lib/analytics' + +export default function DocsSpecsIndex() { + const [firstName, setFirstName] = useState(null) + // Tri-state: 'loading' (waiting on manifest), 'redirect' (we have a + // name to redirect to — render ), 'empty' (no specs + // configured), or 'error' (couldn't load the manifest at all). + const [status, setStatus] = useState('loading') + + useEffect(() => { + track(EVENTS.DOC_VIEWED, { section: 'specs' }) + }, []) + + useEffect(() => { + let active = true + getSpecsManifest() + .then(payload => { + if (!active) return + const specs = (payload && payload.specs) || [] + if (specs.length === 0) { + setStatus('empty') + } else { + setFirstName(specs[0].name) + setStatus('redirect') + } + }) + .catch(() => { + if (!active) return + setStatus('error') + }) + return () => { active = false } + }, []) + + if (status === 'redirect' && firstName) { + return + } + + return ( +
    +

    Specs

    + {status === 'loading' &&

    Loading…

    } + {status === 'empty' && ( +
    +

    No specs are configured for this deployment.

    +

    + + ← Back to the user guide + +

    +
    + )} + {status === 'error' && ( +
    +

    Couldn't load the spec list.

    +
    + )} +
    + ) +}