Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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)
}