Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 37 additions & 31 deletions keel/web/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@
from collections.abc import Iterable, Sequence
from decimal import Decimal
from typing import Any
from urllib.parse import quote

#: The published documentation root. Spelled here and in `static/js/docs.js`, and
#: `tests/web/test_doc_links.py` pins that the two agree.
DOCS_URL = "https://keeltrading.com/en/docs/"

#: Nav order, and the labels. `/` first because the status page is the answer to "is it alive".
#:
#: **The eighth entry is an OUTBOUND link, and used to be a page (#539).** `/glossary` rendered
#: `docs/glossary.md` read from the working directory -- which no installed deployment has, since
#: `docs/` sits at the repository root, outside the `keel/` module `uv_build` packages. Every
#: install therefore rendered an empty glossary, and `help_console.load_glossary`'s docstring said
#: so. It is a link now, for the same reason the client's is: linking is the only form of this
#: that reaches an installed deployment at all.
NAV: tuple[tuple[str, str], ...] = (
("/", "Status"),
("/setup", "Setup"),
Expand All @@ -34,7 +46,7 @@
("/rules", "Rules"),
("/venues", "Venues"),
("/gates", "Gates"),
("/glossary", "Glossary"),
(DOCS_URL, "Docs"),
)

_STYLE = """
Expand Down Expand Up @@ -304,6 +316,7 @@ def page(
path: str,
body: str,
build: str = "",
version: str = "",
refresh_sec: int | None = None,
) -> str:
"""The document shell. `refresh_sec` emits a `<meta http-equiv="refresh">` -- a zero-JS
Expand All @@ -313,7 +326,29 @@ def page(
nav_items = []
for href, label in NAV:
on = ' class="on"' if href == path else ""
nav_items.append(f'<a href="{esc(href)}"{on}>{esc(label)}</a>')
# An outbound entry is navigation, not a subresource, so `default-src 'none'` does not
# reach it. `noopener` still does matter: a new tab opened without it holds a
# `window.opener` handle back to a trading console on a token-bearing origin.
#
# `?v=` carries the running build, exactly as `static/js/docs.js` does for the client:
# the site pins `main` while an operator runs a tagged release, so a linked page can
# describe behaviour their build does not have. That skew is made VISIBLE rather than
# solved -- the build ends up in the URL bar of the page they are reading. `quote`, not
# an f-string, because a full version is `0.11.2+c1634a3fa17f` and a raw `+` in a query
# string decodes to a space.
#
# `version`, NOT `build`, and the two are different strings: `build` is the footer's
# human-readable LINE -- `keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]` -- and the first
# spelling of this used it, putting that whole sentence percent-encoded into the query.
# Caught by looking at the rendered href, not by a test: both forms are non-empty
# strings and every assertion about "the link carries a version" passed.
away = ""
target = href
if href.startswith("https://"):
away = ' target="_blank" rel="noopener noreferrer"'
if version:
target = href + "?v=" + quote(version, safe="")
nav_items.append(f'<a href="{esc(target)}"{on}{away}>{esc(label)}</a>')
nav = "".join(nav_items)
meta_refresh = (
f'<meta http-equiv="refresh" content="{int(refresh_sec)}">' if refresh_sec else ""
Expand Down Expand Up @@ -948,34 +983,5 @@ def render_gates(gates: Sequence[Any], capabilities: Sequence[Any]) -> str:
return "".join(parts)


def render_glossary(terms: Sequence[Any]) -> str:
parts = [
'<h1>Glossary</h1><p class="sub">keel\'s vocabulary, and the fiqh terms it anchors to</p>',
'<dl class="terms">',
]
for term in terms:
marker = ' <span class="pill">fiqh</span>' if term.fiqh else ""
if term.fiqh and not term.stated:
marker = ' <span class="pill warn">not stated in fiqh-basis</span>'
parts.append(f"<dt>{esc(term.term)}{marker}</dt>")
parts.append(f"<dd>{esc(term.definition)}</dd>")
source = term.citation or term.source
if source:
parts.append(f'<dd class="src">{esc(source)}</dd>')
parts.append("</dl>")
if not terms:
# The normal state of an INSTALLED deployment, not a bug: `docs/glossary.md` is read from
# the working directory, and a deployment folder is a config, a database and an .env --
# there is no docs checkout beside them. The TUI's help screen shows the same empty state
# for the same reason. Packaging the docs inside the artifact is D5's business (#438);
# until then this says which file is missing rather than implying the glossary is empty.
parts.append(
'<p class="empty">No glossary here. keel reads <code>docs/glossary.md</code> from '
"the folder it is run in, and an installed deployment has no docs checkout beside "
"its config and database.</p>"
)
return "".join(parts)


def render_message(heading: str, detail: str) -> str:
return f'<h1>{esc(heading)}</h1><p class="sub">{esc(detail)}</p>'
29 changes: 20 additions & 9 deletions keel/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,25 +280,18 @@ def page_gates(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, st
return "Gates", render.render_gates(GATES, CAPABILITIES), None


def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, str, int | None]:
from keel.commands.help_console import load_glossary

return "Glossary", render.render_glossary(load_glossary()), None


ROUTES: dict[str, Callable[[ServeConfig, dict[str, list[str]]], tuple[str, str, int | None]]] = {
# First-run detection (#437): every page that reads the database serves the checklist when
# there is no database to read, rather than a 500 whose real cause is that the user has not
# set anything up yet. `/venues`, `/gates` and `/glossary` are not wrapped -- none of them
# touches the deployment, and all three are useful before one exists.
# set anything up yet. `/venues` and `/gates` are not wrapped -- neither touches the
# deployment, and both are useful before one exists.
"/": needs_database(page_status),
"/setup": page_setup,
"/activity": needs_database(page_activity),
"/insights": needs_database(page_insights),
"/rules": needs_database(page_rules),
"/venues": page_venues,
"/gates": page_gates,
"/glossary": page_glossary,
}


Expand Down Expand Up @@ -466,6 +459,22 @@ def run_setup_action(cfg: ServeConfig, key: str, form: dict[str, str]) -> Any:
_JSON_CONTENT_TYPE = "application/json; charset=utf-8"


def _docs_version(cfg: ServeConfig) -> str:
"""The build the nav's documentation link should report, or `""`.

Read off `build_info` rather than off `cfg.build`, because those are different strings and
only one of them is a version: `cfg.build` is the footer's human-readable LINE
(`keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]`), and putting it in a query string produced
`?v=keel%200.11.2%2B...%20%28DIRTY%29%20%5Bcheckout%5D`. This is the same field `/api/config`
hands the client for the same purpose (`payload.config_document`'s `"build"`), so both
front-ends report the identical string while both exist.

`""` when there is no build info at all -- an unversioned link is honest, and a link claiming
`?v=unknown` is not.
"""
return str(getattr(cfg.build_info, "full_version", "") or "")


def _static_headers(content_type: str) -> tuple[tuple[str, str], ...]:
"""`_STATIC_BASE_HEADERS` plus CSP, but ONLY when `content_type` is one of
`_CSP_CONTENT_TYPES` -- see the comments on `_STATIC_BASE_HEADERS` and `_CSP_CONTENT_TYPES`
Expand Down Expand Up @@ -571,6 +580,7 @@ def _refuse(self, code: int, heading: str, detail: str) -> None:
path="",
body=render.render_message(heading, detail),
build=self.cfg.build,
version=_docs_version(self.cfg),
),
)

Expand Down Expand Up @@ -906,6 +916,7 @@ def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours
path=parsed.path,
body=body,
build=self.cfg.build,
version=_docs_version(self.cfg),
refresh_sec=refresh,
),
)
Expand Down
16 changes: 16 additions & 0 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ h2 { font-size: 1.05rem; margin: 2rem 0 0.6rem; }
}
.kv .v { font-size: 1.05rem; font-variant-numeric: tabular-nums; }

/* #539: a label that names a documented term links out to its definition.
`--muted`, NOT `--accent`: the label is the quietest text on the card and a link colour there
would pull the eye off the figure beside it, which is the thing the operator came to read. The
dotted underline is what marks it as a link -- and it is an underline rather than colour alone,
because #532's whole finding was that this palette had been separating meanings by hue.
`text-decoration-thickness` is set because a dotted border-bottom would sit below the descenders
and read as a divider instead. */
.kv .k .doclink {
color: inherit;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
text-decoration-thickness: 1px;
}
.kv .k .doclink:hover,
.kv .k .doclink:focus-visible { color: var(--fg); text-decoration-style: solid; }

.tablewrap {
overflow-x: auto;
/* A scrollable region must be reachable by keyboard. `tabindex="0"` on the wrapper (set in
Expand Down
17 changes: 17 additions & 0 deletions keel/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@
<li><a href="/static/rules">Rules</a></li>
<li><a href="/static/venues">Venues</a></li>
<li><a href="/static/gates">Gates</a></li>
<!--
THE EIGHTH NAV ENTRY IS A LINK, NOT A VIEW (#539).

`render.py`'s NAV has eight entries and this client has seven, and the difference is the
glossary. keel's `docs/` lives at the REPOSITORY root, outside the `keel/` module that
`uv_build` packages, so no wheel has ever carried it -- every installed deployment
renders an empty glossary today. That is not fixable with a packaging glob (measured at
#535: the `artifacts` key is inert on the pinned backend), so the documentation is
linked rather than shipped.

The href here is the un-versioned form and `main.js` replaces it with `?v=<build>` as
soon as `/api/config` answers. It is spelled out in the markup anyway rather than left
empty, because view-source is one of the reasons this project chose the web (§4), and a
nav entry whose destination only exists after a script runs is not readable there.
-->
<li><a id="docs-link" href="https://keeltrading.com/en/docs/"
target="_blank" rel="noopener noreferrer">Docs<span aria-hidden="true"> &#8599;</span></a></li>
</ul>
</nav>
</header>
Expand Down
141 changes: 141 additions & 0 deletions keel/web/static/js/docs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// @ts-check
/**
* Outbound documentation links (#539). **The app fetches, bundles and caches nothing.**
*
* ── WHY LINKING IS THE ONLY OPTION, NOT THE CHEAP ONE ───────────────────────────────────────
* `docs/` lives at the REPOSITORY root, outside `keel/`, and `uv_build` packages the module root
* -- so no wheel has ever carried it. Every installed deployment, the signed desktop bundle
* included, renders an empty glossary today, and `keel/commands/help_console.py` says so in its
* own docstring: "an installed deployment has no docs/ checkout, and the help screen renders
* that notice as its empty state."
*
* That is not fixable by adding a packaging glob -- it is structural. Measured at #535: building
* with `artifacts` set and with `artifacts = []` produces byte-identical wheels, because
* `uv_build` ships the whole module root regardless of the key. Linking out is the only option
* that reaches an installed deployment at all.
*
* ── NO OFFLINE FALLBACK, DELIBERATELY ───────────────────────────────────────────────────────
* No inline definitions, no cached snapshot, no entry in the service worker's `PRECACHE`. An
* operator running a trading engine has network by definition, and #538's whole argument is that
* a cached copy of something authoritative is worse than no copy: a definition that has since
* changed, presented as current, with nothing on screen to say which it is.
*
* ── THE ANCHOR CONTRACT, AND WHY A TEST HOLDS IT ────────────────────────────────────────────
* `docs/glossary.md` states its own rule -- "Each entry is a `## term` heading, a definition,
* and a `Source:` line" -- and Astro emits kebab-cased IDs for those headings, so the anchor for
* a term is its heading kebab-cased. Nothing in either repository enforces that from the other
* side: a heading renamed upstream would break every deep link here **silently**, because a bad
* fragment is not an error, it is a page that opens at the top.
*
* `tests/web/test_doc_links.py` closes that by parsing this table and asserting every anchor
* exists as a heading in the named document, in this repository, where `docs/` is the source.
*/

/** The published documentation root. One string, spelled once. */
const SITE = "https://keeltrading.com/en/docs/";

/**
* The running build, for `?v=`.
*
* **Version skew is made VISIBLE here, not solved.** The site pins `main` while an operator runs
* a tagged release, so a linked page can describe behaviour their build does not have. Per-
* version documentation paths were rejected: `keeltrading.com/en/docs/v0.11.0/glossary` 404s
* today, and building versioned trees is work in the other repository plus a retention policy,
* across three languages and a sitemap. Carrying the version in the query string costs nothing
* and puts the operator's build in the URL bar of the page they are reading.
*
* Module state, written exactly once, at boot, by `main.js` -- the alternative is threading a
* version string through every render function to reach the four places that build a link.
*/
let version = "";

/**
* Record the build every documentation link should carry. Called once from `main.js`, from the
* same `/api/config` read that fills the footer.
*
* @param {string} build
*/
export function rememberVersion(build) {
version = typeof build === "string" ? build : "";
}

/**
* The URL for one document, optionally at one anchor.
*
* The trailing slash on the slug is not cosmetic: the site serves `…/docs/glossary/index.html`,
* and the un-slashed form is a redirect that some browsers resolve by dropping the fragment --
* a deep link that lands at the top of the page, which is exactly the failure this module's
* anchor table exists to prevent.
*
* @param {string} slug a document slug, or `""` for the documentation index.
* @param {string} anchor a heading anchor, or `""` for the top of the page.
* @returns {string}
*/
export function documentUrl(slug, anchor) {
let url = SITE;
if (slug) url = url + slug + "/";
if (version) url = url + "?v=" + encodeURIComponent(version);
if (anchor) url = url + "#" + anchor;
return url;
}

/** The documentation index, for the header's outbound link. @returns {string} */
export function indexUrl() {
return documentUrl("", "");
}

/**
* The labels this client puts on screen that name a term the documentation defines, mapped to
* where it is defined.
*
* **Keyed by the LABEL, not by the term.** The alternative -- tagging each call site with a term
* name -- spreads the decision across six views and makes "which words on this screen are
* defined somewhere" unanswerable without reading all of them. Keyed by label, the whole answer
* is this table, and `kv` consults it for every pair it builds, so a label that names a term is
* a link wherever it appears without a call site knowing.
*
* **Deliberately NOT exhaustive, and the omissions are the point.** `mode` reads `paper` or
* `live` and would need two different targets for one label; `evidence required` sits on the
* CAPABILITY gates (`keel.capabilities.GATES`), not the promotion gate, and linking it to
* `#promotion-gate` would be confidently wrong. A missing link costs a reader one search. A
* wrong one costs them their trust in every other link on the page.
*
* @type {Record<string, {slug: string, anchor: string}>}
*/
export const TERMS = {
autonomy: { slug: "glossary", anchor: "autonomy" },
"autonomy configured": { slug: "glossary", anchor: "autonomy" },
"autonomy lapses": { slug: "glossary", anchor: "autonomy" },
"kill switch": { slug: "glossary", anchor: "kill-switch" },
"rail 11": { slug: "glossary", anchor: "rail" },
"withdrawal attestation (rail 17)": { slug: "glossary", anchor: "attestation" },
"market session": { slug: "glossary", anchor: "market-clock" },
session: { slug: "glossary", anchor: "session-bound-venue" },
"paper stage": { slug: "glossary", anchor: "paper-mode" },
};

/**
* A label as an outbound link to its definition, or `null` if the label names no term.
*
* `rel="noopener"` with `target="_blank"`: a new tab opened without it gets a `window.opener`
* handle back to this page, and this page is a trading console on a token-bearing origin.
* `noreferrer` too -- the server already sends `Referrer-Policy: no-referrer`, and a link that
* states it as well is one that keeps holding if this markup is ever read somewhere the header
* is not sent.
*
* @param {string} label
* @returns {HTMLAnchorElement | null}
*/
export function termLink(label) {
const target = Object.prototype.hasOwnProperty.call(TERMS, label) ? TERMS[label] : null;
if (!target) return null;
const anchor = document.createElement("a");
anchor.className = "doclink";
anchor.textContent = label;
anchor.href = documentUrl(target.slug, target.anchor);
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
// Named for a reader who arrives on the link out of context, and hears only the link text.
anchor.title = "Definition on keeltrading.com — opens in a new tab";
return anchor;
}
Loading