oxedyne/fe2o3/fe2o3_steel/src/srv/publish/declare.rs
29.0 KiB, 129 runs
created by r1870400018:21500, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! How much a work needed AI, declared in a mark a reader can see. |
| 2 | //! |
| 3 | //! A voluntary declaration, not provenance and not detection: it asserts nothing a machine could |
| 4 | //! check, and is worth exactly what the reader thinks the declarer's word is worth. What the module |
| 5 | //! does is put the claim where a person meets the work -- beside a post, beside a book, in the site's |
| 6 | //! own footer -- and link it to the scheme that defines the words. |
| 7 | //! |
| 8 | //! # The ladder |
| 9 | //! |
| 10 | //! Five rungs, from *AI was unnecessary* to *the human was unnecessary*. The question each one |
| 11 | //! answers is how much the work **needed** a model, not what share of it a model produced: a share is |
| 12 | //! a count nobody performs, and the answer that matters to a reader is whether the work could have |
| 13 | //! existed without the machine. |
| 14 | //! |
| 15 | //! # No scheme is named here |
| 16 | //! |
| 17 | //! The words are ordinary English and the ladder is a public one, but the site that defines them, and |
| 18 | //! the artwork that draws them, are a particular scheme's. Both are configuration |
| 19 | //! ([`DeclareConfig::url`] and [`DeclareConfig::marks`]), so this engine serves a site declaring |
| 20 | //! under any such scheme, and a site that configures none draws nothing at all. |
| 21 | //! |
| 22 | //! # The size rule is structural |
| 23 | //! |
| 24 | //! A level is read by counting the pins around the mark, and the count stops being possible as the |
| 25 | //! mark shrinks -- at half size two neighbouring levels are the same picture, and a mark too small to |
| 26 | //! read does not *look* broken, which is what makes it worse than no mark. So a mark drawn below |
| 27 | //! [`MARK_MIN_PX`] carries its declaration in words beside it, and [`Size::alone`] will not return a |
| 28 | //! wordless mark below that size however small a caller asks for. |
| 29 | //! |
| 30 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 31 | //! Anthropic Claude |
| 32 | |
| 33 | use crate::srv::cache; |
| 34 | |
| 35 | use oxedyne_fe2o3_core::prelude::*; |
| 36 | use oxedyne_fe2o3_jdat::prelude::*; |
| 37 | use oxedyne_fe2o3_jdat::string::enc::EncoderConfig; |
| 38 | use oxedyne_fe2o3_net::http::{ |
| 39 | fields::{ |
| 40 | HeaderFieldValue, |
| 41 | HeaderName, |
| 42 | }, |
| 43 | msg::HttpMessage, |
| 44 | }; |
| 45 | use oxedyne_fe2o3_text::doc::html::{ |
| 46 | escape_attr, |
| 47 | escape_text, |
| 48 | }; |
| 49 | |
| 50 | use std::collections::BTreeMap; |
| 51 | |
| 52 | |
| 53 | // The smallest a mark may be drawn without its words, in CSS pixels. Below this the pins are no |
| 54 | // longer countable, so the level is no longer readable, so the words come with it. See the module |
| 55 | // note: this is the one number in the scheme that a renderer must not treat as advice. |
| 56 | pub const MARK_MIN_PX: u32 = 40; |
| 57 | |
| 58 | // The height a mark is drawn at, in CSS pixels, wherever this module draws one. Above MARK_MIN_PX |
| 59 | // on purpose: the floor is where a level stops being readable at all, which is a bad place to sit, |
| 60 | // leaving nothing for a cheap screen, a low zoom or a reader who is not looking closely. One size |
| 61 | // everywhere also means a mark is the same object on a card, on a post and in a footer, rather |
| 62 | // than a family of sizes to be judged one placement at a time. |
| 63 | pub const MARK_SIZE_PX: u32 = 48; |
| 64 | |
| 65 | // The marks are line drawings, and a line drawing that has to sit at a byline and on a poster is a |
| 66 | // vector. |
| 67 | const MARK_EXT: &str = ".svg"; |
| 68 | |
| 69 | |
| 70 | /// How much a work needed AI. |
| 71 | /// |
| 72 | /// The ladder turns on questions a declarer can answer honestly about their own work: was any used at |
| 73 | /// all; could **you** have done it; does subtracting the AI leave a lesser work or none at all; was a |
| 74 | /// person needed to produce it rather than to direct it. |
| 75 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 76 | pub enum Level { |
| 77 | No, // none was used |
| 78 | Some, // it helped, but the declarer could have done this without it |
| 79 | With, // parts of this could not have been done without it |
| 80 | Mostly, // without AI there would be no work at all |
| 81 | Entirely, // no person was needed to produce it |
| 82 | } |
| 83 | |
| 84 | impl Level { |
| 85 | |
| 86 | // Every rung, in order, weakest claim on AI first. What a chooser offers and what a test walks. |
| 87 | pub const ALL: [Self; 5] = [Self::No, Self::Some, Self::With, Self::Mostly, Self::Entirely]; |
| 88 | |
| 89 | /// The word a record stores and a URL carries. |
| 90 | /// |
| 91 | /// **These never change.** They are printed into the scheme's badge codes, so a slug is a |
| 92 | /// permanent name rather than a spelling this module is free to tidy. |
| 93 | pub fn slug(&self) -> &'static str { |
| 94 | match self { |
| 95 | Self::No => "no-ai", |
| 96 | Self::Some => "some-ai", |
| 97 | Self::With => "with-ai", |
| 98 | Self::Mostly => "mostly-ai", |
| 99 | Self::Entirely => "entirely-ai", |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /// The declaration in words, as a reader is shown it. |
| 104 | /// |
| 105 | /// Note the word order of the fourth rung: a work is made **mostly with** AI, not made with mostly |
| 106 | /// AI. The first says how much the making leaned on the machine, which is the claim; the second |
| 107 | /// says something about the machine, which is not. |
| 108 | pub fn words(&self) -> &'static str { |
| 109 | match self { |
| 110 | Self::No => "Made with no AI", |
| 111 | Self::Some => "Made with some AI", |
| 112 | Self::With => "Made with AI", |
| 113 | Self::Mostly => "Made mostly with AI", |
| 114 | Self::Entirely => "Made with AI entirely", |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// The rung a slug names, or nothing where it names none. |
| 119 | /// |
| 120 | /// **An unknown word is not a level**, deliberately: every other reading would put a declaration on |
| 121 | /// a work whose author did not make one, and a declaration nobody made is the one output this |
| 122 | /// module must never produce. Undeclared is a state, not a failure. |
| 123 | pub fn of(s: &str) -> Option<Self> { |
| 124 | Self::ALL.iter().find(|l| l.slug() == s).copied() |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /// What kind of work is being declared for. |
| 129 | /// |
| 130 | /// A work is often several at once -- a book has a cover and a text, a film has a picture and a score |
| 131 | /// -- and the honest answer can differ between them, so the mark says which part it speaks for. The |
| 132 | /// exception is [`Whole`](Self::Whole), for a work that sits at one level throughout. |
| 133 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 134 | pub enum Medium { |
| 135 | Whole, // the work entire, at one level; the scheme's umbrella mark |
| 136 | Doc, // written prose: a post, a chapter, a document |
| 137 | Code, // software |
| 138 | Image, // a still picture |
| 139 | Audio, // sound |
| 140 | Video, // moving picture |
| 141 | } |
| 142 | |
| 143 | impl Medium { |
| 144 | |
| 145 | // Every kind, the whole work first. What a config chooser offers and what a test walks. |
| 146 | pub const ALL: [Self; 6] = |
| 147 | [Self::Whole, Self::Doc, Self::Code, Self::Image, Self::Audio, Self::Video]; |
| 148 | |
| 149 | /// The word a config names it by and a URL carries. The whole work carries none: its declaration |
| 150 | /// is about the work rather than a part of it, and a URL saying `/with-ai` is that claim exactly. |
| 151 | pub fn slug(&self) -> &'static str { |
| 152 | match self { |
| 153 | Self::Whole => "", |
| 154 | Self::Doc => "doc", |
| 155 | Self::Code => "code", |
| 156 | Self::Image => "image", |
| 157 | Self::Audio => "audio", |
| 158 | Self::Video => "video", |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /// The kind a word names, or nothing where it names none. An empty word is the whole work. |
| 163 | pub fn of(s: &str) -> Option<Self> { |
| 164 | Self::ALL.iter().find(|m| m.slug() == s).copied() |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// A declaration: a rung of the ladder, and the part of the work it speaks for. |
| 169 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 170 | pub struct Declaration { |
| 171 | pub level: Level, |
| 172 | pub medium: Medium, |
| 173 | } |
| 174 | |
| 175 | impl Declaration { |
| 176 | |
| 177 | pub fn new(level: Level, medium: Medium) -> Self { |
| 178 | Self { level, medium } |
| 179 | } |
| 180 | |
| 181 | /// The path under the scheme's site that defines this declaration, e.g. `/with-ai/doc`. |
| 182 | /// |
| 183 | /// The web counterpart of the code printed on a badge, and the same address: a reader who wants to |
| 184 | /// know what a mark means follows it to the page that says so, whether they scanned it or clicked |
| 185 | /// it. |
| 186 | pub fn path(&self) -> String { |
| 187 | match self.medium { |
| 188 | Medium::Whole => fmt!("/{}", self.level.slug()), |
| 189 | _ => fmt!("/{}/{}", self.level.slug(), self.medium.slug()), |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// The artwork's file name, without a directory, e.g. `doc-with-ai.svg`. |
| 194 | /// |
| 195 | /// Built from the two permanent slugs rather than from the artwork's own file names, which belong |
| 196 | /// to whoever drew it and have been renamed at least once. A site ships the thirty marks under |
| 197 | /// this rule and the rule is the whole of the contract. |
| 198 | pub fn mark_file(&self) -> String { |
| 199 | match self.medium { |
| 200 | Medium::Whole => fmt!("{}{}", self.level.slug(), MARK_EXT), |
| 201 | _ => fmt!("{}-{}{}", self.medium.slug(), self.level.slug(), MARK_EXT), |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// A named thing on the site that carries a declaration of its own. |
| 207 | /// |
| 208 | /// Posts hold their level in their own record, because a post is written here. This is for everything |
| 209 | /// else a site shows -- a book, a project, a product -- which is authored somewhere else and needs |
| 210 | /// only somewhere to keep the one field, and a place for an admin to set it. |
| 211 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 212 | pub struct Declarable { |
| 213 | pub key: String, // what the site calls it, in the store and in JSON; never shown |
| 214 | pub name: String, // what an admin sees when choosing its level |
| 215 | pub medium: Medium, // decides which mark it wears |
| 216 | } |
| 217 | |
| 218 | /// A site's declaration settings: the scheme it speaks, where its artwork is, what the site says |
| 219 | /// about itself, and what else on the site may be declared for. |
| 220 | /// |
| 221 | /// Absent from a config, every field is empty and [`is_on`](Self::is_on) is false, which draws no mark |
| 222 | /// anywhere. A site declares when it says where the scheme lives and where the artwork is, and not |
| 223 | /// before -- a mark whose artwork 404s is worse than no mark, and a mark linking nowhere explains |
| 224 | /// nothing. |
| 225 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 226 | pub struct DeclareConfig { |
| 227 | // The scheme's own site and where the artwork is served from, both without a trailing slash. A |
| 228 | // mark links to the first, with the declaration's own path after it. |
| 229 | pub url: String, // e.g. `https://example.org` |
| 230 | pub marks: String, // e.g. `/assets/marks` |
| 231 | // What the site says about itself, drawn in its footer. Nothing where the site declares nothing |
| 232 | // about itself, which is not the same as declaring that it used none. |
| 233 | pub site: Option<Declaration>, |
| 234 | pub items: Vec<Declarable>, // what an admin may set a level for, in the order offered |
| 235 | } |
| 236 | |
| 237 | impl DeclareConfig { |
| 238 | |
| 239 | pub fn is_on(&self) -> bool { |
| 240 | !self.url.is_empty() && !self.marks.is_empty() |
| 241 | } |
| 242 | |
| 243 | pub fn item(&self, key: &str) -> Option<&Declarable> { |
| 244 | self.items.iter().find(|i| i.key == key) |
| 245 | } |
| 246 | |
| 247 | /// Reads the block a config writes. |
| 248 | /// |
| 249 | /// ```text |
| 250 | /// "declare": { |
| 251 | /// "url": "https://example.org", |
| 252 | /// "marks": "/assets/marks", |
| 253 | /// "site": "with-ai/code", |
| 254 | /// "items": [ |
| 255 | /// { "key": "widget", "name": "The Widget", "medium": "doc" } |
| 256 | /// ] |
| 257 | /// } |
| 258 | /// ``` |
| 259 | /// |
| 260 | /// A level or a medium this module does not know is an error rather than a silent default: a |
| 261 | /// mistyped rung would otherwise publish a claim the operator did not make. |
| 262 | pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> { |
| 263 | let get_str = |key: &str| -> Outcome<String> { |
| 264 | match m.get(&dat!(key)) { |
| 265 | Some(Dat::Str(s)) => Ok(s.clone()), |
| 266 | None => Ok(String::new()), |
| 267 | _ => Err(err!( |
| 268 | "DeclareConfig: '{}' must be a string.", key; |
| 269 | Invalid, Input, Mismatch)), |
| 270 | } |
| 271 | }; |
| 272 | |
| 273 | // Both are prefixes something is appended to, so a trailing slash would double the one in the |
| 274 | // path that follows. |
| 275 | let mut url = res!(get_str("url")); |
| 276 | while url.ends_with('/') { |
| 277 | url.pop(); |
| 278 | } |
| 279 | let mut marks = res!(get_str("marks")); |
| 280 | while marks.ends_with('/') { |
| 281 | marks.pop(); |
| 282 | } |
| 283 | |
| 284 | let site_str = res!(get_str("site")); |
| 285 | let site = if site_str.is_empty() { |
| 286 | None |
| 287 | } else { |
| 288 | Some(res!(parse_declaration(&site_str))) |
| 289 | }; |
| 290 | |
| 291 | let items = match m.get(&dat!("items")) { |
| 292 | Some(Dat::List(list)) => res!(declarables(list)), |
| 293 | Some(Dat::Vek(vek)) => res!(declarables(vek.as_slice())), |
| 294 | None => Vec::new(), |
| 295 | _ => return Err(err!( |
| 296 | "DeclareConfig: 'items' must be a list of maps."; |
| 297 | Invalid, Input, Mismatch)), |
| 298 | }; |
| 299 | |
| 300 | Ok(Self { url, marks, site, items }) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | /// A declaration written as one word, `<level>` or `<level>/<medium>`. |
| 305 | /// |
| 306 | /// One field rather than two, because the two are never usefully set apart: a level with no medium is |
| 307 | /// a declaration about the whole work, which the grammar says by leaving the medium off. |
| 308 | fn parse_declaration(s: &str) -> Outcome<Declaration> { |
| 309 | let (level_str, medium_str) = match s.split_once('/') { |
| 310 | Some((l, m)) => (l, m), |
| 311 | None => (s, ""), |
| 312 | }; |
| 313 | let level = res!(Level::of(level_str).ok_or_else(|| err!( |
| 314 | "DeclareConfig: '{}' is not a declaration level. The levels are {}.", |
| 315 | level_str, slug_list(); Invalid, Input))); |
| 316 | let medium = res!(Medium::of(medium_str).ok_or_else(|| err!( |
| 317 | "DeclareConfig: '{}' is not a kind of work.", medium_str; Invalid, Input))); |
| 318 | Ok(Declaration { level, medium }) |
| 319 | } |
| 320 | |
| 321 | /// Every level's slug, for the error a mistyped one raises. An operator who typed the wrong word is |
| 322 | /// owed the list of right ones. |
| 323 | fn slug_list() -> String { |
| 324 | Level::ALL.iter().map(|l| l.slug()).collect::<Vec<_>>().join(", ") |
| 325 | } |
| 326 | |
| 327 | fn declarables(items: &[Dat]) -> Outcome<Vec<Declarable>> { |
| 328 | let mut out = Vec::new(); |
| 329 | for item in items { |
| 330 | let m = match item { |
| 331 | Dat::Map(m) => m, |
| 332 | _ => return Err(err!( |
| 333 | "DeclareConfig: every 'items' entry must be a map."; |
| 334 | Invalid, Input, Mismatch)), |
| 335 | }; |
| 336 | let field = |key: &str| -> Outcome<String> { |
| 337 | match m.get(&dat!(key)) { |
| 338 | Some(Dat::Str(s)) => Ok(s.clone()), |
| 339 | None => Ok(String::new()), |
| 340 | _ => Err(err!( |
| 341 | "DeclareConfig: an item's '{}' must be a string.", key; |
| 342 | Invalid, Input, Mismatch)), |
| 343 | } |
| 344 | }; |
| 345 | let key = res!(field("key")); |
| 346 | if key.is_empty() { |
| 347 | return Err(err!( |
| 348 | "DeclareConfig: every declarable item needs a 'key', which is how its level is \ |
| 349 | stored and how a page asks for it."; Invalid, Input, Missing)); |
| 350 | } |
| 351 | let name = res!(field("name")); |
| 352 | let medium_str = res!(field("medium")); |
| 353 | let medium = res!(Medium::of(&medium_str).ok_or_else(|| err!( |
| 354 | "DeclareConfig: '{}' is not a kind of work, for item '{}'.", medium_str, key; |
| 355 | Invalid, Input))); |
| 356 | out.push(Declarable { |
| 357 | // A name nobody set reads as the key, which is at least a word an admin recognises. |
| 358 | name: if name.is_empty() { key.clone() } else { name }, |
| 359 | key, |
| 360 | medium, |
| 361 | }); |
| 362 | } |
| 363 | Ok(out) |
| 364 | } |
| 365 | |
| 366 | /// How a mark is set on the page. |
| 367 | /// |
| 368 | /// The two are not interchangeable and the difference is not taste: see [`MARK_MIN_PX`]. |
| 369 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 370 | pub enum Size { |
| 371 | Alone(u32), // CSS pixels, at least MARK_MIN_PX |
| 372 | // The mark at the given size with its declaration in words beside it. Words are not only for a |
| 373 | // mark too small to read: a footer saying what a whole site is has room for the sentence and a |
| 374 | // reason to spell it out; a mark beside a reading time does not, and the words there would say |
| 375 | // the same thing on every card. |
| 376 | WithWords(u32), |
| 377 | } |
| 378 | |
| 379 | impl Size { |
| 380 | |
| 381 | /// A mark alone at the given size, **or with its words where that size is too small to read**. |
| 382 | /// |
| 383 | /// The rule made structural: a caller asking for a wordless mark at 16 px does not get one, it |
| 384 | /// gets a legible declaration. There is no way to spell the illegible arrangement. |
| 385 | pub fn alone(px: u32) -> Self { |
| 386 | if px < MARK_MIN_PX { |
| 387 | Self::WithWords(px) |
| 388 | } else { |
| 389 | Self::Alone(px) |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | /// The mark for a declaration, as HTML: a link to the scheme, wearing the artwork. |
| 395 | /// |
| 396 | /// Empty where the site declares nothing ([`DeclareConfig::is_on`]), so every caller can ask |
| 397 | /// unconditionally and a site that has not configured the scheme simply has no marks. |
| 398 | /// |
| 399 | /// # Why the artwork is a mask and not a picture |
| 400 | /// |
| 401 | /// The mark has to sit on a dark site and a light one and read as the site's own furniture, and the |
| 402 | /// artwork is one set of files drawn in black. So it is used as a **mask** over `currentColor`: the |
| 403 | /// shape comes from the file, the colour from whatever the surrounding text is. One asset, every |
| 404 | /// palette, and no per-site copy of the artwork to keep in step. |
| 405 | /// |
| 406 | /// The mask carries no meaning to a reader who cannot see it, so the accessible name is on the link, |
| 407 | /// and the shape itself is hidden from assistive technology rather than announced as an image with no |
| 408 | /// description. |
| 409 | pub fn mark_html(cfg: &DeclareConfig, decl: Declaration, size: Size, class: &str) -> String { |
| 410 | if !cfg.is_on() { |
| 411 | return String::new(); |
| 412 | } |
| 413 | let mut s = String::new(); |
| 414 | s.push_str("<a class=\"ai-mark"); |
| 415 | match size { |
| 416 | Size::Alone(_) => s.push_str(" ai-mark-alone"), |
| 417 | Size::WithWords(_) => s.push_str(" ai-mark-inline"), |
| 418 | } |
| 419 | if !class.is_empty() { |
| 420 | s.push(' '); |
| 421 | escape_attr(&mut s, class); |
| 422 | } |
| 423 | s.push_str("\" href=\""); |
| 424 | escape_attr(&mut s, &fmt!("{}{}", cfg.url, decl.path())); |
| 425 | // A reader following a mark has not finished with the page they were reading, and the scheme is a |
| 426 | // third-party site: a new tab, and no window handle back to this one. |
| 427 | s.push_str("\" target=\"_blank\" rel=\"noopener\" title=\""); |
| 428 | escape_attr(&mut s, decl.level.words()); |
| 429 | s.push_str("\" aria-label=\""); |
| 430 | escape_attr(&mut s, decl.level.words()); |
| 431 | s.push_str("\">"); |
| 432 | |
| 433 | // The shape. Its size rides in a custom property rather than a class per size, because the sizes |
| 434 | // are a placement decision and the stylesheet should not have to grow a class for each one. |
| 435 | s.push_str("<span class=\"ai-mark-ink\" aria-hidden=\"true\" style=\""); |
| 436 | match size { |
| 437 | Size::Alone(px) | Size::WithWords(px) => s.push_str(&fmt!("--ai-mark-size:{}px;", px)), |
| 438 | } |
| 439 | let url = fmt!("{}/{}", cfg.marks, decl.mark_file()); |
| 440 | s.push_str(&fmt!( |
| 441 | "-webkit-mask-image:url('{0}');mask-image:url('{0}')", css_url(&url))); |
| 442 | s.push_str("\"></span>"); |
| 443 | |
| 444 | // The words, where the caller asked for them. |
| 445 | if matches!(size, Size::WithWords(_)) { |
| 446 | s.push_str("<span class=\"ai-mark-words\">"); |
| 447 | escape_text(&mut s, decl.level.words()); |
| 448 | s.push_str("</span>"); |
| 449 | } |
| 450 | s.push_str("</a>"); |
| 451 | s |
| 452 | } |
| 453 | |
| 454 | /// A URL fit to sit inside a CSS `url('…')` in a style attribute. |
| 455 | /// |
| 456 | /// Two escapes, not one: the attribute is escaped on the way out by the caller's `escape_attr`, but |
| 457 | /// the CSS string inside it has its own quoting, and a value carrying a quote or a backslash would |
| 458 | /// otherwise close the string and leave the rest as declarations. The paths this takes are built from |
| 459 | /// config, and config is not a trusted source of syntax. |
| 460 | fn css_url(url: &str) -> String { |
| 461 | let mut out = String::new(); |
| 462 | for c in url.chars() { |
| 463 | match c { |
| 464 | '\\' | '\'' | '"' => { |
| 465 | out.push('\\'); |
| 466 | out.push(c); |
| 467 | }, |
| 468 | // A newline would end the declaration; a parenthesis would end the `url()`. |
| 469 | '\n' | '\r' | '(' | ')' => {}, |
| 470 | _ => out.push(c), |
| 471 | } |
| 472 | } |
| 473 | // The attribute's own escaping happens where this is written into one. |
| 474 | out.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 475 | } |
| 476 | |
| 477 | /// Serves the site's declarations as JSON, for a page that draws its own. |
| 478 | /// |
| 479 | /// Everything resolved -- the words, the artwork's URL, the link -- rather than the two slugs and a |
| 480 | /// rule to apply to them. There is one rule for how a mark is built and it lives here; a client |
| 481 | /// reimplementing it is a second rule that will drift from this one. |
| 482 | /// |
| 483 | /// An item the admin has not set a level for carries no `level` key at all, the empty idiom the rest |
| 484 | /// of this module keeps: absent and undeclared are the same thing said once. |
| 485 | pub fn serve_json( |
| 486 | cfg: &DeclareConfig, |
| 487 | levels: &BTreeMap<String, Level>, |
| 488 | id: &str, |
| 489 | ) |
| 490 | -> Outcome<HttpMessage> |
| 491 | { |
| 492 | let resolved = |d: Declaration| -> Vec<(Dat, Dat)> { |
| 493 | vec![ |
| 494 | (dat!("level"), dat!(d.level.slug().to_string())), |
| 495 | (dat!("medium"), dat!(d.medium.slug().to_string())), |
| 496 | (dat!("words"), dat!(d.level.words().to_string())), |
| 497 | (dat!("mark"), dat!(fmt!("{}/{}", cfg.marks, d.mark_file()))), |
| 498 | (dat!("href"), dat!(fmt!("{}{}", cfg.url, d.path()))), |
| 499 | ] |
| 500 | }; |
| 501 | |
| 502 | // The ladder itself, so a page drawing a chooser -- a composer, an admin panel -- offers exactly |
| 503 | // the rungs this version knows. Without it every client keeps its own copy of the five, and a copy |
| 504 | // is a thing that drifts. |
| 505 | let vocabulary = Level::ALL.iter() |
| 506 | .map(|l| create_dat_ordmap(vec![ |
| 507 | (dat!("level"), dat!(l.slug().to_string())), |
| 508 | (dat!("words"), dat!(l.words().to_string())), |
| 509 | ])) |
| 510 | .collect::<Vec<_>>(); |
| 511 | |
| 512 | let mut fields = vec![ |
| 513 | (dat!("url"), dat!(cfg.url.clone())), |
| 514 | (dat!("marks"), dat!(cfg.marks.clone())), |
| 515 | (dat!("levels"), Dat::List(vocabulary)), |
| 516 | ]; |
| 517 | // What the site says about itself. Absent where it says nothing. |
| 518 | if let Some(d) = cfg.site { |
| 519 | fields.push((dat!("site"), create_dat_ordmap(resolved(d)))); |
| 520 | } |
| 521 | let items = cfg.items.iter() |
| 522 | .map(|item| { |
| 523 | let mut f = vec![ |
| 524 | (dat!("key"), dat!(item.key.clone())), |
| 525 | (dat!("name"), dat!(item.name.clone())), |
| 526 | (dat!("medium"), dat!(item.medium.slug().to_string())), |
| 527 | ]; |
| 528 | if let Some(level) = levels.get(&item.key) { |
| 529 | f.extend(resolved(Declaration::new(*level, item.medium))); |
| 530 | } |
| 531 | create_dat_ordmap(f) |
| 532 | }) |
| 533 | .collect::<Vec<_>>(); |
| 534 | fields.push((dat!("items"), Dat::List(items))); |
| 535 | |
| 536 | let json_cfg = EncoderConfig::<(), ()>::json(None); |
| 537 | let body_json = res!(create_dat_ordmap(fields).encode_string_with_config(&json_cfg)); |
| 538 | |
| 539 | info!("{}: publish: declarations, {} item(s)", id, cfg.items.len()); |
| 540 | |
| 541 | let mut resp = HttpMessage::ok_respond_with_text(body_json); |
| 542 | resp = resp.with_field( |
| 543 | HeaderName::ContentType, |
| 544 | HeaderFieldValue::Generic(fmt!("application/json")), |
| 545 | ); |
| 546 | // A level changed in the console must show on the site at once. A page holding yesterday's copy |
| 547 | // would draw a declaration its author has since corrected, which is the one staleness that matters |
| 548 | // here. |
| 549 | Ok(cache::generated(resp)) |
| 550 | } |
| 551 | |
| 552 | |
| 553 | #[cfg(test)] |
| 554 | mod tests { |
| 555 | use super::*; |
| 556 | |
| 557 | fn cfg() -> DeclareConfig { |
| 558 | DeclareConfig { |
| 559 | url: fmt!("https://example.org"), |
| 560 | marks: fmt!("/assets/marks"), |
| 561 | site: Some(Declaration::new(Level::With, Medium::Code)), |
| 562 | items: vec![Declarable { |
| 563 | key: fmt!("widget"), |
| 564 | name: fmt!("The Widget"), |
| 565 | medium: Medium::Doc, |
| 566 | }], |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | /// The slugs are printed into badge codes and cannot be revised. Pinned here so a tidy-up of the |
| 571 | /// spelling fails a test rather than breaking every code already in the world. |
| 572 | #[test] |
| 573 | fn test_the_slugs_are_permanent_00() -> Outcome<()> { |
| 574 | let got = Level::ALL.iter().map(|l| l.slug()).collect::<Vec<_>>(); |
| 575 | assert_eq!(got, vec!["no-ai", "some-ai", "with-ai", "mostly-ai", "entirely-ai"]); |
| 576 | // Round trip: every slug names back the rung it came from, and nothing else does. |
| 577 | for l in Level::ALL { |
| 578 | assert_eq!(Level::of(l.slug()), Some(l), "'{}' did not read back", l.slug()); |
| 579 | } |
| 580 | assert_eq!(Level::of("mostly"), None, "a partial word named a level"); |
| 581 | assert_eq!(Level::of(""), None, "an empty word named a level"); |
| 582 | Ok(()) |
| 583 | } |
| 584 | |
| 585 | /// The fourth rung says a work was made *mostly with* AI. The other word order says something |
| 586 | /// about the machine rather than about the making, and is not the claim. |
| 587 | #[test] |
| 588 | fn test_the_fourth_rung_keeps_its_word_order_01() -> Outcome<()> { |
| 589 | assert_eq!(Level::Mostly.words(), "Made mostly with AI"); |
| 590 | Ok(()) |
| 591 | } |
| 592 | |
| 593 | /// A declaration addresses the scheme's page for it, and wears the artwork named by the same two |
| 594 | /// slugs. The whole work carries no medium in either. |
| 595 | #[test] |
| 596 | fn test_a_declaration_addresses_its_page_and_its_artwork_02() -> Outcome<()> { |
| 597 | let d = Declaration::new(Level::With, Medium::Doc); |
| 598 | assert_eq!(d.path(), "/with-ai/doc"); |
| 599 | assert_eq!(d.mark_file(), "doc-with-ai.svg"); |
| 600 | let w = Declaration::new(Level::Entirely, Medium::Whole); |
| 601 | assert_eq!(w.path(), "/entirely-ai"); |
| 602 | assert_eq!(w.mark_file(), "entirely-ai.svg"); |
| 603 | Ok(()) |
| 604 | } |
| 605 | |
| 606 | /// A wordless mark below the countable size is not a thing this module can be asked to draw. The |
| 607 | /// caller asking for one gets the legible arrangement instead. |
| 608 | #[test] |
| 609 | fn test_a_small_mark_cannot_lose_its_words_03() -> Outcome<()> { |
| 610 | assert_eq!(Size::alone(MARK_MIN_PX), Size::Alone(MARK_MIN_PX)); |
| 611 | assert_eq!(Size::alone(MARK_SIZE_PX), Size::Alone(MARK_SIZE_PX)); |
| 612 | assert_eq!(Size::alone(MARK_MIN_PX - 1), Size::WithWords(MARK_MIN_PX - 1), |
| 613 | "a mark went wordless below the floor"); |
| 614 | assert_eq!(Size::alone(16), Size::WithWords(16)); |
| 615 | // What the module actually draws sits above the floor rather than on it. |
| 616 | assert!(MARK_SIZE_PX >= MARK_MIN_PX, "the drawn size is below the readable floor"); |
| 617 | |
| 618 | // And the words really are drawn in that arrangement, not merely chosen. |
| 619 | let html = mark_html(&cfg(), Declaration::new(Level::Some, Medium::Doc), Size::alone(16), ""); |
| 620 | assert!(html.contains("Made with some AI"), "the small mark carried no words: {}", html); |
| 621 | assert!(html.contains("ai-mark-inline"), "the small mark is not the inline arrangement: {}", html); |
| 622 | Ok(()) |
| 623 | } |
| 624 | |
| 625 | /// The mark links to the scheme's page for exactly the declaration it draws, wears the artwork as |
| 626 | /// a mask so it takes the site's own colour, and says in words what it is to a reader who cannot |
| 627 | /// see it. |
| 628 | #[test] |
| 629 | fn test_the_mark_links_and_names_itself_04() -> Outcome<()> { |
| 630 | let html = mark_html(&cfg(), Declaration::new(Level::Mostly, Medium::Code), Size::alone(44), "foot-mark"); |
| 631 | assert!(html.contains("href=\"https://example.org/mostly-ai/code\""), "wrong link: {}", html); |
| 632 | assert!(html.contains("mask-image:url('/assets/marks/code-mostly-ai.svg')"), "wrong artwork: {}", html); |
| 633 | assert!(html.contains("-webkit-mask-image"), "no mask for the older engine: {}", html); |
| 634 | assert!(html.contains("aria-label=\"Made mostly with AI\""), "no accessible name: {}", html); |
| 635 | assert!(html.contains("--ai-mark-size:44px"), "the size did not reach the mark: {}", html); |
| 636 | assert!(html.contains("foot-mark"), "the caller's class was dropped: {}", html); |
| 637 | // The shape says nothing to a reader who cannot see it, and must not be announced twice. |
| 638 | assert!(html.contains("aria-hidden=\"true\""), "the shape is not hidden from assistive tech: {}", html); |
| 639 | Ok(()) |
| 640 | } |
| 641 | |
| 642 | /// A site that has not configured the scheme draws nothing at all, rather than a mark whose |
| 643 | /// artwork is missing and whose link goes nowhere. |
| 644 | #[test] |
| 645 | fn test_an_unconfigured_site_draws_no_mark_05() -> Outcome<()> { |
| 646 | let off = DeclareConfig::default(); |
| 647 | assert!(!off.is_on()); |
| 648 | let html = mark_html(&off, Declaration::new(Level::No, Medium::Doc), Size::alone(44), ""); |
| 649 | assert!(html.is_empty(), "an unconfigured site drew a mark: {}", html); |
| 650 | // Half-configured is still off: artwork with no scheme explains nothing, and a scheme with no |
| 651 | // artwork draws nothing. |
| 652 | let half = DeclareConfig { url: fmt!("https://example.org"), ..Default::default() }; |
| 653 | assert!(!half.is_on(), "a site with no artwork declared anyway"); |
| 654 | Ok(()) |
| 655 | } |
| 656 | |
| 657 | /// The config block reads the site's own declaration and its declarable things, and refuses a rung |
| 658 | /// it does not know rather than quietly picking one. |
| 659 | #[test] |
| 660 | fn test_the_config_block_reads_and_refuses_06() -> Outcome<()> { |
| 661 | let m = mapdat!{ |
| 662 | "url" => "https://example.org/", |
| 663 | "marks" => "/assets/marks/", |
| 664 | "site" => "with-ai/code", |
| 665 | "items" => listdat![ |
| 666 | mapdat!{ "key" => "widget", "name" => "The Widget", "medium" => "doc" }, |
| 667 | mapdat!{ "key" => "engine", "medium" => "code" }, |
| 668 | ], |
| 669 | }.get_map().unwrap(); |
| 670 | let c = res!(DeclareConfig::from_datmap(&m)); |
| 671 | // The trailing slashes are gone, or every path built from them would double one. |
| 672 | assert_eq!(c.url, "https://example.org"); |
| 673 | assert_eq!(c.marks, "/assets/marks"); |
| 674 | assert_eq!(c.site, Some(Declaration::new(Level::With, Medium::Code))); |
| 675 | assert_eq!(c.items.len(), 2); |
| 676 | assert_eq!(c.items[0].name, "The Widget"); |
| 677 | // An item with no name of its own is offered under its key, not under nothing. |
| 678 | assert_eq!(c.items[1].name, "engine"); |
| 679 | assert_eq!(c.items[1].medium, Medium::Code); |
| 680 | |
| 681 | let bad = mapdat!{ |
| 682 | "url" => "https://example.org", |
| 683 | "marks" => "/assets/marks", |
| 684 | "site" => "quite-a-lot-of-ai", |
| 685 | }.get_map().unwrap(); |
| 686 | assert!(DeclareConfig::from_datmap(&bad).is_err(), "a level nobody defined was accepted"); |
| 687 | Ok(()) |
| 688 | } |
| 689 | |
| 690 | /// The JSON hands a client the finished mark rather than the parts, and says nothing at all about |
| 691 | /// an item whose level nobody has set. |
| 692 | #[test] |
| 693 | fn test_the_json_resolves_a_mark_and_omits_an_undeclared_one_07() -> Outcome<()> { |
| 694 | let mut levels = BTreeMap::new(); |
| 695 | levels.insert(fmt!("widget"), Level::Some); |
| 696 | let resp = res!(serve_json(&cfg(), &levels, "test")); |
| 697 | let body = String::from_utf8_lossy(&resp.body).to_string(); |
| 698 | assert!(body.contains(r#""mark": "/assets/marks/doc-some-ai.svg""#), "no artwork URL: {}", body); |
| 699 | assert!(body.contains(r#""href": "https://example.org/some-ai/doc""#), "no link: {}", body); |
| 700 | assert!(body.contains(r#""words": "Made with some AI""#), "no words: {}", body); |
| 701 | // The site's own declaration rides alongside, so a footer needs one fetch and not two. |
| 702 | assert!(body.contains(r#""/assets/marks/code-with-ai.svg""#), "no site mark: {}", body); |
| 703 | |
| 704 | // The vocabulary and the site's own declaration both carry the word `level`, so the item is |
| 705 | // read where it lives rather than by searching the whole body for it. |
| 706 | let resp = res!(serve_json(&cfg(), &BTreeMap::new(), "test")); |
| 707 | let body = String::from_utf8_lossy(&resp.body).to_string(); |
| 708 | let items = res!(body.split_once(r#""items""#).map(|(_, rest)| rest.to_string()) |
| 709 | .ok_or_else(|| err!("the body carried no items at all: {}", body; Missing))); |
| 710 | assert!(items.contains(r#""key": "widget""#), "the declarable itself went missing: {}", body); |
| 711 | assert!(!items.contains(r#""level""#), "an unset item was given a level: {}", body); |
| 712 | // The chooser a client draws is still offered every rung -- an undeclared item is not a site |
| 713 | // that has forgotten what the rungs are. |
| 714 | assert!(body.contains(r#""words": "Made mostly with AI""#), "no vocabulary: {}", body); |
| 715 | Ok(()) |
| 716 | } |
| 717 | |
| 718 | /// A path from config cannot break out of the CSS string it is written into. |
| 719 | #[test] |
| 720 | fn test_an_artwork_path_cannot_escape_its_css_string_08() -> Outcome<()> { |
| 721 | let c = DeclareConfig { |
| 722 | marks: fmt!("/a'); background:url('http://elsewhere/x"), |
| 723 | ..cfg() |
| 724 | }; |
| 725 | let html = mark_html(&c, Declaration::new(Level::No, Medium::Doc), Size::alone(44), ""); |
| 726 | assert!(!html.contains("background:url('http"), "a config path opened a second declaration: {}", html); |
| 727 | Ok(()) |
| 728 | } |
| 729 | } |