crates/gunnar-ui-web/src/pages/readme.rs
//! **The README panel: a page the server already rendered, in one of two
//! shapes.**
//!
//! The console does no markdown work at all — no parser, no sanitiser, no SVG
//! library crossed the wasm boundary. `GET …/readme` answers one of four grades
//! and this draws whichever arrived; a second opinion here about a document the
//! server has finished with would have cost the bundle tens of kilobytes and
//! could only ever disagree.
//!
//! # The default is the typeset page, and the toggle is the plain one
//!
//! `svg` is what the panel shows first: the README actually set — a proper
//! measure, proper tables, the page a person would print. It used to be the
//! toggle, because every glyph was an SVG `<path>` and there was no text in it
//! for a selection, a search or a screen reader to find. docbook 0.1.2 puts an
//! invisible text layer over the glyphs, so that objection is gone and the
//! better-looking page can lead.
//!
//! `HTML` swaps in `?as=html`: typst's HTML target, sanitised on the server.
//! Plainer, and about a thirtieth of the bytes.
//!
//! Links take a click in both. docbook 0.1.2's text layer used to sit on top of
//! the typeset page's links and swallow it; 0.1.3 lifts them above the text.
//! The server points every relative link at a console URL (`readme::links`)
//! and marks the HTML ones `rel="external"`, so the router — which claims a
//! same-origin `<a>` click and would draw nothing for `/api/…/raw` — leaves
//! them to the browser. The typeset page's are SVG anchors, which the router
//! never claims.
//!
//! The choice is per panel and not remembered: it is a way to look at one
//! document differently, not a preference about all of them.
//!
//! # Why `inner_html`, twice, and why that is not the usual mistake
//!
//! Both grades are set with `inner_html`, which is normally the wrong tool. It
//! is the right one here because of where the bytes came from:
//!
//! * the HTML has been through `ammonia` on the server — no script, no style,
//! no iframe, no `on*`, no `javascript:`;
//! * the SVG is written by the **typesetter**, not by a repository. Its glyphs
//! are outlines and its text layer is the document's own characters, escaped
//! on the way in; there is no place in it for a `<script>`, and both
//! `readme::tests` and `nornir_docbook`'s own tests assert that rather than
//! trusting the claim. It also cannot be set as a text node: an SVG that
//! arrives as text is drawn as its own source code.
//!
//! The SVG carries a `<style>` of its own — the rules that make the text layer
//! transparent and selectable travel with the page rather than living in the
//! console's stylesheet. That needs `style-src 'unsafe-inline'` in the CSP,
//! which `security.rs` keeps permanently and for its own reasons.
//!
//! `text` is a `<pre>`, because it is text, and Leptos escapes it.
//!
//! # Why the panel is absent rather than empty when there is no README
//!
//! A "no README" box on every repository is a line an operator learns to skip
//! and then misses on the one that has one. `none` draws nothing at all, and so
//! does a refusal — a visitor who may read a repository's tree but whose README
//! call failed has the browse view one click away, and a failure box above the
//! history would be the loudest thing on a page it is not about.
use leptos::prelude::*;
use serde::Deserialize;
use crate::api::segment;
/// What `GET /api/repos/{ns}/{name}/readme` answers.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Readme {
/// `html` | `svg` | `text` | `none`.
pub kind: String,
pub body: String,
pub path: String,
pub oid: String,
/// Why this is not what was asked for. Shown under the page, quietly: a
/// reader who asked for nothing, got the plain view instead of the typeset
/// one, and can see the difference is entitled to know why.
pub warning: Option<String>,
}
/// The repository's README, under the header and above everything else.
#[component]
pub fn ReadmePanel(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
// The reader's choice, and the only state this panel has. `false` is the
// route's own default — the typeset page — so the first request carries no
// query at all and lands on the server's default rather than restating it.
//
// On a phone the plain grade comes first (GUN-FIELD-TEST, 2026-11-01): the typeset
// page is a ~720-wide drawing and at 400 px its text could not be read.
let plain = RwSignal::new(crate::narrow::plain_first(viewport_width()));
let readme = LocalResource::new(move || {
// Reading the signal INSIDE the source closure is what makes the
// toggle refetch: a `LocalResource` re-runs when a signal it read
// changes, and the shape is part of the URL rather than of the answer.
let shape = if plain.get() { "?as=html" } else { "" };
let url = format!(
"/api/repos/{}/{}/readme{shape}",
segment(&namespace.get()),
segment(&name.get())
);
async move { crate::api::get::<Readme>(&url).await }
});
view! {
{move || {
// A refusal draws nothing: see the module docs.
let Some(Ok(readme)) = readme.get() else {
return ().into_any();
};
let warning = readme.warning.clone();
let path = readme.path.clone();
let body = match readme.kind.as_str() {
// The SVG carries its own `width="100%"` and `viewBox`; the
// container is what lets a page that is somehow still too wide
// scroll sideways instead of widening the whole console.
"svg" => view! {
<div class="readme-page" inner_html=readme.body.clone()></div>
}
.into_any(),
"html" => view! {
<div class="readme" inner_html=readme.body.clone()></div>
}
.into_any(),
"text" if !readme.body.is_empty() => view! {
<pre class="readme-text">{readme.body.clone()}</pre>
}
.into_any(),
// `none`, and a `text` grade with nothing in it (a README that
// is not text at all).
_ => return ().into_any(),
};
// The toggle is offered only where it means something. A `text` or
// `none` README has no typeset form, and a button that answered
// with the same thing would be a lie about what it does.
let offer_toggle = matches!(readme.kind.as_str(), "html" | "svg");
view! {
<section class="panel">
<header>
<h2>{path}</h2>
<Show when=move || offer_toggle>
<div class="actions">
<button
class="btn"
aria-pressed=move || plain.get().to_string()
on:click=move |_| plain.update(|on| *on = !*on)
>
{move || if plain.get() { "Typeset" } else { "HTML" }}
</button>
</div>
</Show>
</header>
<div class="panel-body">
{body}
{warning.map(|why| view! { <p class="readme-note">{why}</p> })}
</div>
</section>
}
.into_any()
}}
}
}
/// The viewport's width in CSS pixels, or 0 when the window cannot say.
pub fn viewport_width() -> f64 {
web_sys::window()
.and_then(|w| w.inner_width().ok())
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
}