This file documents the documentation site (/docs) - prose pages, API reference generation, search, and the interactive tester. Claude Code auto-loads it whenever a file in this directory is read or edited.

Routing overview

  • (none) - docs.py (docs/ package), the documentation site (prose pages + API reference). See DOCS_PAGES below.

Documentation site (/docs)

routers/docs/ package serves the docs. DOCS_PAGES (routers/docs/pages.py, re-exported from the routers/docs package; handlers in routers/docs/views.py) keeps curated prose pages (kind: "prose", each with its own template under templates/docs/<slug>.html) and spreads api_doc_pages() from devplacepy/docs_api.py for every API reference page. Prose pages grouped by section: General (index, getting-started, devii, dashboard, media-gallery, notification-settings), admin-only Devii internals (devii-*), admin-only Services (services-*, documenting the BaseService framework and every background service including the live data at GET /admin/services/data), and admin-only Production (production-*, documenting the deployment). A page is admin-gated by "admin": True; search/export pick it up automatically by slug and respect the admin flag - no extra wiring.

Audience tiers and navigation

DOCS_PAGES entries take optional admin: True (hidden + 404 for non-admins, but still indexed and surfaced only to admins by docs_search) and section: "..." (a nested sidebar group rendered by docs_base.html). The sidebar groups sections under four ordered audience tiers (AUDIENCES in routers/docs/pages.py): Start here (General), Build with the API (API, Components, Styles), Contribute and internals (Architecture, Services, Devii internals, Bots internals, Testing, Claude Code), and Operate (Administration, Production). nav_groups(visible_pages) builds the [(audience, [(section, [pages])])] tree from the flat visible-page list (so a section's pages collect under one heading regardless of DOCS_PAGES order or the API/Administration interleave from api_doc_pages()); views.py passes it as nav, and docs_base.html renders an audience super-header (.sidebar-tier) above each section subheading (.sidebar-subheading). DOCS_PAGES stays the canonical list for search/export/routing - the tiering is sidebar-only.

The public getting-started page (SECTION_GENERAL) is the new-contributor on-ramp (install/run, the four-faces workflow, validation); gate its deep-internals links with {% if is_admin(user) %} so guests get no 404s. Keep one canonical home per concept: the auth API group intro in docs_api.py defers method detail to the authentication prose page rather than re-listing the four methods. The member-facing devii prose page is functional; admins also get a Devii internals section of devii-* technical subpages.

Single source of truth

docs_api.py API_GROUPS defines each reference page and its endpoints (method, path, auth, params, notes, sample_response). Add an endpoint there and the page, sidebar link, code examples, and interactive runner appear automatically.

Downloads

devplacepy/docs_export.py: /docs/download.md returns the entire docs as one Markdown file; /docs/download.html returns a single self-contained HTML (vendored marked + highlight.js + theme inlined, the Markdown embedded base64 and rendered on open - works offline). Both are admin-filtered like the rest of the docs and reuse the same group/prose sources. Routes are declared before /docs/{slug}.html so download.html isn't caught by the slug pattern.

Docs search (/docs/search.html)

A backend-rendered BM25 search over all docs pages, in devplacepy/docs_search.py. The corpus = prose page text (template stripped of Jinja/HTML) + each API group's intro/endpoints + the live services group; the inverted index (postings + idf) is built once lazily (get_index(), cached module-global) so a query is just postings lookups (~120us). docs.py special-cases slug == "search" (before the page registry) and renders docs/_search.html inside docs_base.html (kind == 'search'). Admin-only pages are filtered from results for non-admins, exactly like the sidebar. Snippets are HTML-escaped then wrapped in <mark> (XSS-safe). _strip removes <script>/<style> blocks and runs _demarkdown (drops headings, list/quote markers, table pipes, code fences, link/image syntax, and backtick/asterisk/tilde emphasis, but keeps _ so identifiers like owner_kind stay searchable), so both the index and the snippets read as clean prose rather than raw markdown. The former search API group (user/recipient lookups) was renamed to slug lookups ("Search & Lookups") to free the search slug - keep that in mind if adding endpoints there.

Token substitution

Intros/notes/examples may use {{ base }}, {{ username }}, {{ api_key }} - these are substituted server-side by render_group() (plain string replace, not Jinja), because the registry is data, not template source.

Rendering pipeline (kind branch, prose rendering)

docs_base.html renders api pages via _api_page.html -> _endpoint.html per endpoint. Prose pages are rendered server-side: docs.py calls docs_prose.render_prose(slug, ctx) (devplacepy/docs_prose.py, mistune GFM with tables/strikethrough/url + hard_wrap to mirror the client marked config), which renders the template, converts the single <div class="docs-content" data-render> markdown block to HTML (after html.unescape, matching the client's textContent read), and strips data-render. docs_base.html outputs the result via {{ prose_html|safe }}. This eliminates the client markdown-to-HTML flicker and improves first paint. Anything outside that block (component live-demo blocks + their <script type="module">, the devii.html hero/CTA) is passed through verbatim. hljs highlighting and CodeCopy still run client-side over the now-server-rendered <pre><code> (text is already present, only colors/copy button appear after JS).

Every rendered h2/h3 automatically gets a slugified id plus a hover permalink (docs_prose._anchor_headings, heading_slug), so any prose page can deep-link its sections; a page wanting a contents index places a .docs-toc nav (styled in docs.css) OUTSIDE the data-render block linking to those slugs (see isslop-checks). Authored example markup inside that block is therefore still HTML-escaped (&lt;dp-...&gt;); content outside the block passes through untouched. User-generated content elsewhere still uses the client data-render pipeline.

The public Components section (component-* prose pages) documents the custom web components with a live, interactive example on each page. The public Styles section (styles, styles-colors, styles-layout, styles-responsiveness, styles-consistency) is the design-system reference: the colour tokens and their meaning, the approved page layouts, the responsive breakpoint ladder, and the HARD structural rules every page must follow (taken from the feed/posts page as the canonical implementation). It uses the same live-demo convention (real demo markup OUTSIDE the data-render block, example markup inside it entity-escaped).

data-render destroys inner HTML (it renders textContent). Only group-intro / prose markdown lives inside a data-render block; every endpoint card, [data-api-tester] mount, and component live-demo lives OUTSIDE it. A prose page's <div class="docs-content" data-render> is rendered client-side via marked + DOMPurify on textContent, so any example markup shown as code inside it MUST be HTML-escaped (&lt;dp-dialog&gt;) or the browser parses it as a real element before render; the live demo itself goes in a separate block OUTSIDE the data-render div, where a <script type="module"> (which imports its own component module, since it executes before Application.js) wires it up.

data-config must be single-quoted: _endpoint.html emits data-config='{{ endpoint|tojson }}'. tojson escapes ' to &#39;, so single quotes are safe; double quotes would break.

Interactive API tester

The reusable widget is static/js/ApiTester.js (one instance per [data-api-tester], wired by ApiDocs.js loaded in the docs extra_js block). It builds the param form, a response-format picker, live cURL/JS/Python tabs, a Send button that runs the real call, and a two-tab response area. It reads window.DEVPLACE_DOCS (base, loggedIn, username, apiKey, isAdmin) injected in docs_base.html. Code blocks are decorated by the shared static/js/CodeBlock.js (highlight via hljs + a line-number gutter + an always-visible Copy button); the widget calls CodeBlock.refresh(pre) on every tab switch (re-highlights - it clears data-highlighted first, the fix for stale tabs), the response panes use CodeBlock.enhance(pre, {lineNumbers:false}), and CodeCopy.js runs CodeBlock.enhance over prose .docs-content pre. Styling lives in docs.css (.code-pre/.code-gutter/.code-has-copy, .format-picker/.format-option, .response-tabs/.response-pane).

Response-format negotiation

Every endpoint dict carries a negotiation field, set by _classify() in docs_api.py (not hand-written): "negotiable" (page GETs + action POSTs - toggle JSON/HTML via the Accept header), "ajax" (votes/reactions/bookmarks/polls - JSON via X-Requested-With: fetch, HTML shows the redirect), "json" (always JSON), "none" (avatar/proxy/redirect - no body). The picker defaults to JSON everywhere; ApiTester.headerPairs() adds Accept: application/json|text/html accordingly and only sends X-Requested-With for ajax endpoints in JSON mode, so the live request and the generated snippets stay in sync. The Expected tab (always visible, default) renders the endpoint's sample_response; the Live response tab fills in after Send.

Runnable scope

Page GETs are interactive: true (they get a Send button, gated by ctx.loggedIn/ctx.isAdmin for user/admin endpoints). Mutations use destructive: true (confirm dialog). Only avatar, gateway-passthrough, notifications-open, push-register, profile-regenerate-key, and profile-regenerate-avatar stay interactive: false (image/proxy/redirect/side-effecting) - they still show the Expected tab. Admin endpoints (auth: "admin") only run for admins.

Enum params

Pass options, never hardcode allowed values in prose. A field(... type="enum", options=[...]) auto-renders an "Allowed: a, b, c" line under the control in the live tester (ApiTester.js buildParams) and appends Allowed: ... to the Description column in the Markdown/HTML export (docs_export.py _params_table). Source enum lists from devplacepy/constants.py (TOPICS, REACTION_EMOJI) or the model Literals (PROJECT_TYPES, VOTE_TARGETS) so docs stay in sync. Do NOT spell the values into the description - it would duplicate and drift.

Minimal role documentation and validation

The endpoint() factory derives min_role from auth (public -> Public, user -> Member, admin -> Admin); it is rendered as the "Minimal role:" badge in _endpoint.html and as *Minimal role:* in the Markdown/HTML export. The auth value MUST reflect the real enforcement: tests/api/auth/matrix.py drives every documented endpoint (incl. the dynamic services group) as anonymous, member, and admin and asserts the enforcement matches the doc - public allows anonymous, user rejects anonymous (401/login-redirect), admin rejects a non-admin member (403 /feed-redirect). It forces explicit roles to survive the is_first-becomes-Admin rule. If you add/relax a route's auth, update its auth in docs_api.py or this test fails. (Example caught by it: /notifications/counts uses get_current_user and returns zeros to guests, so it is documented public, not user.)

Admin-only pages

A group with "admin": True (currently services, admin) is hidden from the sidebar (docs.py filters visible_pages) and returns 404 for non-admins (page.get("admin") and not is_admin -> not_found, same message as unknown slugs). index.html wraps the operator links in {% if user and user.get('role') == 'Admin' %}.

Dynamic Background Services page

The services group is a placeholder ("dynamic": True, empty endpoints). docs.py branches on dynamic and calls build_services_group(service_manager.describe_all(), base) to generate it live from the registered services and their ConfigField specs (one POST /admin/services/{name}/config card per service, *_enabled fields excluded). Add a service to main.py startup and it documents itself - keep docs_api.py import-pure (no describe_all() at import time).