|
|
|
@@ -0,0 +1,503 @@
|
|
|
|
|
//! The manual, as a page the application can carry.
|
|
|
|
|
//!
|
|
|
|
|
//! # Why it is rendered at all
|
|
|
|
|
//!
|
|
|
|
|
//! `docs/manual/README.md` is read on the forge, where Markdown is rendered for
|
|
|
|
|
//! free. The application has no forge: an installed copy — on a laptop with no
|
|
|
|
|
//! network, or on a tablet — can only show the manual if it ships as something
|
|
|
|
|
//! a browser opens by itself. One HTML page with its pictures beside it is the
|
|
|
|
|
//! least that does, and it is what the help sheet's "See it" links land on.
|
|
|
|
|
//!
|
|
|
|
|
//! # Why it is committed, like the gesture book
|
|
|
|
|
//!
|
|
|
|
|
//! The same argument `gestures::render_rust` makes, with one more reason. The
|
|
|
|
|
//! page is user-facing text and is reviewed like any: a rendering change shows
|
|
|
|
|
//! in the diff. And the three packagers — makepkg, the Windows container and
|
|
|
|
|
//! the APK assembler — then only *copy* it. Generating it at build time would
|
|
|
|
|
//! mean each of them running this tool, and two of them run in images that do
|
|
|
|
|
//! not build the tool today. CI already fails when a generated artefact drifts
|
|
|
|
|
//! from its source (`manual-check`), so committing costs nothing in freshness.
|
|
|
|
|
//!
|
|
|
|
|
//! # Anchors
|
|
|
|
|
//!
|
|
|
|
|
//! Every heading gets the id the forges give it — lower case, punctuation
|
|
|
|
|
//! dropped, spaces to hyphens, `-1`, `-2` on a repeat — so a link written
|
|
|
|
|
//! against `README.md#rating-and-flagging` on the forge and one written against
|
|
|
|
|
//! the bundled page are the same link. [`anchors`] is the list the gesture
|
|
|
|
|
//! scan checks its `manual:` fields against, computed by the same function the
|
|
|
|
|
//! page is rendered with, so the two cannot disagree about what exists.
|
|
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
use pulldown_cmark::{CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
|
|
|
|
|
|
|
|
|
/// Where a link that leaves the manual's own directory goes.
|
|
|
|
|
///
|
|
|
|
|
/// The bundled page has no `docs/dev/` beside it — only the manual and its
|
|
|
|
|
/// pictures are installed — so a relative link to a design document would be
|
|
|
|
|
/// a dead link on every installed copy. It goes to the forge instead, which
|
|
|
|
|
/// is where the reader of a design document is anyway.
|
|
|
|
|
pub const FORGE_TREE: &str = "https://gitea.tourolle.paris/dtourolle/DarkRoom/src/branch/master/";
|
|
|
|
|
|
|
|
|
|
/// The manual's directory inside the repository, for resolving its links.
|
|
|
|
|
const MANUAL_DIR: &str = "docs/manual";
|
|
|
|
|
|
|
|
|
|
/// One heading of the manual, with the id the page gives it.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct Heading {
|
|
|
|
|
pub level: u8,
|
|
|
|
|
pub text: String,
|
|
|
|
|
pub id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn options() -> Options {
|
|
|
|
|
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn level_number(level: HeadingLevel) -> u8 {
|
|
|
|
|
match level {
|
|
|
|
|
HeadingLevel::H1 => 1,
|
|
|
|
|
HeadingLevel::H2 => 2,
|
|
|
|
|
HeadingLevel::H3 => 3,
|
|
|
|
|
HeadingLevel::H4 => 4,
|
|
|
|
|
HeadingLevel::H5 => 5,
|
|
|
|
|
HeadingLevel::H6 => 6,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The forge's slug for a heading's text.
|
|
|
|
|
///
|
|
|
|
|
/// Letters and digits kept (lower-cased), hyphens and underscores kept, a space
|
|
|
|
|
/// becomes a hyphen, everything else — commas, backticks, colons — is dropped.
|
|
|
|
|
pub fn slug(text: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(text.len());
|
|
|
|
|
for c in text.trim().chars() {
|
|
|
|
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
|
|
|
|
out.extend(c.to_lowercase());
|
|
|
|
|
} else if c == ' ' {
|
|
|
|
|
out.push('-');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every heading in document order, each with a unique id.
|
|
|
|
|
pub fn headings(markdown: &str) -> Vec<Heading> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
|
|
|
|
|
let mut current: Option<(u8, String)> = None;
|
|
|
|
|
for event in Parser::new_ext(markdown, options()) {
|
|
|
|
|
match event {
|
|
|
|
|
Event::Start(Tag::Heading { level, .. }) => {
|
|
|
|
|
current = Some((level_number(level), String::new()));
|
|
|
|
|
}
|
|
|
|
|
Event::Text(t) | Event::Code(t) => {
|
|
|
|
|
if let Some((_, text)) = current.as_mut() {
|
|
|
|
|
text.push_str(&t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Event::End(TagEnd::Heading(_)) => {
|
|
|
|
|
if let Some((level, text)) = current.take() {
|
|
|
|
|
let base = slug(&text);
|
|
|
|
|
let n = seen.entry(base.clone()).or_insert(0);
|
|
|
|
|
let id = if *n == 0 {
|
|
|
|
|
base.clone()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{base}-{n}")
|
|
|
|
|
};
|
|
|
|
|
*n += 1;
|
|
|
|
|
out.push(Heading { level, text, id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every anchor the rendered page has.
|
|
|
|
|
pub fn anchors(markdown: &str) -> Vec<String> {
|
|
|
|
|
headings(markdown).into_iter().map(|h| h.id).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A link as the bundled page needs it.
|
|
|
|
|
///
|
|
|
|
|
/// Absolute URLs, in-page fragments and the manual's own pictures stay as
|
|
|
|
|
/// written. Anything else is a path relative to `docs/manual/` that the
|
|
|
|
|
/// installed page does not have, and becomes the same file on the forge.
|
|
|
|
|
fn rewrite_link(dest: &str) -> String {
|
|
|
|
|
if dest.contains("://") || dest.starts_with('#') || dest.starts_with("mailto:") {
|
|
|
|
|
return dest.to_string();
|
|
|
|
|
}
|
|
|
|
|
if dest.starts_with("media/") {
|
|
|
|
|
return dest.to_string();
|
|
|
|
|
}
|
|
|
|
|
let (path, fragment) = match dest.split_once('#') {
|
|
|
|
|
Some((p, f)) => (p, Some(f)),
|
|
|
|
|
None => (dest, None),
|
|
|
|
|
};
|
|
|
|
|
let mut parts: Vec<&str> = MANUAL_DIR.split('/').collect();
|
|
|
|
|
for seg in path.split('/') {
|
|
|
|
|
match seg {
|
|
|
|
|
"" | "." => {}
|
|
|
|
|
".." => {
|
|
|
|
|
parts.pop();
|
|
|
|
|
}
|
|
|
|
|
s => parts.push(s),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut out = format!("{FORGE_TREE}{}", parts.join("/"));
|
|
|
|
|
if let Some(f) = fragment {
|
|
|
|
|
out.push('#');
|
|
|
|
|
out.push_str(f);
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape(s: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(s.len());
|
|
|
|
|
for c in s.chars() {
|
|
|
|
|
match c {
|
|
|
|
|
'&' => out.push_str("&"),
|
|
|
|
|
'<' => out.push_str("<"),
|
|
|
|
|
'>' => out.push_str(">"),
|
|
|
|
|
'"' => out.push_str("""),
|
|
|
|
|
_ => out.push(c),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The page: `docs/manual/index.html`.
|
|
|
|
|
pub fn render_html(markdown: &str) -> String {
|
|
|
|
|
let heads = headings(markdown);
|
|
|
|
|
let title = heads
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|h| h.level == 1)
|
|
|
|
|
.map(|h| h.text.clone())
|
|
|
|
|
.unwrap_or_else(|| "DarkRoom".into());
|
|
|
|
|
|
|
|
|
|
let events: Vec<Event> = Parser::new_ext(markdown, options()).collect();
|
|
|
|
|
let mut out_events: Vec<Event> = Vec::with_capacity(events.len());
|
|
|
|
|
let mut ids = heads.iter().map(|h| h.id.clone());
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
while i < events.len() {
|
|
|
|
|
match &events[i] {
|
|
|
|
|
Event::Start(Tag::Heading {
|
|
|
|
|
level,
|
|
|
|
|
classes,
|
|
|
|
|
attrs,
|
|
|
|
|
..
|
|
|
|
|
}) => {
|
|
|
|
|
out_events.push(Event::Start(Tag::Heading {
|
|
|
|
|
level: *level,
|
|
|
|
|
id: ids.next().map(CowStr::from),
|
|
|
|
|
classes: classes.clone(),
|
|
|
|
|
attrs: attrs.clone(),
|
|
|
|
|
}));
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
// A paragraph that is one picture and nothing else is a figure,
|
|
|
|
|
// and its alt text — written in this manual as a caption, "Rating
|
|
|
|
|
// two photographs, then filtering…" — is shown as one. Alt text is
|
|
|
|
|
// otherwise only read by a screen reader, and here it is the only
|
|
|
|
|
// sentence saying what a moving picture is doing.
|
|
|
|
|
Event::Start(Tag::Paragraph) => {
|
|
|
|
|
if let Some((html, used)) = figure(&events[i..]) {
|
|
|
|
|
out_events.push(Event::Html(html.into()));
|
|
|
|
|
i += used;
|
|
|
|
|
} else {
|
|
|
|
|
out_events.push(events[i].clone());
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Event::Start(Tag::Link {
|
|
|
|
|
link_type,
|
|
|
|
|
dest_url,
|
|
|
|
|
title,
|
|
|
|
|
id,
|
|
|
|
|
}) => {
|
|
|
|
|
out_events.push(Event::Start(Tag::Link {
|
|
|
|
|
link_type: *link_type,
|
|
|
|
|
dest_url: rewrite_link(dest_url).into(),
|
|
|
|
|
title: title.clone(),
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
}));
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
other => {
|
|
|
|
|
out_events.push(other.clone());
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut body = String::new();
|
|
|
|
|
pulldown_cmark::html::push_html(&mut body, out_events.into_iter());
|
|
|
|
|
|
|
|
|
|
// The contents: each section, and its subsections under it. Level 1 is
|
|
|
|
|
// the page's title and level 4 and below too fine to navigate by.
|
|
|
|
|
let mut groups: Vec<(&Heading, Vec<&Heading>)> = Vec::new();
|
|
|
|
|
for h in &heads {
|
|
|
|
|
match (h.level, groups.last_mut()) {
|
|
|
|
|
(2, _) | (3, None) => groups.push((h, Vec::new())),
|
|
|
|
|
(3, Some((_, children))) => children.push(h),
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let item = |h: &Heading| format!("<a href=\"#{}\">{}</a>", h.id, escape(&h.text));
|
|
|
|
|
let mut toc = String::from("<ul>\n");
|
|
|
|
|
for (h, children) in &groups {
|
|
|
|
|
toc.push_str("<li>");
|
|
|
|
|
toc.push_str(&item(h));
|
|
|
|
|
if !children.is_empty() {
|
|
|
|
|
toc.push_str("\n<ul>\n");
|
|
|
|
|
for c in children {
|
|
|
|
|
toc.push_str(&format!("<li>{}</li>\n", item(c)));
|
|
|
|
|
}
|
|
|
|
|
toc.push_str("</ul>\n");
|
|
|
|
|
}
|
|
|
|
|
toc.push_str("</li>\n");
|
|
|
|
|
}
|
|
|
|
|
toc.push_str("</ul>\n");
|
|
|
|
|
|
|
|
|
|
let mut page = String::new();
|
|
|
|
|
page.push_str("<!DOCTYPE html>\n");
|
|
|
|
|
page.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
|
|
|
|
|
page.push_str(
|
|
|
|
|
"<!-- Source: docs/manual/README.md. Regenerate: cargo run -p traceability -- manual -->\n",
|
|
|
|
|
);
|
|
|
|
|
page.push_str("<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
|
|
|
|
|
page.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
|
|
|
|
|
page.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n");
|
|
|
|
|
page.push_str(&format!("<title>{}</title>\n", escape(&title)));
|
|
|
|
|
page.push_str("<style>\n");
|
|
|
|
|
page.push_str(STYLE);
|
|
|
|
|
page.push_str("</style>\n</head>\n<body>\n<div class=\"page\">\n");
|
|
|
|
|
page.push_str(
|
|
|
|
|
"<nav class=\"toc\" aria-label=\"Contents\">\n<p class=\"toc-title\">Contents</p>\n",
|
|
|
|
|
);
|
|
|
|
|
page.push_str(&toc);
|
|
|
|
|
page.push_str("</nav>\n<main>\n");
|
|
|
|
|
page.push_str(&body);
|
|
|
|
|
page.push_str("</main>\n</div>\n</body>\n</html>\n");
|
|
|
|
|
page
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `Start(Paragraph) Start(Image) Text* End(Image) End(Paragraph)`, as one
|
|
|
|
|
/// figure, and how many events that was.
|
|
|
|
|
fn figure(events: &[Event]) -> Option<(String, usize)> {
|
|
|
|
|
let Some(Event::Start(Tag::Image {
|
|
|
|
|
dest_url, title, ..
|
|
|
|
|
})) = events.get(1)
|
|
|
|
|
else {
|
|
|
|
|
return None;
|
|
|
|
|
};
|
|
|
|
|
let mut alt = String::new();
|
|
|
|
|
let mut j = 2;
|
|
|
|
|
loop {
|
|
|
|
|
match events.get(j)? {
|
|
|
|
|
Event::Text(t) | Event::Code(t) => alt.push_str(t),
|
|
|
|
|
Event::End(TagEnd::Image) => break,
|
|
|
|
|
_ => return None,
|
|
|
|
|
}
|
|
|
|
|
j += 1;
|
|
|
|
|
}
|
|
|
|
|
if !matches!(events.get(j + 1)?, Event::End(TagEnd::Paragraph)) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let title_attr = if title.is_empty() {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
format!(" title=\"{}\"", escape(title))
|
|
|
|
|
};
|
|
|
|
|
let html = format!(
|
|
|
|
|
"<figure><img loading=\"lazy\" src=\"{}\" alt=\"{}\"{title_attr}>\
|
|
|
|
|
<figcaption>{}</figcaption></figure>\n",
|
|
|
|
|
escape(&rewrite_link(dest_url)),
|
|
|
|
|
escape(&alt),
|
|
|
|
|
escape(&alt),
|
|
|
|
|
);
|
|
|
|
|
Some((html, j + 2))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The page's stylesheet, inline so the page is one file beside its pictures.
|
|
|
|
|
///
|
|
|
|
|
/// Light and dark from the system's own preference, because the page opens in
|
|
|
|
|
/// whatever browser the system has and should match it rather than the app.
|
|
|
|
|
/// `:target` marks the heading a "See it" link landed on — a jump to the middle
|
|
|
|
|
/// of a long page otherwise leaves the reader to find which paragraph was
|
|
|
|
|
/// meant.
|
|
|
|
|
///
|
|
|
|
|
/// **Every picture reserves its box before it loads** (`aspect-ratio`), which
|
|
|
|
|
/// is what makes that jump land. The pictures load lazily, and a lazy picture
|
|
|
|
|
/// above the target is zero pixels tall until it is scrolled to — so without a
|
|
|
|
|
/// reserved box the target moves down the page by every picture above it, a
|
|
|
|
|
/// screenful at a time, after the browser has already scrolled. 16:11 is the
|
|
|
|
|
/// window `tools/manual/record.sh` records at, so it is exact for every picture
|
|
|
|
|
/// today; `auto` lets one of a different shape take its own once it arrives.
|
|
|
|
|
/// Not `width`/`height` attributes read from the files: the pictures are in LFS
|
|
|
|
|
/// and CI does not fetch them, so the page would render differently there.
|
|
|
|
|
const STYLE: &str = r#":root {
|
|
|
|
|
--bg: #fbfaf8;
|
|
|
|
|
--ink: #1d1c1a;
|
|
|
|
|
--ink-dim: #5c5955;
|
|
|
|
|
--rule: #dedad4;
|
|
|
|
|
--accent: #8a4b12;
|
|
|
|
|
--panel: #f1eee9;
|
|
|
|
|
--mark: #f6e3c7;
|
|
|
|
|
}
|
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #161514;
|
|
|
|
|
--ink: #e9e6e1;
|
|
|
|
|
--ink-dim: #a39e97;
|
|
|
|
|
--rule: #34312d;
|
|
|
|
|
--accent: #e8a25c;
|
|
|
|
|
--panel: #201e1c;
|
|
|
|
|
--mark: #43321f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
}
|
|
|
|
|
.page {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 15rem minmax(0, 46rem);
|
|
|
|
|
gap: 3rem;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: 2rem 1.5rem 4rem;
|
|
|
|
|
}
|
|
|
|
|
.toc {
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 1.5rem;
|
|
|
|
|
align-self: start;
|
|
|
|
|
max-height: calc(100vh - 3rem);
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
}
|
|
|
|
|
.toc-title {
|
|
|
|
|
margin: 0 0 0.5rem;
|
|
|
|
|
color: var(--ink-dim);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
.toc ul { list-style: none; margin: 0; padding: 0; }
|
|
|
|
|
.toc ul ul { padding-left: 0.9rem; }
|
|
|
|
|
.toc li { margin: 0.2rem 0; }
|
|
|
|
|
.toc a { color: var(--ink-dim); text-decoration: none; }
|
|
|
|
|
.toc a:hover { color: var(--accent); }
|
|
|
|
|
main { min-width: 0; }
|
|
|
|
|
h1, h2, h3 { line-height: 1.25; scroll-margin-top: 1rem; }
|
|
|
|
|
h1 { font-size: 2.1rem; margin: 0 0 1rem; }
|
|
|
|
|
h2 { font-size: 1.5rem; margin: 2.6rem 0 0.8rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
|
|
|
|
|
h3 { font-size: 1.15rem; margin: 1.8rem 0 0.6rem; }
|
|
|
|
|
h2:target, h3:target { background: var(--mark); border-radius: 4px; padding-left: 0.3rem; margin-left: -0.3rem; }
|
|
|
|
|
a { color: var(--accent); }
|
|
|
|
|
code {
|
|
|
|
|
font: 0.88em ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", monospace;
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
border: 1px solid var(--rule);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
padding: 0.05em 0.3em;
|
|
|
|
|
}
|
|
|
|
|
figure { margin: 1.4rem 0; }
|
|
|
|
|
figure img, main img {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: auto;
|
|
|
|
|
aspect-ratio: auto 16 / 11;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
border: 1px solid var(--rule);
|
|
|
|
|
}
|
|
|
|
|
figcaption { margin-top: 0.4rem; color: var(--ink-dim); font-size: 0.9rem; }
|
|
|
|
|
table { border-collapse: collapse; width: 100%; font-size: 0.95rem; }
|
|
|
|
|
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
|
|
|
|
|
th { color: var(--ink-dim); font-weight: 600; }
|
|
|
|
|
@media (max-width: 52rem) {
|
|
|
|
|
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 16px 3rem; }
|
|
|
|
|
.toc { position: static; max-height: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.8rem 1rem; background: var(--panel); }
|
|
|
|
|
body { font-size: 16px; }
|
|
|
|
|
}
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_slug_is_what_the_forge_makes() {
|
|
|
|
|
assert_eq!(slug("Rating and flagging"), "rating-and-flagging");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
slug("History, snapshots, presets"),
|
|
|
|
|
"history-snapshots-presets"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(slug("The `Local` rail"), "the-local-rail");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_repeated_heading_gets_a_numbered_anchor() {
|
|
|
|
|
let md = "## Light\n\ntext\n\n## Light\n";
|
|
|
|
|
assert_eq!(anchors(md), ["light", "light-1"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_heading_carries_its_anchor_in_the_page() {
|
|
|
|
|
let md = "# Title\n\n## Getting about\n\n### Looking closer\n";
|
|
|
|
|
let html = render_html(md);
|
|
|
|
|
assert!(html.contains("<h2 id=\"getting-about\">"), "{html}");
|
|
|
|
|
assert!(html.contains("<h3 id=\"looking-closer\">"), "{html}");
|
|
|
|
|
assert!(html.contains("<a href=\"#looking-closer\">"), "{html}");
|
|
|
|
|
assert!(html.contains("<title>Title</title>"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_lone_picture_is_a_captioned_figure_with_its_relative_path() {
|
|
|
|
|
let md = "\n";
|
|
|
|
|
let html = render_html(md);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("src=\"media/library-timeline.gif\""),
|
|
|
|
|
"{html}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("<figcaption>Scrubbing the timeline</figcaption>"),
|
|
|
|
|
"{html}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_link_out_of_the_manual_goes_to_the_forge() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rewrite_link("../dev/faces.md"),
|
|
|
|
|
format!("{FORGE_TREE}docs/dev/faces.md")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
rewrite_link("../../tools/manual/README.md#x"),
|
|
|
|
|
format!("{FORGE_TREE}tools/manual/README.md#x")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(rewrite_link("media/a.png"), "media/a.png");
|
|
|
|
|
assert_eq!(rewrite_link("#top"), "#top");
|
|
|
|
|
assert_eq!(rewrite_link("https://x.org/"), "https://x.org/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The real manual: one page, every section in the contents, nothing lost.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_real_manual_renders_with_its_contents() {
|
|
|
|
|
let md = include_str!("../../../docs/manual/README.md");
|
|
|
|
|
let html = render_html(md);
|
|
|
|
|
for h in headings(md).iter().filter(|h| h.level == 2) {
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains(&format!("<a href=\"#{}\">", h.id)),
|
|
|
|
|
"{} missing from the contents",
|
|
|
|
|
h.id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert!(!html.contains("src=\"../"), "a picture escaped the manual");
|
|
|
|
|
}
|
|
|
|
|
}
|