Skip to main content

max / makenotwork

8.7 KB · 183 lines History Blame Raw
1 //! Tier G1: one screen through the description layer, beside its Askama one.
2 //!
3 //! Wiki note `look-wave-2`, tier G. This exists to be measured and deleted, not
4 //! to be built on. It serves `/spike/docs`, which answers the same thing
5 //! `/docs` does, described rather than templated, so the two can be diffed and
6 //! the cost of converting a screen here is a number instead of an argument.
7 //!
8 //! The docs index was chosen because it isolates the question. Its data is an
9 //! in-memory `Arc<DocLoader>`, so nothing about it is entangled with the async
10 //! Postgres layer, and it is public, so nothing about it is entangled with the
11 //! session. What is left over when both of those are held still is the part
12 //! that is genuinely about describing a screen. What the choice *hides* is the
13 //! two findings in the module docs below, which are the ones that decide G2.
14 //!
15 //! # Finding 1: a handler cannot await, and this server's data layer is async
16 //!
17 //! [`quasi_router::Handler`] is `fn(&S, Request) -> Result<Response, RouteError>`,
18 //! sync by quasi's decision 6, which was taken for egui-in-a-frame and
19 //! terminal-in-an-event-loop and matches the desktop apps' rusqlite store. This
20 //! server is async sqlx over Postgres end to end. `quasi-axum` runs the router
21 //! on `spawn_blocking`, so a handler *can* reach a pool through
22 //! `Handle::block_on`, but every described route then holds a blocking-pool
23 //! thread for the length of a database round trip. That is a real cost on the
24 //! one host in the tree that has many concurrent readers.
25 //!
26 //! # Finding 2: nothing carries identity into a handler
27 //!
28 //! `quasi_http::decode` builds the request and drops the header map. Its
29 //! parameters are the path captures, the form body and the query string, which
30 //! is to say things the route pattern fixed or the client chose. A handler receives `&S`, which is shared, and those
31 //! params. There is no session, no cookie, no `AuthUser`, and no side channel:
32 //! the doc comment on `Adapter::per_request` says so explicitly, and that
33 //! factory feeds the *renderer*, after dispatch, not the handler.
34 //!
35 //! So an authenticated screen cannot be described today. That is most of this
36 //! server: of the 105 conversion units phase 0 counted, the dashboard, project,
37 //! item and library families are all behind auth. The public tier is what would
38 //! convert as-is, which is roughly the batch-1 set.
39 //!
40 //! # Finding 3: an internal navigation is never a link
41 //!
42 //! Read off the emitted document, which the test below prints. A row that
43 //! activates emits
44 //!
45 //! ```html
46 //! <button type="button" class="row-activate" hx-get="/docs/api" hx-swap="morph">API</button>
47 //! ```
48 //!
49 //! `quasi_webview::node::action_attrs` writes an `href` only for
50 //! [`Destination::External`](quasi_router::Destination); an internal route is
51 //! always a control carrying `hx-get`. So there is no href for a crawler to
52 //! follow, no middle-click, no copy-link, and with JS off the page is inert.
53 //!
54 //! That collides head-on with finding 2. The only tier that can be described
55 //! today is the public one, and the public one is the tier where this costs
56 //! most: `/docs`, `/discover`, and every creator, project and item page is an
57 //! SEO surface. The docs index today is `<li><a href="/docs/{slug}">`.
58 //!
59 //! # Finding 4: the site chrome has no home
60 //!
61 //! The Askama page opens with `{% include "partials/site_header.html" %}`, 35
62 //! lines of nav, sign-in state and the wordmark. [`Shell`] emits the `<head>`
63 //! and the `<body>` wrapper and nothing inside it, so the header is either a
64 //! `Region::Band` every screen redescribes, or a bespoke fill every screen
65 //! carries. Either way it is per-screen work times 105, for markup that is the
66 //! same on all of them, and `base.html` does it once today.
67 //!
68 //! # Finding 5: two vocabulary gaps this one screen already hits
69 //!
70 //! - **Disclosure.** The Guide section groups into `<details open><summary>`
71 //! subsections. No node says that, so the described version flattens them.
72 //! - **The search box.** The input, its results container and `docs-search.js`
73 //! are bespoke, so this screen needs a `Region::Bespoke` fill even though
74 //! phase 0 rated it furniture. Worth noting for the sizing: "furniture" was
75 //! read off templates, and this one turned out to carry a fill.
76 //!
77 //! # What it deletes, measured on this screen
78 //!
79 //! `templates/pages/doc_index.html` is 45 lines and would go. The 45-line
80 //! grouping pass in the Askama handler stays, near enough verbatim: the
81 //! description replaces the markup, not the domain logic. The described screen
82 //! is 14 lines of handler plus 7 of assembly.
83 //!
84 //! It does **not** delete the stylesheet. `style.css` carries 25 `.docs-*`
85 //! rules; of those the `.docs-search-*` set survives with the fill, and the
86 //! `.docs-index` / `.docs-section` set has nothing to attach to any more,
87 //! because the emitted classes are the generated `.list` / `.row` / `.heading`.
88 //! Note what that does to charter rule 13: both the page title and the section
89 //! titles emit `class="heading"`, so the 13 named heading classes have no
90 //! purchase on a described screen and any level distinction has to come from
91 //! the element selector.
92
93 use std::sync::Arc;
94
95 use docengine::DocLoader;
96 use quasi_axum::Adapter;
97 use quasi_router::{Node, RegionKind, Request, Response, RouteError, Row, Screen, Slot};
98 use quasi_webview::{Shell, Webview};
99
100 /// The docs index, described.
101 ///
102 /// Compare against `routes::pages::public::docs::docs_index`, which is the same
103 /// grouping over the same loader ending in `DocIndexTemplate`.
104 fn docs_index(docs: &DocLoader, _request: Request) -> Result<Response, RouteError> {
105 // The grouping is the handler's either way. Describing a screen does not
106 // remove the domain pass over the index, and this is a fair copy of it.
107 let mut sections: Vec<(String, Vec<Row>)> = Vec::new();
108 for entry in docs.index() {
109 let row = Row::new(entry.title.clone())
110 .activate(quasi_router::Action::get(format!("/docs/{}", entry.slug)));
111 match sections.iter_mut().find(|(name, _)| name == &entry.section) {
112 Some((_, rows)) => rows.push(row),
113 None => sections.push((entry.section.clone(), vec![row])),
114 }
115 }
116
117 Ok(screen_from(sections).into())
118 }
119
120 /// Build the screen from an already-grouped index.
121 ///
122 /// Split out of the handler so a test can render it without standing up a
123 /// [`DocLoader`]. The grouping above is the only part that touches the loader.
124 fn screen_from(sections: Vec<(String, Vec<Row>)>) -> Screen {
125 let mut pane = Slot::new("docs", RegionKind::Pane).with(Node::page("Documentation"));
126 for (name, rows) in sections {
127 pane = pane.with(Node::section(name)).with(Node::list(rows));
128 }
129 Screen::sidebar_content("Documentation - Makenotwork").with(pane)
130 }
131
132 /// The renderer this spike serves with.
133 fn renderer() -> Webview {
134 // `Shell::under` points the asset paths at `/static`, which is what this
135 // server already serves. `layered` states the cascade order the hand-written
136 // head states today; without it the generated sheets would establish the
137 // `makeover` layer by link order.
138 let shell = Shell::under("/static").layered(["base", "components", "responsive"]);
139 Webview::new().with_shell(shell)
140 }
141
142 /// The spike's route table and renderer, mounted under `/spike`.
143 ///
144 /// Mounted with `nest_service` rather than merged, because
145 /// [`Adapter::into_router`] mounts as a fallback and this server already has
146 /// one. The prefix is stripped, so the router sees `/docs` and the pattern
147 /// below is the real one.
148 pub fn router(docs: Arc<DocLoader>) -> axum::Router {
149 let quasi = quasi_router::Router::<DocLoader>::new().get("/docs", docs_index);
150 Adapter::new(quasi, docs, Arc::new(renderer())).into_router()
151 }
152
153 #[cfg(test)]
154 mod tests {
155 use super::*;
156 use quasi_axum::Serves;
157
158 /// Print what the description layer emits for this screen, so the G1
159 /// measurement is read off a real document rather than reasoned about.
160 #[test]
161 fn emits_the_docs_index() {
162 let sections = vec![
163 (
164 "Guide".to_string(),
165 vec![
166 Row::new("Getting started")
167 .activate(quasi_router::Action::get("/docs/getting-started")),
168 Row::new("Uploading files")
169 .activate(quasi_router::Action::get("/docs/uploading")),
170 ],
171 ),
172 (
173 "Reference".to_string(),
174 vec![Row::new("API").activate(quasi_router::Action::get("/docs/api"))],
175 ),
176 ];
177
178 let html = renderer().screen(&screen_from(sections));
179 println!("{html}");
180 assert!(html.contains("Documentation"));
181 }
182 }
183