Skip to main content

max / makenotwork

10.0 KB · 267 lines History Blame Raw
1 //! Notes on the object a git page is showing, described.
2 //!
3 //! `templates/partials/git_notes_panel.html`'s contents. The commit page and
4 //! the file view both carry it -- a note on a commit and a note on a blob are
5 //! the same note drawn the same way -- so it stays one thing here for the
6 //! reason it was one partial there.
7 //!
8 //! [`super::carousel`]'s shape: [`region`] is the description and [`html`] is
9 //! the Askama half.
10 //!
11 //! # The body is a handover, and the header is not
12 //!
13 //! `CommitNote::html` has already been through docengine and the sanitiser, and
14 //! neither pass is derivable from the note's markdown by anything downstream.
15 //! That is `src/quasi/project.rs`'s split exactly: markup a host pipeline owns
16 //! goes in a [`RegionKind::Handover`], and the frame over it is described.
17 //!
18 //! The alternative was [`Node::Rich`], which carries the source and lets each
19 //! renderer make its own markup. It is the right member for a note *written* in
20 //! this session and the wrong one here: `source` is kept for the edit box and
21 //! rendering it a second way would draw a different document from the one the
22 //! notes index, the feed and the search results all show.
23 //!
24 //! # A namespace is monospace because it is a ref path
25 //!
26 //! `commits`, `mnw/builds`: a name git will accept, not prose. It is said as an
27 //! inline [`Node::Code`], which is the member for machine text in a line of
28 //! reading, and the shipped rule set `font-family: var(--font-mono)` on it for
29 //! the same reason without being able to say so.
30
31 use quasi_declare::declare;
32 use quasi_router::RegionKind;
33 use quasi_webview::Webview;
34
35 use crate::routes::git::notes_view::CommitNote;
36
37 /// The panel's own region, and what a link to the notes on a page aims at.
38 ///
39 /// `notes` rather than a longer name because the shipped markup used it as the
40 /// fragment target and links off the notes index point at it.
41 pub const REGION: &str = "notes";
42
43 /// Where one note's rendered markdown lands.
44 ///
45 /// By namespace, which is unique within an object: a note is one blob per
46 /// namespace, so two notes on a page cannot share this. Slugged first, because
47 /// a namespace carries slashes (`mnw/builds`) and this becomes an element id:
48 /// a slash there is legal HTML and is an escape every selector and every URL
49 /// fragment that reaches for it has to remember.
50 #[must_use]
51 pub fn body_region(namespace: &str) -> String {
52 format!("note-body-{}", slug(namespace))
53 }
54
55 /// A namespace as an element id can carry it. See [`body_region`].
56 fn slug(namespace: &str) -> String {
57 namespace
58 .chars()
59 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
60 .collect()
61 }
62
63 declare! {
64 /// The panel, for a screen that owns its whole document.
65 ///
66 /// `None` when there is nothing to show. The shipped partial wrapped itself
67 /// in `{{% if !notes.is_empty() %}}`, and an empty panel here would be a
68 /// bordered box with nothing in it rather than the absence the page wants.
69 #[must_use]
70 pub shape region(notes: &[CommitNote]) -> Option<Node>;
71
72 region REGION as Group unless notes.is_empty() {
73 for note in notes {
74 include one(note);
75 }
76 }
77 }
78
79 /// The markup, for an Askama template to drop in.
80 ///
81 /// Pays its own handovers, which is what separates this from [`region`]: a
82 /// described screen fills them from its own renderer ([`fill`]), and a template
83 /// has no renderer to fill from.
84 #[must_use]
85 pub fn html(notes: &[CommitNote]) -> String {
86 use quasi_axum::Serves as _;
87
88 let Some(node) = region(notes) else {
89 return String::new();
90 };
91
92 // No shell: a fragment landing inside a document Askama already built.
93 fill(Webview::new(), notes).fragment(&node)
94 }
95
96 /// Pay every note's handover on a renderer.
97 ///
98 /// Separate from [`region`] because a screen builds its description once and
99 /// its renderer once, and the two are not the same call. `src/quasi/project.rs`
100 /// makes the same pair.
101 #[must_use]
102 pub fn fill(mut webview: Webview, notes: &[CommitNote]) -> Webview {
103 for note in notes {
104 // The app's own classes, which is what a handover is: the region is a
105 // place and the markup in it is this host's. `git-note-body` and
106 // `git-readme-body` are the rules the shipped panel wrote and the
107 // annotations panel still writes, so a note and an annotation stay one
108 // typography rather than two.
109 webview = webview.with_fill(
110 body_region(&note.namespace),
111 format!(
112 "<div class=\"git-note-body git-readme-body\">{}</div>",
113 note.html
114 ),
115 );
116 }
117 webview
118 }
119
120 /// Who last edited the note, or the bound the walk stopped at.
121 ///
122 /// A supplier because the line is decided by `exact`, which is a field inside
123 /// an `Option` the form has no binding pattern to reach. Empty when there is no
124 /// attribution, which is what R9 asks of a supplier: the value is built whether
125 /// or not the guard places it.
126 ///
127 /// `exact` is the load-bearing field. When the walk hit its budget the commit
128 /// it stopped at is not the one that made the edit, so the line names a bound
129 /// and never a person.
130 fn attribution(note: &CommitNote) -> String {
131 let Some(attribution) = &note.attribution else {
132 return String::new();
133 };
134 if attribution.exact {
135 format!("{} - {}", attribution.by, attribution.when)
136 } else {
137 format!(
138 "edited since {} - {}",
139 attribution.short_commit, attribution.when
140 )
141 }
142 }
143
144 declare! {
145 /// One note: what it is, where it came from, and what it says.
146 ///
147 /// The header is one line: a name on the left and a provenance on the
148 /// right. Wrapping keeps both when there is no room for one line, which is
149 /// what the shipped rule's `flex-wrap` did. A note nobody on the repo wrote
150 /// says so, so it does not read as one of theirs they have forgotten.
151 shape one(note: &CommitNote) -> Slot;
152
153 region "note-{slug(&note.namespace)}" as Group {
154 region "note-header-{slug(&note.namespace)}" as Group {
155 across Wrap {
156 beside Essential literal &note.namespace;
157 beside Secondary text "written by makenot.work" when note.read_only;
158 beside Secondary text attribution(note) when note.attribution.is_some();
159 }
160 }
161
162 region body_region(&note.namespace) as RegionKind::handover("a rendered note") {}
163 }
164 }
165
166 #[cfg(test)]
167 mod tests {
168 use super::*;
169 use crate::routes::git::notes_view::NoteAttribution;
170
171 fn note(namespace: &str, read_only: bool, attribution: Option<NoteAttribution>) -> CommitNote {
172 CommitNote {
173 namespace: namespace.into(),
174 html: format!("<p>the note in {namespace}</p>"),
175 source: format!("the note in {namespace}"),
176 attribution,
177 read_only,
178 }
179 }
180
181 fn attributed(exact: bool) -> NoteAttribution {
182 NoteAttribution {
183 short_commit: "abc1234".into(),
184 by: "ada".into(),
185 when: "3 days ago".into(),
186 exact,
187 }
188 }
189
190 /// The panel is the absence when there is nothing in it, which is what the
191 /// shipped `{% if !notes.is_empty() %}` said.
192 #[test]
193 fn no_notes_is_no_panel() {
194 assert!(region(&[]).is_none());
195 assert_eq!(html(&[]), "");
196 }
197
198 /// The rendered markdown reaches the page, through the handover rather than
199 /// through the description.
200 #[test]
201 fn the_rendered_note_is_what_the_reader_gets() {
202 let html = html(&[note("commits", false, None)]);
203
204 assert!(html.contains("<p>the note in commits</p>"), "{html}");
205 assert!(html.contains("git-note-body"), "{html}");
206 }
207
208 /// A namespace carries slashes and lands in an element id, so it is slugged
209 /// on the way.
210 #[test]
211 fn a_namespace_with_a_slash_is_still_an_id() {
212 let html = html(&[note("mnw/builds", true, None)]);
213
214 assert!(html.contains("id=\"note-body-mnw-builds\""), "{html}");
215 assert!(!html.contains("id=\"note-body-mnw/builds\""), "{html}");
216 // The name itself is unchanged where it is read.
217 assert!(html.contains("mnw/builds"), "{html}");
218 }
219
220 /// A note nobody on the repo wrote says so, and one they did does not.
221 #[test]
222 fn a_note_written_here_says_where_it_came_from() {
223 assert!(html(&[note("mnw/builds", true, None)]).contains("written by makenot.work"));
224 assert!(!html(&[note("commits", false, None)]).contains("written by makenot.work"));
225 }
226
227 /// When the walk hit its budget the commit it stopped at is not the one
228 /// that made the edit, so the line names a bound and never a person.
229 #[test]
230 fn an_inexact_attribution_names_no_one() {
231 let exact = html(&[note("commits", false, Some(attributed(true)))]);
232 assert!(exact.contains("ada"), "{exact}");
233
234 let inexact = html(&[note("commits", false, Some(attributed(false)))]);
235 assert!(inexact.contains("edited since abc1234"), "{inexact}");
236 assert!(!inexact.contains("ada"), "{inexact}");
237 }
238
239 /// Each note is its own region, under the id the panel builds from the
240 /// namespace. Two notes on one page are two boxes, and the header is a box
241 /// inside each of them.
242 #[test]
243 fn every_note_is_its_own_region() {
244 let html = html(&[
245 note("commits", false, None),
246 note("mnw/builds", false, None),
247 ]);
248
249 assert!(html.contains("id=\"note-commits\""), "{html}");
250 assert!(html.contains("id=\"note-header-commits\""), "{html}");
251 assert!(html.contains("id=\"note-mnw-builds\""), "{html}");
252 assert!(html.contains("id=\"note-header-mnw-builds\""), "{html}");
253 }
254
255 /// The namespace is machine text in a line of reading, which is what the
256 /// inline code member is for.
257 #[test]
258 fn the_namespace_is_monospace() {
259 let html = html(&[note("commits", false, None)]);
260
261 assert!(
262 html.contains("<code class=\"code\">commits</code>"),
263 "{html}"
264 );
265 }
266 }
267