~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A fold's summary is Markdown, and a squash commit leaves the fold out

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/fold-summary
.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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
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 &lt;b&gt;</summary ></details>"),
"<details open><summary>a &amp;lt;b&amp;gt;</summary></details>"
"<details open><summary>a &lt;b&gt;</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"),
("![i](i.png)", "![i](i.png)"),
("a <b>b</b>", "a &lt;b&gt;b&lt;/b&gt;"),
(" ", ""),
] {
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 &amp; 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())
}