feat(booth): render .md/.txt/.log inline in the gallery, collapsible + closable
Docs used to render as a clumsy link that navigated to a separate page. They now render in place: build_gallery pre-renders each doc (markdown -> HTML, plain text raw) and the gallery shows it inside a native <details open> disclosure that spans the full grid width so prose has a readable measure. The doc bar carries: a collapse chevron (the whole <details> summary toggles, works with JS off), a full-page link (still reaches the standalone viewer), a download link, and a session-close ✕. The ✕ needed stopPropagation + preventDefault because it lives inside <summary> — otherwise its click would toggle the disclosure instead of hiding the item. Close is JS (progressive enhancement); collapse is native. Two design points: - Plain text is returned RAW from build_gallery and escaped by the template inside <pre>. Pre-escaping in Python plus Jinja autoescape would double-encode angle brackets; a test pins the single-escape. - Inlining is bounded by DOC_MAX_BYTES. A doc over the limit keeps the old link-out behaviour rather than being rendered into every index load; a test covers the fallback. The shared .markdown-body / .textview typography moved from doc.html's scoped <style> into base.html so the inline body and the full-page view render identically; doc.html keeps only its page-layout wrapper. Updated the pre-existing test_gallery_links_docs_to_view: it asserted the old link-out behaviour the operator asked to change, so it now asserts the inline render plus the surviving full-page and download affordances. 61 pass. Verified live: markdown renders with headings/table/blockquote/code, txt preserves whitespace and single-escapes, collapse and ✕-close both work.
This commit is contained in:
+19
-1
@@ -269,13 +269,31 @@ def build_gallery(child: Path) -> list[dict]:
|
|||||||
if rel in sidecars:
|
if rel in sidecars:
|
||||||
continue
|
continue
|
||||||
p = by_rel[rel]
|
p = by_rel[rel]
|
||||||
|
dkind = doc_kind(p.name)
|
||||||
|
rendered = None
|
||||||
|
rendered_html = False
|
||||||
|
# Pre-render docs so the gallery can show them INLINE (collapsible)
|
||||||
|
# instead of linking out to a separate page. Bounded by DOC_MAX_BYTES:
|
||||||
|
# a giant log stays a download link rather than being inlined into every
|
||||||
|
# index render. Markdown → HTML (marked safe in the template); plain text
|
||||||
|
# is returned RAW and the template escapes it inside <pre> — pre-escaping
|
||||||
|
# here would double-encode under Jinja autoescape.
|
||||||
|
if dkind is not None:
|
||||||
|
try:
|
||||||
|
if p.stat().st_size <= DOC_MAX_BYTES:
|
||||||
|
text = p.read_text(errors="replace")
|
||||||
|
rendered, rendered_html = render_doc(text, dkind)
|
||||||
|
except OSError:
|
||||||
|
rendered = None
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"name": rel,
|
"name": rel,
|
||||||
"kind": classify(p.name),
|
"kind": classify(p.name),
|
||||||
"doc": doc_kind(p.name),
|
"doc": dkind,
|
||||||
"url": quote(rel, safe="/"),
|
"url": quote(rel, safe="/"),
|
||||||
"caption": caption.get(rel),
|
"caption": caption.get(rel),
|
||||||
|
"rendered": rendered,
|
||||||
|
"rendered_html": rendered_html,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -212,6 +212,50 @@
|
|||||||
.item figcaption{padding:.6rem .85rem .75rem;color:var(--fg-2);font-size:.78rem;
|
.item figcaption{padding:.6rem .85rem .75rem;color:var(--fg-2);font-size:.78rem;
|
||||||
font-family:var(--font-mono);letter-spacing:.02em;border-top:1px solid var(--border-subtle);word-break:break-word}
|
font-family:var(--font-mono);letter-spacing:.02em;border-top:1px solid var(--border-subtle);word-break:break-word}
|
||||||
.item-audio figcaption,.item-other figcaption{border-top:none}
|
.item-audio figcaption,.item-other figcaption{border-top:none}
|
||||||
|
|
||||||
|
/* Inline doc rendering — a .md/.txt/.log shows in place, collapsible and
|
||||||
|
closable, instead of a link to a separate page. The item spans the full
|
||||||
|
grid width so prose has a readable measure. */
|
||||||
|
.item-doc{grid-column:1 / -1}
|
||||||
|
.item-doc.is-closed{display:none}
|
||||||
|
.doc-inline{display:block}
|
||||||
|
.doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:.55rem;
|
||||||
|
padding:.6rem .85rem;font-family:var(--font-mono);font-size:.8rem;color:var(--fg-2);
|
||||||
|
background:var(--rk-well);border-bottom:1px solid var(--border-subtle);user-select:none}
|
||||||
|
.doc-inline > .doc-bar::-webkit-details-marker{display:none}
|
||||||
|
.doc-chevron{color:var(--fg-3);transition:transform .12s ease;font-size:.7rem}
|
||||||
|
.doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)}
|
||||||
|
.doc-name{color:var(--fg-1);word-break:break-all}
|
||||||
|
.doc-spacer{flex:1}
|
||||||
|
.doc-act{color:var(--fg-3);text-decoration:none;padding:.1rem .35rem;border-radius:5px;
|
||||||
|
font-size:.9rem;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit}
|
||||||
|
.doc-act:hover{color:var(--aus-bright-cyan,#42dcd1);background:var(--rk-deep)}
|
||||||
|
.doc-close:hover{color:var(--aus-red,#ff6b6b)}
|
||||||
|
.doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:1rem 1.15rem}
|
||||||
|
.doc-body.textview{background:var(--rk-panel)}
|
||||||
|
|
||||||
|
/* Shared doc typography — used by the inline body above AND the full-page
|
||||||
|
doc view (doc.html). Kept here so both surfaces render identically. */
|
||||||
|
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
|
||||||
|
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
|
||||||
|
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
|
||||||
|
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
|
||||||
|
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
|
||||||
|
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
|
||||||
|
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
|
||||||
|
.markdown-body :first-child{margin-top:0}
|
||||||
|
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
|
||||||
|
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
|
||||||
|
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
|
||||||
|
padding:.12em .38em;border-radius:5px}
|
||||||
|
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
|
||||||
|
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
|
||||||
|
.markdown-body pre code{background:none;padding:0}
|
||||||
|
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
|
||||||
|
padding-left:1em;color:var(--fg-2);margin-left:0}
|
||||||
|
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
|
||||||
|
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
|
||||||
|
.markdown-body img{max-width:100%}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -25,6 +25,29 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="gallery">
|
<div class="gallery">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
|
{% if it.doc and it.rendered is not none %}
|
||||||
|
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||||
|
separate page. <details open> is native collapse (works with JS off);
|
||||||
|
the ✕ hides the item for the session (JS, progressive enhancement).
|
||||||
|
The item spans the full grid width so prose has room to read. #}
|
||||||
|
<figure class="item item-doc" data-name="{{ it.name }}">
|
||||||
|
<details class="doc-inline" open>
|
||||||
|
<summary class="doc-bar">
|
||||||
|
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||||
|
<span class="doc-name">{{ it.name }}</span>
|
||||||
|
<span class="doc-spacer"></span>
|
||||||
|
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||||||
|
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||||
|
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||||||
|
</summary>
|
||||||
|
{% if it.rendered_html %}
|
||||||
|
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||||||
|
{% else %}
|
||||||
|
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
</figure>
|
||||||
|
{% else %}
|
||||||
<figure class="item item-{{ it.kind }}">
|
<figure class="item item-{{ it.kind }}">
|
||||||
{% if it.kind == 'image' %}
|
{% if it.kind == 'image' %}
|
||||||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||||||
@@ -36,6 +59,7 @@
|
|||||||
{% elif it.kind == 'audio' %}
|
{% elif it.kind == 'audio' %}
|
||||||
<audio controls preload="none" src="{{ it.url }}"></audio>
|
<audio controls preload="none" src="{{ it.url }}"></audio>
|
||||||
{% elif it.doc %}
|
{% elif it.doc %}
|
||||||
|
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
|
||||||
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
|
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
|
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
|
||||||
@@ -49,6 +73,7 @@
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</figure>
|
</figure>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -85,5 +110,21 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
||||||
|
inside <summary>, so without this its click would just toggle the <details>
|
||||||
|
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
||||||
|
"collapse". Collapse stays available via the rest of the summary bar. With
|
||||||
|
JS off the button is inert and collapse via <details> still works. */
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
var item = btn.closest('.item-doc');
|
||||||
|
if (item) item.classList.add('is-closed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,26 +15,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
|
/* .markdown-body and .textview now live in base.html (shared with the inline
|
||||||
|
gallery view). Only the full-page layout wrapper is page-specific. */
|
||||||
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
|
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
|
||||||
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
|
.docview .textview{overflow-x:auto}
|
||||||
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
|
|
||||||
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
|
|
||||||
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
|
|
||||||
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
|
|
||||||
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
|
|
||||||
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
|
|
||||||
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
|
|
||||||
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
|
|
||||||
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
|
|
||||||
padding:.12em .38em;border-radius:5px}
|
|
||||||
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
|
|
||||||
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
|
|
||||||
.markdown-body pre code{background:none;padding:0}
|
|
||||||
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
|
|
||||||
padding-left:1em;color:var(--fg-2);margin-left:0}
|
|
||||||
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
|
|
||||||
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
|
|
||||||
.markdown-body img{max-width:100%}
|
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
|
|||||||
+93
-5
@@ -105,6 +105,89 @@ def test_build_gallery_folds_caption_sidecars(tmp_path):
|
|||||||
assert "loose.txt" in by_name # a caption with nothing to caption stays visible
|
assert "loose.txt" in by_name # a caption with nothing to caption stays visible
|
||||||
|
|
||||||
|
|
||||||
|
# ---- inline doc rendering ---------------------------------------------------
|
||||||
|
#
|
||||||
|
# .md / .txt / .log docs render INLINE in the gallery (collapsible), not as a
|
||||||
|
# clumsy link to a separate page. build_gallery pre-renders the content so the
|
||||||
|
# template stays logicless.
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_gallery_prerenders_markdown_inline(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / "report.md").write_text("# Title\n\nsome **bold** text\n")
|
||||||
|
|
||||||
|
it = next(i for i in build_gallery(booth) if i["name"] == "report.md")
|
||||||
|
|
||||||
|
assert it["doc"] == "markdown"
|
||||||
|
assert it["rendered_html"] is True
|
||||||
|
assert "<h1>Title</h1>" in it["rendered"]
|
||||||
|
assert "<strong>bold</strong>" in it["rendered"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_gallery_prerenders_text_as_raw(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / "notes.txt").write_text("plain <not html> line")
|
||||||
|
|
||||||
|
it = next(i for i in build_gallery(booth) if i["name"] == "notes.txt")
|
||||||
|
|
||||||
|
assert it["doc"] == "text"
|
||||||
|
assert it["rendered_html"] is False
|
||||||
|
# Raw text is NOT pre-escaped here — the template escapes it inside <pre>.
|
||||||
|
# Pre-escaping plus template autoescape would double-encode the angle brackets.
|
||||||
|
assert it["rendered"] == "plain <not html> line"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_gallery_oversize_doc_is_not_inlined(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
big = "x" * (2 * 1024 * 1024 + 10) # over DOC_MAX_BYTES
|
||||||
|
(booth / "huge.log").write_text(big)
|
||||||
|
|
||||||
|
it = next(i for i in build_gallery(booth) if i["name"] == "huge.log")
|
||||||
|
|
||||||
|
assert it["doc"] == "text"
|
||||||
|
assert it["rendered"] is None # too big to inline; template falls back to a link
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_doc_item_has_no_rendered_field(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_touch(booth / "shot.png")
|
||||||
|
|
||||||
|
it = next(i for i in build_gallery(booth) if i["name"] == "shot.png")
|
||||||
|
|
||||||
|
assert it["doc"] is None
|
||||||
|
assert it["rendered"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_booth_page_renders_markdown_inline_collapsible(client):
|
||||||
|
c, data = client
|
||||||
|
(data / "run1").mkdir()
|
||||||
|
(data / "run1" / "brief.md").write_text("# Heading\n\nbody line\n")
|
||||||
|
|
||||||
|
html = c.get("/b/run1/").text
|
||||||
|
|
||||||
|
# rendered inline, inside a native <details> disclosure — no navigation
|
||||||
|
assert "<details" in html
|
||||||
|
assert "<h1>Heading</h1>" in html
|
||||||
|
# and the raw-file link is still available for download / full view
|
||||||
|
assert "brief.md" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_booth_page_inlines_txt_without_double_escaping(client):
|
||||||
|
c, data = client
|
||||||
|
(data / "run1").mkdir()
|
||||||
|
(data / "run1" / "log.txt").write_text("value <x> & <y>")
|
||||||
|
|
||||||
|
html = c.get("/b/run1/").text
|
||||||
|
|
||||||
|
# exactly one level of HTML-escaping (template autoescape inside <pre>),
|
||||||
|
# not the double-encoding that pre-escaping in Python would produce
|
||||||
|
assert "value <x> & <y>" in html
|
||||||
|
assert "&lt;" not in html
|
||||||
|
|
||||||
|
|
||||||
# ---- HTTP surface -----------------------------------------------------------
|
# ---- HTTP surface -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -487,15 +570,20 @@ def test_view_text_shows_preformatted(client):
|
|||||||
assert "attachment" not in r.headers.get("content-disposition", "")
|
assert "attachment" not in r.headers.get("content-disposition", "")
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_links_docs_to_view(client):
|
def test_gallery_inlines_docs_with_fullpage_and_download_affordances(client):
|
||||||
|
# Docs now render INLINE in the gallery (see the inline-doc tests above),
|
||||||
|
# not as a link. The full-page viewer stays reachable via the ⤢ affordance,
|
||||||
|
# and the raw file via a download link — but the doc content itself is on
|
||||||
|
# the page, not behind a click.
|
||||||
c, data = client
|
c, data = client
|
||||||
d = data / "run1"; d.mkdir()
|
d = data / "run1"; d.mkdir()
|
||||||
(d / "readme.md").write_text("# hi")
|
(d / "readme.md").write_text("# hi")
|
||||||
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> own item
|
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> own item
|
||||||
page = c.get("/b/run1/")
|
page = c.get("/b/run1/").text
|
||||||
assert "view?f=readme.md" in page.text # md -> viewer
|
assert "<h1>hi</h1>" in page # md rendered inline
|
||||||
assert "view?f=notes.txt" in page.text # txt -> viewer
|
assert "hello" in page # txt shown inline
|
||||||
assert 'href="readme.md" download' not in page.text # not a forced download
|
assert "view?f=readme.md" in page # full-page viewer still linked (⤢)
|
||||||
|
assert 'href="readme.md" download' in page # download affordance present
|
||||||
|
|
||||||
|
|
||||||
# ---- image viewer prev/next nav ---------------------------------------------
|
# ---- image viewer prev/next nav ---------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user