|
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, Params) -> 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 `Incoming { method, path, params }` and drops the
|
|
29 |
+ |
//! header map. Params are the query string and the form body, which is to say
|
|
30 |
+ |
//! things 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, Params, RegionKind, 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, _params: Params) -> 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::Render;
|
|
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 |
+ |
}
|