chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A fold's summary is Markdown, and a squash commit leaves the fold out
.changeset/fold-summary-markdown.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# A fold's summary is Markdown, and a squash commit leaves the fold out | ||
| ||
What a `<summary>` says shows its emphasis, code and links on the pages and in the iOS app. A pull merged as one squash commit, by `knotless pull merge` or the merge queue, has its body's `<details>` and `<summary>` tags left out of the commit message, each summary's words kept as a line. | ||
crates/knotless/src/forge.rs+78−1
| @@ -3463,7 +3463,8 @@ pub(crate) async fn squash_commit( | ||
.push((var.into(), author.next().unwrap_or_default())); | ||
} | ||
let mut message = item.title.clone(); | ||
if let Some(body) = item.body.as_deref().filter(|b| !b.is_empty()) { | ||
let body = item.body.as_deref().map(unfolded).unwrap_or_default(); | ||
if !body.is_empty() { | ||
message = format!("{message}\n\n{body}"); | ||
} | ||
let message = format!("{message}\n\n{}", item.uri); | ||
| @@ -3471,6 +3472,64 @@ pub(crate) async fn squash_commit( | ||
.await | ||
} | ||
| ||
/// A pull's body as its squashed commit says it: without the `<details>` | ||
/// and `<summary>` tags that fold a part of it away on its page, each | ||
/// summary's words kept as a line of their own, and with no more than one | ||
/// blank line in a row where a tag was. Fenced code is left as it is. | ||
fn unfolded(body: &str) -> String { | ||
let mut lines: Vec<&str> = Vec::new(); | ||
let mut fenced = false; | ||
for line in body.lines() { | ||
let fence = line.trim_start().starts_with("```"); | ||
let code = fenced || fence; | ||
fenced ^= fence; | ||
let kept = match code { | ||
true => Some(line), | ||
false => fold_line(line.trim()).unwrap_or(Some(line)), | ||
}; | ||
let blank = kept.is_none_or(|k| k.trim().is_empty()); | ||
if !code && blank && lines.last().is_none_or(|l| l.trim().is_empty()) { | ||
continue; | ||
} | ||
lines.push(kept.unwrap_or("")); | ||
} | ||
lines.join("\n").trim_end().to_string() | ||
} | ||
| ||
/// What a line of fold tags leaves in a commit message: nothing for | ||
/// `<details>` (or `<details open>`) and `</details>`, a summary's words | ||
/// for `<summary>…</summary>`, with those around it or not; or `None` for | ||
/// a line that is more than these, kept as it is. | ||
fn fold_line(line: &str) -> Option<Option<&str>> { | ||
let mut rest = line; | ||
for open in ["<details>", "<details open>"] { | ||
if let Some(after) = tag_start(rest, open) { | ||
rest = after.trim_start(); | ||
} | ||
} | ||
if let Some(before) = tag_end(rest, "</details>") { | ||
rest = before.trim_end(); | ||
} | ||
if rest.is_empty() { | ||
return Some(None); | ||
} | ||
let said = tag_end(tag_start(rest, "<summary>")?, "</summary>")?; | ||
Some(Some(said.trim())) | ||
} | ||
| ||
/// `s` past `tag`, in any case, if it starts with it. | ||
fn tag_start<'a>(s: &'a str, tag: &str) -> Option<&'a str> { | ||
let start = s.get(..tag.len())?; | ||
start.eq_ignore_ascii_case(tag).then(|| &s[tag.len()..]) | ||
} | ||
| ||
/// `s` before `tag`, in any case, if it ends with it. | ||
fn tag_end<'a>(s: &'a str, tag: &str) -> Option<&'a str> { | ||
let at = s.len().checked_sub(tag.len())?; | ||
let end = s.get(at..)?; | ||
end.eq_ignore_ascii_case(tag).then(|| &s[..at]) | ||
} | ||
| ||
/// Forks a repository into my account, under `name` or its own. A private | ||
/// repository forks into a private one, which its owner can read, to take | ||
/// pulls from it. | ||
| @@ -3759,6 +3818,24 @@ mod tests { | ||
assert_eq!(sections(text), ["what changes", "why"]); | ||
} | ||
| ||
#[test] | ||
fn a_squash_message_leaves_folds_out() { | ||
let body = concat!( | ||
"\nShort.\n\n<details>\n<summary> Claude Opus 5.5 · high </summary>\n\n", | ||
"## What changes\n\n```\n<details>\n\n\n</details>\n```\n", | ||
"<DETAILS OPEN><summary>b</SUMMARY></details>\n<details>words\n<summary>\n", | ||
"</details>\n\n", | ||
); | ||
assert_eq!( | ||
unfolded(body), | ||
concat!( | ||
"Short.\n\nClaude Opus 5.5 · high\n\n## What changes\n\n", | ||
"```\n<details>\n\n\n</details>\n```\nb\n<details>words\n<summary>", | ||
) | ||
); | ||
assert_eq!(unfolded("<details>\n\n</details>\n"), ""); | ||
} | ||
| ||
#[test] | ||
fn a_templates_notes_are_left_out() { | ||
let template = "<!-- Title: short. -->\n\n## What\n<!-- unclosed"; | ||
crates/knotless-serve/src/web.rs+110−16
| @@ -2421,7 +2421,8 @@ fn drawn( | ||
said.and_then(|s| s.named.get(dest)) | ||
.map(|(path, _)| path.clone()) | ||
}; | ||
let events = autolinks(parsed).map(|event| match event { | ||
// Folds first, so what a summary says is linked as the rest is. | ||
let events = autolinks(folds(parsed, options).into_iter()).map(|event| match event { | ||
Event::Start(Tag::Link { | ||
link_type, | ||
dest_url, | ||
| @@ -2451,12 +2452,9 @@ fn drawn( | ||
}), | ||
other => other, | ||
}); | ||
let events = folds(events); | ||
let events: Vec<Event> = match said { | ||
Some(said) => { | ||
mentions(references(events.into_iter(), said.named), said.mentioned).collect() | ||
} | ||
None => events, | ||
Some(said) => mentions(references(events, said.named), said.mentioned).collect(), | ||
None => events.collect(), | ||
}; | ||
let mut out = String::new(); | ||
pulldown_cmark::html::push_html(&mut out, fenced(events).into_iter()); | ||
| @@ -2490,9 +2488,12 @@ impl Fold { | ||
/// around them do: a closing tag counts only where it closes what was | ||
/// opened beside it, a block closes what it left open as it ends, and so | ||
/// does the end of the text, so nothing written can close the page's own | ||
/// elements. | ||
/// elements. What a summary says is Markdown on a line, as [`summary`] | ||
/// reads it; raw HTML's text is written escaped, for no later step to | ||
/// link. | ||
fn folds<'a>( | ||
events: impl Iterator<Item = pulldown_cmark::Event<'a>>, | ||
options: pulldown_cmark::Options, | ||
) -> Vec<pulldown_cmark::Event<'a>> { | ||
use pulldown_cmark::{Event, Tag, TagEnd}; | ||
let mut out = Vec::new(); | ||
| @@ -2512,8 +2513,8 @@ fn folds<'a>( | ||
shut(&mut out, &mut open, kept); | ||
out.push(event); | ||
} | ||
Event::InlineHtml(html) => out.push(Event::Text(html)), | ||
Event::Html(html) => unfolded(&html, depth, &mut open, &mut out), | ||
Event::InlineHtml(html) => out.push(Event::Html(escaped(&html).into())), | ||
Event::Html(html) => unfolded(&html, depth, options, &mut open, &mut out), | ||
other => out.push(other), | ||
} | ||
} | ||
| @@ -2521,6 +2522,57 @@ fn folds<'a>( | ||
out | ||
} | ||
| ||
/// `text` escaped as HTML's text is, as pulldown-cmark escapes it. | ||
fn escaped(text: &str) -> String { | ||
let mut out = String::with_capacity(text.len()); | ||
for c in text.chars() { | ||
match c { | ||
'&' => out.push_str("&"), | ||
'<' => out.push_str("<"), | ||
'>' => out.push_str(">"), | ||
c => out.push(c), | ||
} | ||
} | ||
out | ||
} | ||
| ||
/// What a `<summary>` says, `text`, as Markdown on a line: its emphasis, | ||
/// code and links. Written as more than that (a heading, a list, a | ||
/// picture), it is shown as text. | ||
fn summary(text: &str, options: pulldown_cmark::Options) -> Vec<pulldown_cmark::Event<'static>> { | ||
use pulldown_cmark::{Event, Parser, Tag, TagEnd}; | ||
let events: Vec<Event> = Parser::new_ext(text, options).collect(); | ||
let inline = |e: &Event| { | ||
matches!( | ||
e, | ||
Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link { .. }) | ||
| Event::End( | ||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link | ||
) | ||
| Event::Text(_) | ||
| Event::Code(_) | ||
| Event::SoftBreak | ||
| Event::HardBreak | ||
| Event::InlineHtml(_) | ||
) | ||
}; | ||
match events.as_slice() { | ||
[ | ||
Event::Start(Tag::Paragraph), | ||
inner @ .., | ||
Event::End(TagEnd::Paragraph), | ||
] if inner.iter().all(inline) => { | ||
let said = inner.iter().cloned().map(|e| match e { | ||
Event::InlineHtml(html) => Event::Html(escaped(&html).into()), | ||
e => e.into_static(), | ||
}); | ||
said.collect() | ||
} | ||
[] => Vec::new(), | ||
_ => vec![Event::Html(escaped(text).into())], | ||
} | ||
} | ||
| ||
/// The folds `open` holds past its first `kept`, closed. | ||
fn shut(out: &mut Vec<pulldown_cmark::Event<'_>>, open: &mut Vec<(Fold, usize)>, kept: usize) { | ||
for (fold, _) in open.drain(kept..).rev() { | ||
| @@ -2532,10 +2584,23 @@ fn shut(out: &mut Vec<pulldown_cmark::Event<'_>>, open: &mut Vec<(Fold, usize)>, | ||
fn unfolded<'a>( | ||
html: &str, | ||
depth: usize, | ||
options: pulldown_cmark::Options, | ||
open: &mut Vec<(Fold, usize)>, | ||
out: &mut Vec<pulldown_cmark::Event<'a>>, | ||
) { | ||
use pulldown_cmark::Event; | ||
// The text since the last tag, said in a summary or not. | ||
let said = |text: String, summarised: bool, out: &mut Vec<Event<'a>>| { | ||
if text.is_empty() { | ||
return; | ||
} | ||
if summarised { | ||
let words: Vec<Event<'a>> = summary(&text, options); | ||
out.extend(words); | ||
} else { | ||
out.push(Event::Html(escaped(&text).into())); | ||
} | ||
}; | ||
let mut text = String::new(); | ||
let mut rest = html; | ||
while let Some(at) = rest.find('<') { | ||
| @@ -2543,6 +2608,7 @@ fn unfolded<'a>( | ||
rest = &rest[at..]; | ||
let top = open.last().copied(); | ||
let under = open.len().checked_sub(2).map(|i| open[i]); | ||
let before = std::mem::take(&mut text); | ||
let kept: Option<(&'static str, usize)> = match fold_tag(rest) { | ||
Some((FoldTag::Opens(Fold::Details, shown), len)) => { | ||
open.push((Fold::Details, depth)); | ||
| @@ -2573,22 +2639,19 @@ fn unfolded<'a>( | ||
}; | ||
match kept { | ||
Some((tag, len)) => { | ||
if !text.is_empty() { | ||
out.push(Event::Text(std::mem::take(&mut text).into())); | ||
} | ||
said(before, top == Some((Fold::Summary, depth)), out); | ||
out.push(Event::Html(tag.into())); | ||
rest = &rest[len..]; | ||
} | ||
None => { | ||
text = before; | ||
text.push('<'); | ||
rest = &rest[1..]; | ||
} | ||
} | ||
} | ||
text.push_str(rest); | ||
if !text.is_empty() { | ||
out.push(Event::Text(text.into())); | ||
} | ||
said(text, open.last() == Some(&(Fold::Summary, depth)), out); | ||
} | ||
| ||
/// A `<details>` or `<summary>` tag [`folds`] keeps. | ||
| @@ -4059,7 +4122,38 @@ mod tests { | ||
assert!(html.ends_with("</ul>\n</details>\n"), "{html}"); | ||
assert_eq!( | ||
draw("<DETAILS Open><Summary >a <b></summary ></details>"), | ||
"<details open><summary>a &lt;b&gt;</summary></details>" | ||
"<details open><summary>a <b></summary></details>" | ||
); | ||
// A summary is Markdown on a line, linked as the rest is; more | ||
// than that, or HTML in it, is text. | ||
let html = written( | ||
"<details><summary>**Why** `x` [l](javascript:alert(1)) at https://knotless.dev</summary>", | ||
"/B", | ||
None, | ||
&HashMap::new(), | ||
); | ||
assert!( | ||
html.starts_with( | ||
"<details><summary><strong>Why</strong> <code>x</code> <a href=\"#\">l</a>" | ||
), | ||
"{html}" | ||
); | ||
assert!(html.contains("<a href=\"https://knotless.dev\">"), "{html}"); | ||
for (said, shown) in [ | ||
("# Not a heading", "# Not a heading"), | ||
("", ""), | ||
("a <b>b</b>", "a <b>b</b>"), | ||
(" ", ""), | ||
] { | ||
assert_eq!( | ||
draw(&format!("<details><summary>{said}</summary></details>")), | ||
format!("<details><summary>{shown}</summary></details>") | ||
); | ||
} | ||
// Raw HTML's text is never linked, and is escaped. | ||
assert_eq!( | ||
draw("<details>at https://knotless.dev & more"), | ||
"<details>at https://knotless.dev & more</details>" | ||
); | ||
// Anything else about them is text: attributes, a tag in a | ||
// paragraph, one unfinished. | ||
docs/design/collaboration.md+4−0
| @@ -660,6 +660,10 @@ Details: | ||
committer, and a merge commit's author, is the merger, as git commits on | ||
their machine, or else as `<did>@knotless.invalid`; the merge queue's | ||
is the runner (see [Commit authors](identity.md#commit-authors)). | ||
- A squash commit's message is the pull's title, its body and its | ||
AT-URI. The body leaves out the `<details>` and `<summary>` tags that | ||
fold a part of it away on its page, outside fenced code, keeping each | ||
summary's words as a line of their own, so `git log` reads it whole. | ||
- A head branch is spent when it is in the target repository, is neither | ||
the pull's base nor the default branch, is not protected, and no other | ||
open pull targets or brings it in. A fork's branch is never spent. `--keep-branch` keeps it. | ||
docs/design/merge-queue.md+4−1
| @@ -193,7 +193,10 @@ Details: | ||
`knotless`, at the runner's account's `<did>@knotless.invalid`, at the | ||
current time. | ||
- Its message is the pull's title, the body if there is one, and the | ||
pull's AT-URI, separated by blank lines. | ||
pull's AT-URI, separated by blank lines. The body leaves out the | ||
`<details>` and `<summary>` tags that fold a part of it away on its | ||
page, outside fenced code, keeping each summary's words as a line of | ||
their own. | ||
| ||
## Pre-flights | ||
| ||
docs/design/serve.md+5−1
| @@ -1533,7 +1533,11 @@ running script on the site. | ||
attribute but `open`, and only on a line of its own, never inside a | ||
paragraph. A closing tag counts only where it closes one opened beside it, | ||
and what a block leaves open closes as the block ends, so a body never | ||
closes the page's own elements. The iOS app draws the same folds. | ||
closes the page's own elements. What a summary says is Markdown on one | ||
line, its emphasis, code and links, its links held as any other link is; | ||
anything more, as a heading or a picture, shows as text. A diagram in a | ||
closed fold is drawn as the page loads and sized as it opens. The iOS app | ||
draws the same folds. | ||
Links keep only http, https and mailto. Relative links resolve inside the | ||
repository, never above its root. | ||
- Mermaid blocks arrive as code. The browser swaps them for diagrams drawn by | ||
ios/Knotless/Support/Markdown.swift+6−5
| @@ -91,7 +91,7 @@ struct MarkdownView: View { | ||
} | ||
.padding(.leading, CGFloat(depth) * 16) | ||
case .fold(let summary, let blocks, let open): | ||
FoldedMarkdown(summary: summary, open: open) { | ||
FoldedMarkdown(summary: inline(summary), open: open) { | ||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in | ||
// Erased, as a fold can hold folds. | ||
AnyView(view(block, colours)) | ||
| @@ -363,13 +363,14 @@ struct MarkdownView: View { | ||
} | ||
| ||
/// A part of Markdown folded away, as `<details>` is: what its summary says, | ||
/// beside an arrow, and what's inside once that's tapped. | ||
/// its inline marks drawn, beside an arrow, and what's inside once that's | ||
/// tapped. | ||
private struct FoldedMarkdown<Content: View>: View { | ||
let summary: String | ||
let summary: Text | ||
let content: Content | ||
@State private var open: Bool | ||
| ||
init(summary: String, open: Bool, @ViewBuilder content: () -> Content) { | ||
init(summary: Text, open: Bool, @ViewBuilder content: () -> Content) { | ||
self.summary = summary | ||
self.content = content() | ||
_open = State(initialValue: open) | ||
| @@ -382,7 +383,7 @@ private struct FoldedMarkdown<Content: View>: View { | ||
Oct(name: "chevron-down", size: Icon.sm) | ||
.rotationEffect(open ? .zero : .degrees(-90)) | ||
.foregroundStyle(.secondary) | ||
Text(summary).foregroundStyle(Color.fg).multilineTextAlignment(.leading) | ||
summary.foregroundStyle(Color.fg).multilineTextAlignment(.leading) | ||
} | ||
.contentShape(Rectangle()) | ||
} | ||