~ 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

Markdown folds a part away with <details> on the pages and in the iOS app

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/details-fold
.changeset/details-fold.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Markdown folds a part away with `<details>`
A pull, issue, comment or README can fold its long part away behind `<details><summary>…</summary>`, as on GitHub: the pages and the iOS app show the summary, and the rest once it is opened.
crates/knotless-serve/src/web/preview.rs+4−4
@@ -1,10 +1,10 @@
//! Markdown as the pages will show it once posted:
//! `dev.knotless.renderMarkdown`, which a composer's preview asks. It is
//! the same renderer the pages' comments, bodies and READMEs are drawn
//! with, raw HTML shown as text and links kept to the web's and the
//! repository's own, so what is previewed is what is shown. It reads
//! nothing and fetches nothing: no login is needed, and the text it takes
//! is no longer than a body may be.
//! with, raw HTML shown as text but for `<details>` folds, and links
//! kept to the web's and the repository's own, so what is previewed is
//! what is shown. It reads nothing and fetches nothing: no login is
//! needed, and the text it takes is no longer than a body may be.
use super::repo::invalid;
use super::*;
crates/knotless-serve/src/web.rs+230−6
@@ -2353,9 +2353,10 @@ fn is_markdown(path: &str) -> bool {
/// Markdown's links to a directory go there rather than to a file.
type Trees<'a> = Option<(&'a str, &'a dyn Fn(&str) -> bool)>;
/// Markdown, with raw HTML shown as text and links made relative to the
/// directory `dir` it was found in, among files shown under `blobs`,
/// directories under `trees` and, for images, `raws`.
/// Markdown, with raw HTML shown as text but for the folds [`folds`]
/// keeps, and links made relative to the directory `dir` it was found
/// in, among files shown under `blobs`, directories under `trees` and,
/// for images, `raws`.
fn markdown(text: &str, blobs: &str, raws: &str, dir: &str, trees: Trees) -> String {
drawn(text, blobs, raws, dir, trees, None, None)
}
@@ -2421,7 +2422,6 @@ fn drawn(
.map(|(path, _)| path.clone())
};
let events = autolinks(parsed).map(|event| match event {
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html),
Event::Start(Tag::Link {
link_type,
dest_url,
@@ -2451,9 +2451,12 @@ fn drawn(
}),
other => other,
});
let events = folds(events);
let events: Vec<Event> = match said {
Some(said) => mentions(references(events, said.named), said.mentioned).collect(),
None => events.collect(),
Some(said) => {
mentions(references(events.into_iter(), said.named), said.mentioned).collect()
}
None => events,
};
let mut out = String::new();
pulldown_cmark::html::push_html(&mut out, fenced(events).into_iter());
@@ -2463,6 +2466,169 @@ fn drawn(
}
}
/// A part of Markdown folded away: `<details>`, its `<summary>` said
/// beside the arrow that opens it.
#[derive(Clone, Copy, PartialEq)]
enum Fold {
Details,
Summary,
}
impl Fold {
fn closer(self) -> &'static str {
match self {
Fold::Details => "</details>",
Fold::Summary => "</summary>",
}
}
}
/// Raw HTML shown as text, but for the `<details>` and `<summary>` that
/// fold a part away, as GitHub's Markdown keeps them: each written anew,
/// `open` the only attribute kept, and only on lines of their own (an
/// HTML block), so never inside a paragraph. They nest as the blocks
/// 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.
fn folds<'a>(
events: impl Iterator<Item = pulldown_cmark::Event<'a>>,
) -> Vec<pulldown_cmark::Event<'a>> {
use pulldown_cmark::{Event, Tag, TagEnd};
let mut out = Vec::new();
// What is open, each with how many blocks deep it was opened.
let mut open: Vec<(Fold, usize)> = Vec::new();
let mut depth = 0usize;
for event in events {
match event {
Event::Start(Tag::HtmlBlock) | Event::End(TagEnd::HtmlBlock) => out.push(event),
Event::Start(_) => {
depth += 1;
out.push(event);
}
Event::End(_) => {
depth -= 1;
let kept = open.iter().take_while(|(_, d)| *d <= depth).count();
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),
other => out.push(other),
}
}
shut(&mut out, &mut open, 0);
out
}
/// 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() {
out.push(pulldown_cmark::Event::Html(fold.closer().into()));
}
}
/// A line of an HTML block, `depth` blocks deep, as [`folds`] shows it.
fn unfolded<'a>(
html: &str,
depth: usize,
open: &mut Vec<(Fold, usize)>,
out: &mut Vec<pulldown_cmark::Event<'a>>,
) {
use pulldown_cmark::Event;
let mut text = String::new();
let mut rest = html;
while let Some(at) = rest.find('<') {
text.push_str(&rest[..at]);
rest = &rest[at..];
let top = open.last().copied();
let under = open.len().checked_sub(2).map(|i| open[i]);
let kept: Option<(&'static str, usize)> = match fold_tag(rest) {
Some((FoldTag::Opens(Fold::Details, shown), len)) => {
open.push((Fold::Details, depth));
Some((if shown { "<details open>" } else { "<details>" }, len))
}
Some((FoldTag::Opens(Fold::Summary, _), len))
if top == Some((Fold::Details, depth)) =>
{
open.push((Fold::Summary, depth));
Some(("<summary>", len))
}
Some((FoldTag::Closes(Fold::Summary), len)) if top == Some((Fold::Summary, depth)) => {
open.pop();
Some(("</summary>", len))
}
Some((FoldTag::Closes(Fold::Details), len)) if top == Some((Fold::Details, depth)) => {
open.pop();
Some(("</details>", len))
}
// A summary left open closes with its fold.
Some((FoldTag::Closes(Fold::Details), len))
if top == Some((Fold::Summary, depth)) && under == Some((Fold::Details, depth)) =>
{
open.truncate(open.len() - 2);
Some(("</summary></details>", len))
}
_ => None,
};
match kept {
Some((tag, len)) => {
if !text.is_empty() {
out.push(Event::Text(std::mem::take(&mut text).into()));
}
out.push(Event::Html(tag.into()));
rest = &rest[len..];
}
None => {
text.push('<');
rest = &rest[1..];
}
}
}
text.push_str(rest);
if !text.is_empty() {
out.push(Event::Text(text.into()));
}
}
/// A `<details>` or `<summary>` tag [`folds`] keeps.
enum FoldTag {
/// Opening one, and for `<details>`, whether it is drawn open.
Opens(Fold, bool),
Closes(Fold),
}
/// The fold tag `html` starts with, and its length: `<details>`,
/// `<details open>`, `<summary>` or the closing tag of either, in any
/// case, with any space before its `>`.
fn fold_tag(html: &str) -> Option<(FoldTag, usize)> {
let end = html.find('>')?;
let inner = &html[1..end];
let (closes, inner) = match inner.strip_prefix('/') {
Some(inner) => (true, inner),
None => (false, inner),
};
if !inner.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
let mut words = inner.split_ascii_whitespace();
let fold = match words.next()?.to_ascii_lowercase().as_str() {
"details" => Fold::Details,
"summary" => Fold::Summary,
_ => return None,
};
let attributes: Vec<&str> = words.collect();
let tag = match (closes, fold, attributes.as_slice()) {
(true, _, []) => FoldTag::Closes(fold),
(false, _, []) => FoldTag::Opens(fold, false),
(false, Fold::Details, [open]) if open.eq_ignore_ascii_case("open") => {
FoldTag::Opens(Fold::Details, true)
}
_ => return None,
};
Some((tag, end + 1))
}
/// Fenced code in a language highlighted here, its block opened with the
/// ranges [`highlight::fence`] gives, which the page paints as it paints a
/// file's.
@@ -3878,6 +4044,64 @@ mod tests {
}
}
#[test]
fn details_fold_a_part_away() {
let draw = |text: &str| markdown(text, "/B", "/R", "", None);
// As GitHub's: the summary beside the arrow, Markdown inside.
let html = draw(concat!(
"Short.\n\n<details>\n<summary>Claude Opus 5.5</summary>\n\n",
"## What changes\n\n- [ ] a task\n\n</details>\n",
));
assert!(
html.contains("<details>\n<summary>Claude Opus 5.5</summary>\n<h2>What changes</h2>"),
"{html}"
);
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>"
);
// Anything else about them is text: attributes, a tag in a
// paragraph, one unfinished.
for text in [
"<details onclick=\"x\">",
"<details open=x>",
"</details x>",
"a <details> b",
"<details\n",
"< details>",
"<detailsx>",
] {
let html = draw(text);
assert!(!html.contains("<details"), "{text}: {html}");
}
// A summary only in its fold; closers only of what is open beside
// them; what is left open closed as its block, or the text, ends.
assert_eq!(
draw("</summary>\n<summary>\n</details>"),
"&lt;/summary&gt;\n&lt;summary&gt;\n&lt;/details&gt;"
);
assert_eq!(
draw("<details><summary>a</details>"),
"<details><summary>a</summary></details>"
);
assert_eq!(
draw("<details><summary>a"),
"<details><summary>a</summary></details>"
);
// A `<` that starts no tag is text beside them.
assert_eq!(
draw("<details>1 < 2 >\n"),
"<details>1 &lt; 2 &gt;\n</details>"
);
let html = draw("> <details>\n\n</details>");
assert!(
html.contains("<details>\n</details></blockquote>"),
"{html}"
);
assert!(html.ends_with("&lt;/details&gt;"), "{html}");
}
#[test]
fn failures_by_name() {
let named = [
docs/design/security.md+6−2
@@ -390,8 +390,12 @@ site:
- Pages carry a strict content security policy: scripts only from the
site, and no other site may frame them.
- Markdown keeps raw HTML as text. Links keep only http, https and mailto,
and relative links resolve inside the repository.
- Markdown keeps raw HTML as text, but for the `<details>` and `<summary>`
that fold a part away: each written anew with no attribute but `open`,
only on a line of its own, and closed where the block holding it ends,
so nothing written closes the page's own elements. Links keep only
http, https and mailto, and relative links resolve inside the
repository.
- A composer's preview (`dev.knotless.renderMarkdown`) is that same
renderer, open to anyone without a login: it reads no repository and
fetches nothing, a link's target is only written into the HTML, and the
docs/design/serve.md+8−2
@@ -1528,8 +1528,14 @@ running script on the site.
- Pages carry a strict content security policy: scripts only from the site, and
no other site may frame them.
- Markdown keeps raw HTML as text. Links keep only http, https and mailto.
Relative links resolve inside the repository, never above its root.
- Markdown keeps raw HTML as text, but for `<details>` and `<summary>`, which
fold a part away as GitHub's Markdown does. Each is written anew, with no
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.
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
Pele, loaded only on a page that has one. A block that fails shows its source
again. Pele escapes text, drops script callbacks, blocks `javascript:` and
ios/Knotless/Support/Markdown.swift+58−3
@@ -34,6 +34,9 @@ struct MarkdownView: View {
case rule
case table(MarkdownTable)
case task(String, number: Int, done: Bool, depth: Int)
/// A part folded away, as `<details>` is: what its summary says, the
/// blocks inside, and whether it starts open.
case fold(String, blocks: [Block], open: Bool)
}
var body: some View {
@@ -87,6 +90,13 @@ struct MarkdownView: View {
.foregroundStyle(done ? Color.mute : Color.fg)
}
.padding(.leading, CGFloat(depth) * 16)
case .fold(let summary, let blocks, let open):
FoldedMarkdown(summary: summary, open: open) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
// Erased, as a fold can hold folds.
AnyView(view(block, colours))
}
}
}
}
@@ -260,6 +270,13 @@ struct MarkdownView: View {
}
static func parse(_ source: String) -> [Block] {
let lines = source.components(separatedBy: "\n")
let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first }
return parse(lines, lines.indices, tasks)
}
/// The blocks of `lines` in `range`, `tasks` by the line each is on.
private static func parse(_ lines: [String], _ range: Range<Int>, _ tasks: [Int: MarkdownTasks.Task]) -> [Block] {
var blocks: [Block] = []
var paragraph: [String] = []
var code: [String]?
@@ -273,8 +290,10 @@ struct MarkdownView: View {
table = nil
}
let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first }
for (at, raw) in source.components(separatedBy: "\n").enumerated() {
var at = range.lowerBound
while at < range.upperBound {
let raw = lines[at]
defer { at += 1 }
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") {
if let lines = code {
@@ -299,7 +318,12 @@ struct MarkdownView: View {
}
flush()
}
if let task = tasks[at] {
if let fold = MarkdownFolds.fold(lines, at: at, end: range.upperBound) {
flush()
blocks.append(.fold(fold.summary, blocks: parse(lines, fold.inside, tasks), open: fold.open))
// Past its `</details>`, as the loop steps on by one.
at = fold.next - 1
} else if let task = tasks[at] {
flush()
let depth = raw.prefix { $0 == " " }.count / 2
blocks.append(.task(MarkdownTasks.words(raw, task), number: task.number, done: task.done, depth: depth))
@@ -338,6 +362,37 @@ 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.
private struct FoldedMarkdown<Content: View>: View {
let summary: String
let content: Content
@State private var open: Bool
init(summary: String, open: Bool, @ViewBuilder content: () -> Content) {
self.summary = summary
self.content = content()
_open = State(initialValue: open)
}
var body: some View {
VStack(alignment: .leading, spacing: Space.s2_5) {
Button { open.toggle() } label: {
HStack(spacing: Space.s2) {
Oct(name: "chevron-down", size: Icon.sm)
.rotationEffect(open ? .zero : .degrees(-90))
.foregroundStyle(.secondary)
Text(summary).foregroundStyle(Color.fg).multilineTextAlignment(.leading)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue(open ? "Open" : "Closed")
if open { content }
}
}
}
/// Markdown its author can tick: each tick shows at once and is saved behind it,
/// one write at a time as each says the whole body, and a late one mustn't undo
/// a newer one. Once all are saved the thread is read anew. One that fails puts
ios/Knotless/Support/MarkdownFolds.swiftnew+73−0
@@ -0,0 +1,73 @@
import Foundation
/// The parts of what someone wrote folded away by `<details>`, as GitHub's
/// Markdown and the web pages (`folds` in crates/knotless-serve/src/web.rs)
/// draw them: a line starting `<details>`, its `<summary>` on that line or the
/// next, then Markdown, to a `</details>` on a line of its own, or the end.
enum MarkdownFolds {
struct Fold: Equatable {
/// What its summary says, or "Details", as a browser says without one.
let summary: String
/// Whether it starts open, as `<details open>` does.
let open: Bool
/// Its lines past the summary.
let inside: Range<Int>
/// The line after its `</details>`.
let next: Int
}
/// The fold line `at` of `lines` opens, none past `end`, or nil.
static func fold(_ lines: [String], at: Int, end: Int) -> Fold? {
guard let opened = opens(trimmed(lines[at])) else { return nil }
let open = opened.0
var rest = opened.1
var from = at + 1
if rest.isEmpty, from < end, starts(trimmed(lines[from]), "<summary>") != nil {
rest = trimmed(lines[from])
from += 1
}
var summary = "Details"
if let after = starts(rest, "<summary>"), let close = rest.range(of: "</summary>", options: .caseInsensitive, range: after..<rest.endIndex) {
summary = rest[after..<close.lowerBound].trimmingCharacters(in: .whitespaces)
rest = rest[close.upperBound...].trimmingCharacters(in: .whitespaces)
}
if closes(rest) { return Fold(summary: summary, open: open, inside: from..<from, next: from) }
// Anything else beside it is no fold of ours.
guard rest.isEmpty else { return nil }
var line = from
var fenced = false
while line < end {
let words = trimmed(lines[line])
if words.hasPrefix("```") {
fenced.toggle()
} else if !fenced {
if closes(words) { return Fold(summary: summary, open: open, inside: from..<line, next: line + 1) }
// A fold inside, closed by its own `</details>`.
if let inner = fold(lines, at: line, end: end) {
line = inner.next
continue
}
}
line += 1
}
return Fold(summary: summary, open: open, inside: from..<end, next: end)
}
private static func trimmed(_ line: String) -> String { line.trimmingCharacters(in: .whitespaces) }
/// Where `line` goes on past `tag`, in any case, if it starts with it.
private static func starts(_ line: String, _ tag: String) -> String.Index? {
line.range(of: tag, options: [.caseInsensitive, .anchored])?.upperBound
}
/// Whether `line` starts with `<details>` (false) or `<details open>`
/// (true), and what follows it.
private static func opens(_ line: String) -> (Bool, String)? {
for (tag, open) in [("<details>", false), ("<details open>", true)] {
if let after = starts(line, tag) { return (open, line[after...].trimmingCharacters(in: .whitespaces)) }
}
return nil
}
private static func closes(_ line: String) -> Bool { line.caseInsensitiveCompare("</details>") == .orderedSame }
}
ios/ModelCheck/main.swift+7−0
@@ -124,6 +124,13 @@ expect(MarkdownTasks.all(written).map(\.done) == [false, true, true, false, fals
expect(MarkdownTasks.ticked("- [ ] a\n```\n- [ ] b\n```\n- [ ] c", 1, done: true) == "- [ ] a\n```\n- [ ] b\n```\n- [x] c", "a tick rewrote another line")
expect(MarkdownTasks.ticked("é\n- [x] 🦊 a\r\n", 0, done: false) == "é\n- [ ] 🦊 a\r\n", "an untick changed more than its mark")
expect(MarkdownTasks.ticked("- [ ] a", 1, done: true) == nil, "a tick of no task")
// The folds the web pages draw, `<details>` to `</details>`.
let folded = "a\n<details open>\n<SUMMARY> Claude </summary>\n\n```\n</details>\n```\n<details><summary>in</summary></details>\n<details>\n</details>\n</Details>\nb".components(separatedBy: "\n")
expect(MarkdownFolds.fold(folded, at: 0, end: folded.count) == nil, "a line opening no fold")
expect(MarkdownFolds.fold(folded, at: 1, end: folded.count) == .init(summary: "Claude", open: true, inside: 3..<10, next: 11), "a fold with fenced code and folds inside: \(String(describing: MarkdownFolds.fold(folded, at: 1, end: folded.count)))")
expect(MarkdownFolds.fold(folded, at: 7, end: folded.count) == .init(summary: "in", open: false, inside: 8..<8, next: 8), "a fold on one line")
expect(MarkdownFolds.fold(folded, at: 8, end: 9) == .init(summary: "Details", open: false, inside: 9..<9, next: 9), "a fold left open, with no summary")
expect(MarkdownFolds.fold(["<details>words"], at: 0, end: 1) == nil, "words beside a fold's tag")
if wrong > 0 { exit(1) }
// Every write the app makes, for check-writes.mjs to hold to its lexicon.
ios/check-models.sh+1−1
@@ -6,6 +6,6 @@ set -eu
cd "$(dirname "$0")/.."
out=$(mktemp -d)
trap 'rm -rf "$out"' EXIT
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/Knotless/Support/MarkdownTable.swift ios/Knotless/Support/MarkdownTasks.swift ios/ModelCheck/main.swift
swiftc -o "$out/check" ios/Knotless/API/Models.swift ios/Knotless/API/Writes.swift ios/Knotless/API/Standing.swift ios/Knotless/Support/Identicon.swift ios/Knotless/Support/MarkdownPlace.swift ios/Knotless/Support/MarkdownFences.swift ios/Knotless/Support/MarkdownTable.swift ios/Knotless/Support/MarkdownTasks.swift ios/Knotless/Support/MarkdownFolds.swift ios/ModelCheck/main.swift
"$out/check" harness/shots/fixtures.json "$out/writes.json"
node ios/check-writes.mjs "$out/writes.json"
web/src/app.css+12−0
@@ -2834,6 +2834,18 @@ a.row:hover {
padding-left: var(--space-4);
margin-left: 0;
}
.prose details {
margin: 0 0 var(--space-4);
}
.prose summary {
cursor: pointer;
}
.prose details[open] > summary {
margin-bottom: var(--space-2);
}
.prose details > :last-child {
margin-bottom: 0;
}
.prose .diagram {
margin: 0 0 var(--space-4);
line-height: normal;