Merge feature/v0.20.0-docs-specs-nav-hierarchy
This commit is contained in:
@@ -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/<name>`** — 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/<NNNN>` 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='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw 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/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (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.
|
||||
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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/<name> — 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}"}
|
||||
@@ -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"
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2281,6 +2281,24 @@
|
||||
background: #e7e5e4; color: #111; font-weight: 600;
|
||||
}
|
||||
|
||||
/* v0.20.0 — nested transcript rows under each session row. The parent
|
||||
<li> holds the session link; the nested <ul> 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;
|
||||
|
||||
@@ -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 }) {
|
||||
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||||
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||||
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||||
{/* 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. */}
|
||||
<Route path="specs" element={<DocsSpecsIndex />} />
|
||||
<Route path="specs/:name" element={<DocsSpec />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }) {
|
||||
<DocsNav
|
||||
manifest={manifest}
|
||||
manifestState={manifestState}
|
||||
sessionFiles={sessionFiles}
|
||||
specs={specs}
|
||||
specsState={specsState}
|
||||
onRetry={retryManifest}
|
||||
currentPath={location.pathname}
|
||||
/>
|
||||
@@ -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 }) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Specs</div>
|
||||
{specsState === 'loading' && (
|
||||
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
<li><span className="skeleton-row" /></li>
|
||||
</ul>
|
||||
)}
|
||||
{specsState === 'error' && (
|
||||
<div className="docs-nav-error" role="alert">
|
||||
<span>Couldn't load specs.</span>
|
||||
</div>
|
||||
)}
|
||||
{specsState === 'ok' && specs.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
{specs.map(spec => {
|
||||
const to = `/docs/specs/${spec.name}`
|
||||
return (
|
||||
<li key={spec.name}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isExactly(to) ? 'active' : ''}
|
||||
aria-label={`Spec: ${spec.title}`}
|
||||
data-amp-track-name="Docs Nav Spec"
|
||||
data-amp-track-spec={spec.name}
|
||||
>
|
||||
{spec.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="docs-nav-section">
|
||||
<div className="docs-nav-section-label">Sessions</div>
|
||||
<ul className="docs-nav-list">
|
||||
<li>
|
||||
<Link
|
||||
to="/docs/sessions/about"
|
||||
className={currentPath === '/docs/sessions/about' ? 'active' : ''}
|
||||
className={isExactly('/docs/sessions/about') ? 'active' : ''}
|
||||
aria-label="About sessions"
|
||||
data-amp-track-name="Docs Nav Sessions About"
|
||||
>
|
||||
@@ -183,23 +290,45 @@ function DocsNav({ manifest, manifestState, onRetry, currentPath }) {
|
||||
)}
|
||||
|
||||
{manifestState === 'ok' && sessionKeys.length > 0 && (
|
||||
<ul className="docs-nav-list">
|
||||
<ul className="docs-nav-list docs-nav-list--tree">
|
||||
{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 (
|
||||
<li key={nnnn}>
|
||||
<Link
|
||||
to={to}
|
||||
className={isActive(to) ? 'active' : ''}
|
||||
className={isExactly(to) ? 'active' : ''}
|
||||
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
|
||||
data-amp-track-name="Docs Nav Session"
|
||||
data-amp-track-session={nnnn}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
{files.length > 0 && (
|
||||
<ul className="docs-nav-list docs-nav-list--children">
|
||||
{files.map(f => {
|
||||
const tTo = `/docs/sessions/${nnnn}/${f}`
|
||||
return (
|
||||
<li key={f}>
|
||||
<Link
|
||||
to={tTo}
|
||||
className={isExactly(tTo) ? 'active' : ''}
|
||||
aria-label={`Transcript ${transcriptOrdinal(f)}`}
|
||||
data-amp-track-name="Docs Nav Transcript"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={f}
|
||||
>
|
||||
{transcriptOrdinal(f)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && files.length > 0 && (
|
||||
<ul className="docs-session-files">
|
||||
{files.map(f => (
|
||||
<li key={f}>
|
||||
<Link
|
||||
to={`/docs/sessions/${nnnn}/${f}`}
|
||||
aria-label={`Open transcript ${f}`}
|
||||
data-amp-track-name="Docs Session Transcript Open"
|
||||
data-amp-track-session={nnnn}
|
||||
data-amp-track-filename={f}
|
||||
>
|
||||
{f}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="docs-session-overview">
|
||||
<p>
|
||||
{files.length === 1
|
||||
? '1 transcript in this session.'
|
||||
: `${files.length} transcripts in this session.`}{' '}
|
||||
Select one from the navigation on the left.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<article className="docs-article">
|
||||
<header className="docs-article-header">
|
||||
<h1 className="docs-article-title">{header}</h1>
|
||||
{sourceUrl && (
|
||||
<a
|
||||
className="docs-source-link"
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View spec source on gitea"
|
||||
data-amp-track-name="Docs Spec Source Link"
|
||||
data-amp-track-spec={name}
|
||||
>
|
||||
View source
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'notfound' && (
|
||||
<div className="docs-empty">
|
||||
<p>This spec isn't available.</p>
|
||||
<p>
|
||||
<Link
|
||||
to="/docs/user-guide"
|
||||
aria-label="User guide"
|
||||
data-amp-track-name="Docs Spec Notfound User Guide Link"
|
||||
>
|
||||
← Back to the user guide
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't reach the spec source.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
aria-label="Retry"
|
||||
data-amp-track-name="Docs Spec Retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && (
|
||||
<div className="philosophy-body">
|
||||
<MarkdownPreview content={body} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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 <Navigate>), '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 <Navigate to={firstName} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="docs-article">
|
||||
<h1 className="docs-article-title">Specs</h1>
|
||||
{status === 'loading' && <p className="muted">Loading…</p>}
|
||||
{status === 'empty' && (
|
||||
<div className="docs-empty">
|
||||
<p>No specs are configured for this deployment.</p>
|
||||
<p>
|
||||
<Link
|
||||
to="/docs/user-guide"
|
||||
aria-label="User guide"
|
||||
data-amp-track-name="Docs Specs Empty User Guide Link"
|
||||
>
|
||||
← Back to the user guide
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="docs-error" role="alert">
|
||||
<p>Couldn't load the spec list.</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user