feat(web): add blob and raw pages with chroma highlighting
GET /<repo>/blob/<ref>/<path> shows a file: text under 1 MiB highlighted with chroma (token classes only, no inline style; the theme is the static 56-chroma.css generated by scripts/chroma-css.go onto the --syn-* tokens), numbered lines with #L12 anchors and #L12-L20 ranges (the mockup's hx-on selection), Markdown rendered with its source under a fold, a notice with the raw link for files over 1 MiB and for binary files, and png/jpeg/gif/webp as <img src=raw>. SVG is never an image or inline: it is text or a binary state. Bidi, format and control characters are replaced by visible <U+XXXX> markers in the code view and in names. Files over 20000 lines show the first 20000. GET /<repo>/raw/<ref>/<path> streams the blob: text/plain; charset=utf-8 for text, an image type sniffed from the bytes for the four raster formats, else application/octet-stream as an attachment; nosniff, a sandbox CSP (default-src 'none'; sandbox), no-cache for refs and immutable for a full commit id, ETag from the blob id. Tests: golden pages for every kind of file, raw headers and bytes, refs with a slash, tags and commit ids, 404s, a private repository, Trojan Source, no inline script or style.
- commit
- 652ddb1706570aa8b9612ff116c0a42578a1c6a2
- author
- brice · · 2026-10-10 13:24 UTC
- committer
- brice · · 2026-10-10 13:25 UTC
- parent
- 20d1f28
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +4716 -0
A cmd/ermite/testdata/web/blob-binary.html +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>data.bin · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/data.bin" aria-current="page">data.bin</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>data.bin</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/data.bin">raw</a> | |
| 65 | + | <a href="/files/commits/main/data.bin">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <p class="notice" role="note">binary file (application/octet-stream, 10 B): not shown. use raw.</p> | |
| 70 | + | </main> | |
| 71 | + | <aside class="rail" aria-label="Context"> | |
| 72 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 73 | + | <summary>file · last change · selection</summary> | |
| 74 | + | <section aria-labelledby="r-file"> | |
| 75 | + | <h2 id="r-file">file</h2> | |
| 76 | + | <dl class="kv"> | |
| 77 | + | <dt>size</dt> | |
| 78 | + | <dd>10 B</dd> | |
| 79 | + | <dt>type</dt> | |
| 80 | + | <dd>application/octet-stream</dd> | |
| 81 | + | </dl> | |
| 82 | + | </section> | |
| 83 | + | <section aria-labelledby="r-last"> | |
| 84 | + | <h2 id="r-last">last change <a href="/files/commits/main/data.bin">history</a> | |
| 85 | + | </h2> | |
| 86 | + | <p> | |
| 87 | + | <a href="/files/commit/SHA40">SHA7</a> add data.bin</p> | |
| 88 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 89 | + | </p> | |
| 90 | + | </section> | |
| 91 | + | </details> | |
| 92 | + | </aside> | |
| 93 | + | </div> | |
| 94 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 95 | + | <div class="panel-head"> | |
| 96 | + | <span>keys</span> | |
| 97 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 98 | + | </div> | |
| 99 | + | <dl class="keys"> | |
| 100 | + | <div> | |
| 101 | + | <dt> | |
| 102 | + | <kbd>j</kbd> | |
| 103 | + | <kbd>k</kbd> | |
| 104 | + | </dt> | |
| 105 | + | <dd>move cursor down / up</dd> | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <dt> | |
| 109 | + | <kbd>enter</kbd> | |
| 110 | + | </dt> | |
| 111 | + | <dd>open row under cursor</dd> | |
| 112 | + | </div> | |
| 113 | + | <div> | |
| 114 | + | <dt> | |
| 115 | + | <kbd>g</kbd> | |
| 116 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 117 | + | </dt> | |
| 118 | + | <dd>first / last row</dd> | |
| 119 | + | </div> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>/</kbd> | |
| 123 | + | </dt> | |
| 124 | + | <dd>focus the filter</dd> | |
| 125 | + | </div> | |
| 126 | + | <div> | |
| 127 | + | <dt> | |
| 128 | + | <kbd>g h</kbd> | |
| 129 | + | <kbd>g t</kbd> | |
| 130 | + | </dt> | |
| 131 | + | <dd>go home / tree</dd> | |
| 132 | + | </div> | |
| 133 | + | <div> | |
| 134 | + | <dt> | |
| 135 | + | <kbd>g c</kbd> | |
| 136 | + | <kbd>g r</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>go commits / refs</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>b</kbd> | |
| 143 | + | </dt> | |
| 144 | + | <dd>switch ref (branches, tags)</dd> | |
| 145 | + | </div> | |
| 146 | + | <div> | |
| 147 | + | <dt> | |
| 148 | + | <kbd>:</kbd> | |
| 149 | + | <kbd>f</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>go to: section, ref, file, repo</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>y</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>s</kbd> | |
| 162 | + | <kbd>t</kbd> | |
| 163 | + | <kbd>c</kbd> | |
| 164 | + | <kbd>r</kbd> | |
| 165 | + | </dt> | |
| 166 | + | <dd>repo sections (when shown)</dd> | |
| 167 | + | </div> | |
| 168 | + | <div> | |
| 169 | + | <dt> | |
| 170 | + | <kbd>d</kbd> | |
| 171 | + | <kbd>u</kbd> | |
| 172 | + | <kbd>x</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>commit: split / unified / collapse all</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>close panel</dd> | |
| 181 | + | </div> | |
| 182 | + | </dl> | |
| 183 | + | </dialog> | |
| 184 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 185 | + | <div class="panel-head"> | |
| 186 | + | <label for="refs-filter">ref></label> | |
| 187 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 188 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 189 | + | </div> | |
| 190 | + | <div class="panel-cols" data-picklist> | |
| 191 | + | <section aria-label="Branches"> | |
| 192 | + | <h2>branches <span class="mute">2</span> | |
| 193 | + | </h2> | |
| 194 | + | <ul> | |
| 195 | + | <li> | |
| 196 | + | <a href="/files/tree/feature/x"> | |
| 197 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 198 | + | </li> | |
| 199 | + | <li> | |
| 200 | + | <a href="/files/tree/main" aria-current="true"> | |
| 201 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 202 | + | </li> | |
| 203 | + | </ul> | |
| 204 | + | </section> | |
| 205 | + | <section aria-label="Tags"> | |
| 206 | + | <h2>tags <span class="mute">1</span> | |
| 207 | + | </h2> | |
| 208 | + | <ul> | |
| 209 | + | <li> | |
| 210 | + | <a href="/files/tree/v1.0"> | |
| 211 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 212 | + | </li> | |
| 213 | + | </ul> | |
| 214 | + | </section> | |
| 215 | + | </div> | |
| 216 | + | </dialog> | |
| 217 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 218 | + | <div class="panel-head"> | |
| 219 | + | <label for="cmd-q" class="hi">:</label> | |
| 220 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 221 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 222 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 223 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 224 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 225 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 226 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 227 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 228 | + | </div> | |
| 229 | + | <ul id="cmd-list" class="cmd-list"> | |
| 230 | + | <li data-k="go home" data-cursor> | |
| 231 | + | <a href="/" tabindex="-1"> | |
| 232 | + | <span class="k">go</span>home</a> | |
| 233 | + | </li> | |
| 234 | + | <li data-k="section summary"> | |
| 235 | + | <a href="/files" tabindex="-1"> | |
| 236 | + | <span class="k">section</span>summary</a> | |
| 237 | + | </li> | |
| 238 | + | <li data-k="section tree"> | |
| 239 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 240 | + | <span class="k">section</span>tree</a> | |
| 241 | + | </li> | |
| 242 | + | <li data-k="section commits"> | |
| 243 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 244 | + | <span class="k">section</span>commits</a> | |
| 245 | + | </li> | |
| 246 | + | <li data-k="section refs"> | |
| 247 | + | <a href="/files/refs" tabindex="-1"> | |
| 248 | + | <span class="k">section</span>refs</a> | |
| 249 | + | </li> | |
| 250 | + | <li data-k="branch feature/x"> | |
| 251 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 252 | + | <span class="k">branch</span>feature/x</a> | |
| 253 | + | </li> | |
| 254 | + | <li data-k="branch main"> | |
| 255 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 256 | + | <span class="k">branch</span>main</a> | |
| 257 | + | </li> | |
| 258 | + | <li data-k="tag v1.0"> | |
| 259 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 260 | + | <span class="k">tag</span>v1.0</a> | |
| 261 | + | </li> | |
| 262 | + | </ul> | |
| 263 | + | </dialog> | |
| 264 | + | <footer class="bar status" aria-label="Status"> | |
| 265 | + | <span class="mode">blob</span> | |
| 266 | + | <span class="st-ctx">data.bin</span> | |
| 267 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 268 | + | <span class="hi">:</span> go to…</button> | |
| 269 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 270 | + | </span> | |
| 271 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 272 | + | <span class="st-src"> | |
| 273 | + | <span class="st-ver">ermite devel · </span> | |
| 274 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 275 | + | </span> | |
| 276 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 277 | + | </footer> | |
| 278 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 279 | + | if (q('dialog[open]').count) return; | |
| 280 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 281 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 282 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 283 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 284 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 285 | + | })(1); | |
| 286 | + | k-k from:body -> ((d) => { | |
| 287 | + | if (q('dialog[open]').count) return; | |
| 288 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 289 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 290 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 291 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 292 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 293 | + | })(-1); | |
| 294 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 295 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 296 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 297 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 298 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 299 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 300 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 301 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 302 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 303 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 304 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 305 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 306 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 307 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 308 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 309 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 310 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 311 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 312 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 313 | + | k-permalink from:body -> { | |
| 314 | + | let b = document.body.dataset; | |
| 315 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 316 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 317 | + | navigator.clipboard?.writeText(location.href); | |
| 318 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 319 | + | }"> | |
| 320 | + | </div> | |
| 321 | + | </body> | |
| 322 | + | </html> | |
A cmd/ermite/testdata/web/blob-code.html +393 -0
| @@ -0,0 +1,393 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>main.go · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/main.go" aria-current="page">main.go</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>main.go</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/main.go">raw</a> | |
| 65 | + | <a href="/files/commits/main/main.go">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 70 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 71 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 72 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 73 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 74 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 75 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 76 | + | <code> | |
| 77 | + | <span class="line" id="L1"> | |
| 78 | + | <a class="ln" href="#L1">1</a> | |
| 79 | + | <span class="kn">package</span> | |
| 80 | + | <span class="w"> | |
| 81 | + | </span> | |
| 82 | + | <span class="nx">main</span> | |
| 83 | + | </span> | |
| 84 | + | <span class="line" id="L2"> | |
| 85 | + | <a class="ln" href="#L2">2</a> | |
| 86 | + | </span> | |
| 87 | + | <span class="line" id="L3"> | |
| 88 | + | <a class="ln" href="#L3">3</a> | |
| 89 | + | <span class="kn">import</span> | |
| 90 | + | <span class="w"> | |
| 91 | + | </span> | |
| 92 | + | <span class="s">"fmt"</span> | |
| 93 | + | </span> | |
| 94 | + | <span class="line" id="L4"> | |
| 95 | + | <a class="ln" href="#L4">4</a> | |
| 96 | + | </span> | |
| 97 | + | <span class="line" id="L5"> | |
| 98 | + | <a class="ln" href="#L5">5</a> | |
| 99 | + | <span class="c1">// main prints.</span> | |
| 100 | + | </span> | |
| 101 | + | <span class="line" id="L6"> | |
| 102 | + | <a class="ln" href="#L6">6</a> | |
| 103 | + | <span class="kd">func</span> | |
| 104 | + | <span class="w"> | |
| 105 | + | </span> | |
| 106 | + | <span class="nf">main</span> | |
| 107 | + | <span class="p">()</span> | |
| 108 | + | <span class="w"> | |
| 109 | + | </span> | |
| 110 | + | <span class="p">{</span> | |
| 111 | + | </span> | |
| 112 | + | <span class="line" id="L7"> | |
| 113 | + | <a class="ln" href="#L7">7</a> | |
| 114 | + | <span class="w"> | |
| 115 | + | </span> | |
| 116 | + | <span class="nx">fmt</span> | |
| 117 | + | <span class="p">.</span> | |
| 118 | + | <span class="nf">Println</span> | |
| 119 | + | <span class="p">(</span> | |
| 120 | + | <span class="s">"hi <b>"</span> | |
| 121 | + | <span class="p">,</span> | |
| 122 | + | <span class="w"> | |
| 123 | + | </span> | |
| 124 | + | <span class="mi">42</span> | |
| 125 | + | <span class="p">)</span> | |
| 126 | + | </span> | |
| 127 | + | <span class="line" id="L8"> | |
| 128 | + | <a class="ln" href="#L8">8</a> | |
| 129 | + | <span class="p">}</span> | |
| 130 | + | </span> | |
| 131 | + | </code> | |
| 132 | + | </pre> | |
| 133 | + | </main> | |
| 134 | + | <aside class="rail" aria-label="Context"> | |
| 135 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 136 | + | <summary>file · last change · selection</summary> | |
| 137 | + | <section aria-labelledby="r-file"> | |
| 138 | + | <h2 id="r-file">file</h2> | |
| 139 | + | <dl class="kv"> | |
| 140 | + | <dt>size</dt> | |
| 141 | + | <dd>87 B</dd> | |
| 142 | + | <dt>lines</dt> | |
| 143 | + | <dd>8</dd> | |
| 144 | + | <dt>type</dt> | |
| 145 | + | <dd>Go</dd> | |
| 146 | + | </dl> | |
| 147 | + | </section> | |
| 148 | + | <section aria-labelledby="r-last"> | |
| 149 | + | <h2 id="r-last">last change <a href="/files/commits/main/main.go">history</a> | |
| 150 | + | </h2> | |
| 151 | + | <p> | |
| 152 | + | <a href="/files/commit/SHA40">SHA7</a> add main.go</p> | |
| 153 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 154 | + | </p> | |
| 155 | + | </section> | |
| 156 | + | <section aria-labelledby="r-sel"> | |
| 157 | + | <h2 id="r-sel">selection</h2> | |
| 158 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 159 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 160 | + | </section> | |
| 161 | + | </details> | |
| 162 | + | </aside> | |
| 163 | + | </div> | |
| 164 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 165 | + | <div class="panel-head"> | |
| 166 | + | <span>keys</span> | |
| 167 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 168 | + | </div> | |
| 169 | + | <dl class="keys"> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>j</kbd> | |
| 173 | + | <kbd>k</kbd> | |
| 174 | + | </dt> | |
| 175 | + | <dd>move cursor down / up</dd> | |
| 176 | + | </div> | |
| 177 | + | <div> | |
| 178 | + | <dt> | |
| 179 | + | <kbd>enter</kbd> | |
| 180 | + | </dt> | |
| 181 | + | <dd>open row under cursor</dd> | |
| 182 | + | </div> | |
| 183 | + | <div> | |
| 184 | + | <dt> | |
| 185 | + | <kbd>g</kbd> | |
| 186 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 187 | + | </dt> | |
| 188 | + | <dd>first / last row</dd> | |
| 189 | + | </div> | |
| 190 | + | <div> | |
| 191 | + | <dt> | |
| 192 | + | <kbd>/</kbd> | |
| 193 | + | </dt> | |
| 194 | + | <dd>focus the filter</dd> | |
| 195 | + | </div> | |
| 196 | + | <div> | |
| 197 | + | <dt> | |
| 198 | + | <kbd>g h</kbd> | |
| 199 | + | <kbd>g t</kbd> | |
| 200 | + | </dt> | |
| 201 | + | <dd>go home / tree</dd> | |
| 202 | + | </div> | |
| 203 | + | <div> | |
| 204 | + | <dt> | |
| 205 | + | <kbd>g c</kbd> | |
| 206 | + | <kbd>g r</kbd> | |
| 207 | + | </dt> | |
| 208 | + | <dd>go commits / refs</dd> | |
| 209 | + | </div> | |
| 210 | + | <div> | |
| 211 | + | <dt> | |
| 212 | + | <kbd>b</kbd> | |
| 213 | + | </dt> | |
| 214 | + | <dd>switch ref (branches, tags)</dd> | |
| 215 | + | </div> | |
| 216 | + | <div> | |
| 217 | + | <dt> | |
| 218 | + | <kbd>:</kbd> | |
| 219 | + | <kbd>f</kbd> | |
| 220 | + | </dt> | |
| 221 | + | <dd>go to: section, ref, file, repo</dd> | |
| 222 | + | </div> | |
| 223 | + | <div> | |
| 224 | + | <dt> | |
| 225 | + | <kbd>y</kbd> | |
| 226 | + | </dt> | |
| 227 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 228 | + | </div> | |
| 229 | + | <div> | |
| 230 | + | <dt> | |
| 231 | + | <kbd>s</kbd> | |
| 232 | + | <kbd>t</kbd> | |
| 233 | + | <kbd>c</kbd> | |
| 234 | + | <kbd>r</kbd> | |
| 235 | + | </dt> | |
| 236 | + | <dd>repo sections (when shown)</dd> | |
| 237 | + | </div> | |
| 238 | + | <div> | |
| 239 | + | <dt> | |
| 240 | + | <kbd>d</kbd> | |
| 241 | + | <kbd>u</kbd> | |
| 242 | + | <kbd>x</kbd> | |
| 243 | + | </dt> | |
| 244 | + | <dd>commit: split / unified / collapse all</dd> | |
| 245 | + | </div> | |
| 246 | + | <div> | |
| 247 | + | <dt> | |
| 248 | + | <kbd>esc</kbd> | |
| 249 | + | </dt> | |
| 250 | + | <dd>close panel</dd> | |
| 251 | + | </div> | |
| 252 | + | </dl> | |
| 253 | + | </dialog> | |
| 254 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 255 | + | <div class="panel-head"> | |
| 256 | + | <label for="refs-filter">ref></label> | |
| 257 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 258 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 259 | + | </div> | |
| 260 | + | <div class="panel-cols" data-picklist> | |
| 261 | + | <section aria-label="Branches"> | |
| 262 | + | <h2>branches <span class="mute">2</span> | |
| 263 | + | </h2> | |
| 264 | + | <ul> | |
| 265 | + | <li> | |
| 266 | + | <a href="/files/tree/feature/x"> | |
| 267 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 268 | + | </li> | |
| 269 | + | <li> | |
| 270 | + | <a href="/files/tree/main" aria-current="true"> | |
| 271 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 272 | + | </li> | |
| 273 | + | </ul> | |
| 274 | + | </section> | |
| 275 | + | <section aria-label="Tags"> | |
| 276 | + | <h2>tags <span class="mute">1</span> | |
| 277 | + | </h2> | |
| 278 | + | <ul> | |
| 279 | + | <li> | |
| 280 | + | <a href="/files/tree/v1.0"> | |
| 281 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 282 | + | </li> | |
| 283 | + | </ul> | |
| 284 | + | </section> | |
| 285 | + | </div> | |
| 286 | + | </dialog> | |
| 287 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 288 | + | <div class="panel-head"> | |
| 289 | + | <label for="cmd-q" class="hi">:</label> | |
| 290 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 291 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 292 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 293 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 294 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 295 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 296 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 297 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 298 | + | </div> | |
| 299 | + | <ul id="cmd-list" class="cmd-list"> | |
| 300 | + | <li data-k="go home" data-cursor> | |
| 301 | + | <a href="/" tabindex="-1"> | |
| 302 | + | <span class="k">go</span>home</a> | |
| 303 | + | </li> | |
| 304 | + | <li data-k="section summary"> | |
| 305 | + | <a href="/files" tabindex="-1"> | |
| 306 | + | <span class="k">section</span>summary</a> | |
| 307 | + | </li> | |
| 308 | + | <li data-k="section tree"> | |
| 309 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 310 | + | <span class="k">section</span>tree</a> | |
| 311 | + | </li> | |
| 312 | + | <li data-k="section commits"> | |
| 313 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 314 | + | <span class="k">section</span>commits</a> | |
| 315 | + | </li> | |
| 316 | + | <li data-k="section refs"> | |
| 317 | + | <a href="/files/refs" tabindex="-1"> | |
| 318 | + | <span class="k">section</span>refs</a> | |
| 319 | + | </li> | |
| 320 | + | <li data-k="branch feature/x"> | |
| 321 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 322 | + | <span class="k">branch</span>feature/x</a> | |
| 323 | + | </li> | |
| 324 | + | <li data-k="branch main"> | |
| 325 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 326 | + | <span class="k">branch</span>main</a> | |
| 327 | + | </li> | |
| 328 | + | <li data-k="tag v1.0"> | |
| 329 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 330 | + | <span class="k">tag</span>v1.0</a> | |
| 331 | + | </li> | |
| 332 | + | </ul> | |
| 333 | + | </dialog> | |
| 334 | + | <footer class="bar status" aria-label="Status"> | |
| 335 | + | <span class="mode">blob</span> | |
| 336 | + | <span class="st-ctx">main.go</span> | |
| 337 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 338 | + | <span class="hi">:</span> go to…</button> | |
| 339 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 340 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 341 | + | </span> | |
| 342 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 343 | + | <span class="st-src"> | |
| 344 | + | <span class="st-ver">ermite devel · </span> | |
| 345 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 346 | + | </span> | |
| 347 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 348 | + | </footer> | |
| 349 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 350 | + | if (q('dialog[open]').count) return; | |
| 351 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 352 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 353 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 354 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 355 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 356 | + | })(1); | |
| 357 | + | k-k from:body -> ((d) => { | |
| 358 | + | if (q('dialog[open]').count) return; | |
| 359 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 360 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 361 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 362 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 363 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 364 | + | })(-1); | |
| 365 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 366 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 367 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 368 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 370 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 371 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 372 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 374 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 375 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 376 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 377 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 378 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 379 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 380 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 381 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 382 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 383 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 384 | + | k-permalink from:body -> { | |
| 385 | + | let b = document.body.dataset; | |
| 386 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 387 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 388 | + | navigator.clipboard?.writeText(location.href); | |
| 389 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 390 | + | }"> | |
| 391 | + | </div> | |
| 392 | + | </body> | |
| 393 | + | </html> | |
A cmd/ermite/testdata/web/blob-empty.html +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>empty.txt · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/empty.txt" aria-current="page">empty.txt</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>empty.txt</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/empty.txt">raw</a> | |
| 65 | + | <a href="/files/commits/main/empty.txt">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <p class="notice" role="note">empty file.</p> | |
| 70 | + | </main> | |
| 71 | + | <aside class="rail" aria-label="Context"> | |
| 72 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 73 | + | <summary>file · last change · selection</summary> | |
| 74 | + | <section aria-labelledby="r-file"> | |
| 75 | + | <h2 id="r-file">file</h2> | |
| 76 | + | <dl class="kv"> | |
| 77 | + | <dt>size</dt> | |
| 78 | + | <dd>0 B</dd> | |
| 79 | + | <dt>type</dt> | |
| 80 | + | <dd>text/plain</dd> | |
| 81 | + | </dl> | |
| 82 | + | </section> | |
| 83 | + | <section aria-labelledby="r-last"> | |
| 84 | + | <h2 id="r-last">last change <a href="/files/commits/main/empty.txt">history</a> | |
| 85 | + | </h2> | |
| 86 | + | <p> | |
| 87 | + | <a href="/files/commit/SHA40">SHA7</a> add empty.txt</p> | |
| 88 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 89 | + | </p> | |
| 90 | + | </section> | |
| 91 | + | </details> | |
| 92 | + | </aside> | |
| 93 | + | </div> | |
| 94 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 95 | + | <div class="panel-head"> | |
| 96 | + | <span>keys</span> | |
| 97 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 98 | + | </div> | |
| 99 | + | <dl class="keys"> | |
| 100 | + | <div> | |
| 101 | + | <dt> | |
| 102 | + | <kbd>j</kbd> | |
| 103 | + | <kbd>k</kbd> | |
| 104 | + | </dt> | |
| 105 | + | <dd>move cursor down / up</dd> | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <dt> | |
| 109 | + | <kbd>enter</kbd> | |
| 110 | + | </dt> | |
| 111 | + | <dd>open row under cursor</dd> | |
| 112 | + | </div> | |
| 113 | + | <div> | |
| 114 | + | <dt> | |
| 115 | + | <kbd>g</kbd> | |
| 116 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 117 | + | </dt> | |
| 118 | + | <dd>first / last row</dd> | |
| 119 | + | </div> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>/</kbd> | |
| 123 | + | </dt> | |
| 124 | + | <dd>focus the filter</dd> | |
| 125 | + | </div> | |
| 126 | + | <div> | |
| 127 | + | <dt> | |
| 128 | + | <kbd>g h</kbd> | |
| 129 | + | <kbd>g t</kbd> | |
| 130 | + | </dt> | |
| 131 | + | <dd>go home / tree</dd> | |
| 132 | + | </div> | |
| 133 | + | <div> | |
| 134 | + | <dt> | |
| 135 | + | <kbd>g c</kbd> | |
| 136 | + | <kbd>g r</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>go commits / refs</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>b</kbd> | |
| 143 | + | </dt> | |
| 144 | + | <dd>switch ref (branches, tags)</dd> | |
| 145 | + | </div> | |
| 146 | + | <div> | |
| 147 | + | <dt> | |
| 148 | + | <kbd>:</kbd> | |
| 149 | + | <kbd>f</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>go to: section, ref, file, repo</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>y</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>s</kbd> | |
| 162 | + | <kbd>t</kbd> | |
| 163 | + | <kbd>c</kbd> | |
| 164 | + | <kbd>r</kbd> | |
| 165 | + | </dt> | |
| 166 | + | <dd>repo sections (when shown)</dd> | |
| 167 | + | </div> | |
| 168 | + | <div> | |
| 169 | + | <dt> | |
| 170 | + | <kbd>d</kbd> | |
| 171 | + | <kbd>u</kbd> | |
| 172 | + | <kbd>x</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>commit: split / unified / collapse all</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>close panel</dd> | |
| 181 | + | </div> | |
| 182 | + | </dl> | |
| 183 | + | </dialog> | |
| 184 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 185 | + | <div class="panel-head"> | |
| 186 | + | <label for="refs-filter">ref></label> | |
| 187 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 188 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 189 | + | </div> | |
| 190 | + | <div class="panel-cols" data-picklist> | |
| 191 | + | <section aria-label="Branches"> | |
| 192 | + | <h2>branches <span class="mute">2</span> | |
| 193 | + | </h2> | |
| 194 | + | <ul> | |
| 195 | + | <li> | |
| 196 | + | <a href="/files/tree/feature/x"> | |
| 197 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 198 | + | </li> | |
| 199 | + | <li> | |
| 200 | + | <a href="/files/tree/main" aria-current="true"> | |
| 201 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 202 | + | </li> | |
| 203 | + | </ul> | |
| 204 | + | </section> | |
| 205 | + | <section aria-label="Tags"> | |
| 206 | + | <h2>tags <span class="mute">1</span> | |
| 207 | + | </h2> | |
| 208 | + | <ul> | |
| 209 | + | <li> | |
| 210 | + | <a href="/files/tree/v1.0"> | |
| 211 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 212 | + | </li> | |
| 213 | + | </ul> | |
| 214 | + | </section> | |
| 215 | + | </div> | |
| 216 | + | </dialog> | |
| 217 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 218 | + | <div class="panel-head"> | |
| 219 | + | <label for="cmd-q" class="hi">:</label> | |
| 220 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 221 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 222 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 223 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 224 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 225 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 226 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 227 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 228 | + | </div> | |
| 229 | + | <ul id="cmd-list" class="cmd-list"> | |
| 230 | + | <li data-k="go home" data-cursor> | |
| 231 | + | <a href="/" tabindex="-1"> | |
| 232 | + | <span class="k">go</span>home</a> | |
| 233 | + | </li> | |
| 234 | + | <li data-k="section summary"> | |
| 235 | + | <a href="/files" tabindex="-1"> | |
| 236 | + | <span class="k">section</span>summary</a> | |
| 237 | + | </li> | |
| 238 | + | <li data-k="section tree"> | |
| 239 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 240 | + | <span class="k">section</span>tree</a> | |
| 241 | + | </li> | |
| 242 | + | <li data-k="section commits"> | |
| 243 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 244 | + | <span class="k">section</span>commits</a> | |
| 245 | + | </li> | |
| 246 | + | <li data-k="section refs"> | |
| 247 | + | <a href="/files/refs" tabindex="-1"> | |
| 248 | + | <span class="k">section</span>refs</a> | |
| 249 | + | </li> | |
| 250 | + | <li data-k="branch feature/x"> | |
| 251 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 252 | + | <span class="k">branch</span>feature/x</a> | |
| 253 | + | </li> | |
| 254 | + | <li data-k="branch main"> | |
| 255 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 256 | + | <span class="k">branch</span>main</a> | |
| 257 | + | </li> | |
| 258 | + | <li data-k="tag v1.0"> | |
| 259 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 260 | + | <span class="k">tag</span>v1.0</a> | |
| 261 | + | </li> | |
| 262 | + | </ul> | |
| 263 | + | </dialog> | |
| 264 | + | <footer class="bar status" aria-label="Status"> | |
| 265 | + | <span class="mode">blob</span> | |
| 266 | + | <span class="st-ctx">empty.txt</span> | |
| 267 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 268 | + | <span class="hi">:</span> go to…</button> | |
| 269 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 270 | + | </span> | |
| 271 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 272 | + | <span class="st-src"> | |
| 273 | + | <span class="st-ver">ermite devel · </span> | |
| 274 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 275 | + | </span> | |
| 276 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 277 | + | </footer> | |
| 278 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 279 | + | if (q('dialog[open]').count) return; | |
| 280 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 281 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 282 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 283 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 284 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 285 | + | })(1); | |
| 286 | + | k-k from:body -> ((d) => { | |
| 287 | + | if (q('dialog[open]').count) return; | |
| 288 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 289 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 290 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 291 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 292 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 293 | + | })(-1); | |
| 294 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 295 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 296 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 297 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 298 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 299 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 300 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 301 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 302 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 303 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 304 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 305 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 306 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 307 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 308 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 309 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 310 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 311 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 312 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 313 | + | k-permalink from:body -> { | |
| 314 | + | let b = document.body.dataset; | |
| 315 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 316 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 317 | + | navigator.clipboard?.writeText(location.href); | |
| 318 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 319 | + | }"> | |
| 320 | + | </div> | |
| 321 | + | </body> | |
| 322 | + | </html> | |
A cmd/ermite/testdata/web/blob-image.html +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>pic.png · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/pic.png" aria-current="page">pic.png</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>pic.png</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/pic.png">raw</a> | |
| 65 | + | <a href="/files/commits/main/pic.png">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <p class="img-view"> | |
| 70 | + | <img src="/files/raw/main/pic.png" alt="pic.png"> | |
| 71 | + | </p> | |
| 72 | + | </main> | |
| 73 | + | <aside class="rail" aria-label="Context"> | |
| 74 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 75 | + | <summary>file · last change · selection</summary> | |
| 76 | + | <section aria-labelledby="r-file"> | |
| 77 | + | <h2 id="r-file">file</h2> | |
| 78 | + | <dl class="kv"> | |
| 79 | + | <dt>size</dt> | |
| 80 | + | <dd>88 B</dd> | |
| 81 | + | <dt>type</dt> | |
| 82 | + | <dd>image/png</dd> | |
| 83 | + | </dl> | |
| 84 | + | </section> | |
| 85 | + | <section aria-labelledby="r-last"> | |
| 86 | + | <h2 id="r-last">last change <a href="/files/commits/main/pic.png">history</a> | |
| 87 | + | </h2> | |
| 88 | + | <p> | |
| 89 | + | <a href="/files/commit/SHA40">SHA7</a> add pic.png</p> | |
| 90 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 91 | + | </p> | |
| 92 | + | </section> | |
| 93 | + | </details> | |
| 94 | + | </aside> | |
| 95 | + | </div> | |
| 96 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 97 | + | <div class="panel-head"> | |
| 98 | + | <span>keys</span> | |
| 99 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 100 | + | </div> | |
| 101 | + | <dl class="keys"> | |
| 102 | + | <div> | |
| 103 | + | <dt> | |
| 104 | + | <kbd>j</kbd> | |
| 105 | + | <kbd>k</kbd> | |
| 106 | + | </dt> | |
| 107 | + | <dd>move cursor down / up</dd> | |
| 108 | + | </div> | |
| 109 | + | <div> | |
| 110 | + | <dt> | |
| 111 | + | <kbd>enter</kbd> | |
| 112 | + | </dt> | |
| 113 | + | <dd>open row under cursor</dd> | |
| 114 | + | </div> | |
| 115 | + | <div> | |
| 116 | + | <dt> | |
| 117 | + | <kbd>g</kbd> | |
| 118 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 119 | + | </dt> | |
| 120 | + | <dd>first / last row</dd> | |
| 121 | + | </div> | |
| 122 | + | <div> | |
| 123 | + | <dt> | |
| 124 | + | <kbd>/</kbd> | |
| 125 | + | </dt> | |
| 126 | + | <dd>focus the filter</dd> | |
| 127 | + | </div> | |
| 128 | + | <div> | |
| 129 | + | <dt> | |
| 130 | + | <kbd>g h</kbd> | |
| 131 | + | <kbd>g t</kbd> | |
| 132 | + | </dt> | |
| 133 | + | <dd>go home / tree</dd> | |
| 134 | + | </div> | |
| 135 | + | <div> | |
| 136 | + | <dt> | |
| 137 | + | <kbd>g c</kbd> | |
| 138 | + | <kbd>g r</kbd> | |
| 139 | + | </dt> | |
| 140 | + | <dd>go commits / refs</dd> | |
| 141 | + | </div> | |
| 142 | + | <div> | |
| 143 | + | <dt> | |
| 144 | + | <kbd>b</kbd> | |
| 145 | + | </dt> | |
| 146 | + | <dd>switch ref (branches, tags)</dd> | |
| 147 | + | </div> | |
| 148 | + | <div> | |
| 149 | + | <dt> | |
| 150 | + | <kbd>:</kbd> | |
| 151 | + | <kbd>f</kbd> | |
| 152 | + | </dt> | |
| 153 | + | <dd>go to: section, ref, file, repo</dd> | |
| 154 | + | </div> | |
| 155 | + | <div> | |
| 156 | + | <dt> | |
| 157 | + | <kbd>y</kbd> | |
| 158 | + | </dt> | |
| 159 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 160 | + | </div> | |
| 161 | + | <div> | |
| 162 | + | <dt> | |
| 163 | + | <kbd>s</kbd> | |
| 164 | + | <kbd>t</kbd> | |
| 165 | + | <kbd>c</kbd> | |
| 166 | + | <kbd>r</kbd> | |
| 167 | + | </dt> | |
| 168 | + | <dd>repo sections (when shown)</dd> | |
| 169 | + | </div> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>d</kbd> | |
| 173 | + | <kbd>u</kbd> | |
| 174 | + | <kbd>x</kbd> | |
| 175 | + | </dt> | |
| 176 | + | <dd>commit: split / unified / collapse all</dd> | |
| 177 | + | </div> | |
| 178 | + | <div> | |
| 179 | + | <dt> | |
| 180 | + | <kbd>esc</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>close panel</dd> | |
| 183 | + | </div> | |
| 184 | + | </dl> | |
| 185 | + | </dialog> | |
| 186 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 187 | + | <div class="panel-head"> | |
| 188 | + | <label for="refs-filter">ref></label> | |
| 189 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 190 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 191 | + | </div> | |
| 192 | + | <div class="panel-cols" data-picklist> | |
| 193 | + | <section aria-label="Branches"> | |
| 194 | + | <h2>branches <span class="mute">2</span> | |
| 195 | + | </h2> | |
| 196 | + | <ul> | |
| 197 | + | <li> | |
| 198 | + | <a href="/files/tree/feature/x"> | |
| 199 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 200 | + | </li> | |
| 201 | + | <li> | |
| 202 | + | <a href="/files/tree/main" aria-current="true"> | |
| 203 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 204 | + | </li> | |
| 205 | + | </ul> | |
| 206 | + | </section> | |
| 207 | + | <section aria-label="Tags"> | |
| 208 | + | <h2>tags <span class="mute">1</span> | |
| 209 | + | </h2> | |
| 210 | + | <ul> | |
| 211 | + | <li> | |
| 212 | + | <a href="/files/tree/v1.0"> | |
| 213 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 214 | + | </li> | |
| 215 | + | </ul> | |
| 216 | + | </section> | |
| 217 | + | </div> | |
| 218 | + | </dialog> | |
| 219 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 220 | + | <div class="panel-head"> | |
| 221 | + | <label for="cmd-q" class="hi">:</label> | |
| 222 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 223 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 224 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 225 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 226 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 227 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 228 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 229 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 230 | + | </div> | |
| 231 | + | <ul id="cmd-list" class="cmd-list"> | |
| 232 | + | <li data-k="go home" data-cursor> | |
| 233 | + | <a href="/" tabindex="-1"> | |
| 234 | + | <span class="k">go</span>home</a> | |
| 235 | + | </li> | |
| 236 | + | <li data-k="section summary"> | |
| 237 | + | <a href="/files" tabindex="-1"> | |
| 238 | + | <span class="k">section</span>summary</a> | |
| 239 | + | </li> | |
| 240 | + | <li data-k="section tree"> | |
| 241 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 242 | + | <span class="k">section</span>tree</a> | |
| 243 | + | </li> | |
| 244 | + | <li data-k="section commits"> | |
| 245 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 246 | + | <span class="k">section</span>commits</a> | |
| 247 | + | </li> | |
| 248 | + | <li data-k="section refs"> | |
| 249 | + | <a href="/files/refs" tabindex="-1"> | |
| 250 | + | <span class="k">section</span>refs</a> | |
| 251 | + | </li> | |
| 252 | + | <li data-k="branch feature/x"> | |
| 253 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 254 | + | <span class="k">branch</span>feature/x</a> | |
| 255 | + | </li> | |
| 256 | + | <li data-k="branch main"> | |
| 257 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 258 | + | <span class="k">branch</span>main</a> | |
| 259 | + | </li> | |
| 260 | + | <li data-k="tag v1.0"> | |
| 261 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 262 | + | <span class="k">tag</span>v1.0</a> | |
| 263 | + | </li> | |
| 264 | + | </ul> | |
| 265 | + | </dialog> | |
| 266 | + | <footer class="bar status" aria-label="Status"> | |
| 267 | + | <span class="mode">blob</span> | |
| 268 | + | <span class="st-ctx">pic.png</span> | |
| 269 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 270 | + | <span class="hi">:</span> go to…</button> | |
| 271 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 272 | + | </span> | |
| 273 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 274 | + | <span class="st-src"> | |
| 275 | + | <span class="st-ver">ermite devel · </span> | |
| 276 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 277 | + | </span> | |
| 278 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 279 | + | </footer> | |
| 280 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 281 | + | if (q('dialog[open]').count) return; | |
| 282 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 283 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 284 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 285 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 286 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 287 | + | })(1); | |
| 288 | + | k-k from:body -> ((d) => { | |
| 289 | + | if (q('dialog[open]').count) return; | |
| 290 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 291 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 292 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 293 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 294 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 295 | + | })(-1); | |
| 296 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 297 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 298 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 299 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 300 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 301 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 302 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 303 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 304 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 305 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 306 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 307 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 308 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 309 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 310 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 311 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 312 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 313 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 314 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 315 | + | k-permalink from:body -> { | |
| 316 | + | let b = document.body.dataset; | |
| 317 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 318 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 319 | + | navigator.clipboard?.writeText(location.href); | |
| 320 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 321 | + | }"> | |
| 322 | + | </div> | |
| 323 | + | </body> | |
| 324 | + | </html> | |
A cmd/ermite/testdata/web/blob-markdown.html +390 -0
| @@ -0,0 +1,390 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>docs/guide.md · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/tree/main/docs">docs</a> | |
| 37 | + | </li> | |
| 38 | + | <li> | |
| 39 | + | <a href="/files/blob/main/docs/guide.md" aria-current="page">guide.md</a> | |
| 40 | + | </li> | |
| 41 | + | </ol> | |
| 42 | + | </nav> | |
| 43 | + | <nav class="secs" aria-label="Sections"> | |
| 44 | + | <ul> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 53 | + | </li> | |
| 54 | + | <li> | |
| 55 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 56 | + | </li> | |
| 57 | + | </ul> | |
| 58 | + | </nav> | |
| 59 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 60 | + | </button> | |
| 61 | + | </header> | |
| 62 | + | <div class="work has-rail read"> | |
| 63 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 64 | + | <h1 class="file-h"> | |
| 65 | + | <span>guide.md</span> | |
| 66 | + | <span class="acts"> | |
| 67 | + | <a href="/files/raw/main/docs/guide.md">raw</a> | |
| 68 | + | <a href="/files/commits/main/docs/guide.md">history</a> | |
| 69 | + | <a href="/files/tree/main/docs">dir</a> | |
| 70 | + | </span> | |
| 71 | + | </h1> | |
| 72 | + | <article class="prose" aria-label="guide.md"> | |
| 73 | + | <h1 id="md-guide">Guide</h1> | |
| 74 | + | <h2 id="md-steps">Steps</h2> | |
| 75 | + | <p>Run it.</p> | |
| 76 | + | <pre> | |
| 77 | + | <code class="language-sh">make all | |
| 78 | + | </code> | |
| 79 | + | </pre> | |
| 80 | + | </article> | |
| 81 | + | <details class="source"> | |
| 82 | + | <summary>source</summary> | |
| 83 | + | <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 84 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 85 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 86 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 87 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 88 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 89 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 90 | + | <code> | |
| 91 | + | <span class="line" id="L1"> | |
| 92 | + | <a class="ln" href="#L1">1</a> | |
| 93 | + | <span class="gh"># Guide</span> | |
| 94 | + | </span> | |
| 95 | + | <span class="line" id="L2"> | |
| 96 | + | <a class="ln" href="#L2">2</a> | |
| 97 | + | </span> | |
| 98 | + | <span class="line" id="L3"> | |
| 99 | + | <a class="ln" href="#L3">3</a> | |
| 100 | + | <span class="gu">## Steps</span> | |
| 101 | + | </span> | |
| 102 | + | <span class="line" id="L4"> | |
| 103 | + | <a class="ln" href="#L4">4</a> | |
| 104 | + | </span> | |
| 105 | + | <span class="line" id="L5"> | |
| 106 | + | <a class="ln" href="#L5">5</a>Run it.</span> | |
| 107 | + | <span class="line" id="L6"> | |
| 108 | + | <a class="ln" href="#L6">6</a> | |
| 109 | + | </span> | |
| 110 | + | <span class="line" id="L7"> | |
| 111 | + | <a class="ln" href="#L7">7</a> | |
| 112 | + | <span class="s">```sh</span> | |
| 113 | + | </span> | |
| 114 | + | <span class="line" id="L8"> | |
| 115 | + | <a class="ln" href="#L8">8</a>make all</span> | |
| 116 | + | <span class="line" id="L9"> | |
| 117 | + | <a class="ln" href="#L9">9</a> | |
| 118 | + | <span class="s">```</span> | |
| 119 | + | </span> | |
| 120 | + | </code> | |
| 121 | + | </pre> | |
| 122 | + | </details> | |
| 123 | + | </main> | |
| 124 | + | <aside class="rail" aria-label="Context"> | |
| 125 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 126 | + | <summary>file · last change · selection</summary> | |
| 127 | + | <section aria-labelledby="r-file"> | |
| 128 | + | <h2 id="r-file">file</h2> | |
| 129 | + | <dl class="kv"> | |
| 130 | + | <dt>size</dt> | |
| 131 | + | <dd>47 B</dd> | |
| 132 | + | <dt>lines</dt> | |
| 133 | + | <dd>9</dd> | |
| 134 | + | <dt>type</dt> | |
| 135 | + | <dd>markdown</dd> | |
| 136 | + | </dl> | |
| 137 | + | </section> | |
| 138 | + | <section aria-labelledby="r-last"> | |
| 139 | + | <h2 id="r-last">last change <a href="/files/commits/main/docs/guide.md">history</a> | |
| 140 | + | </h2> | |
| 141 | + | <p> | |
| 142 | + | <a href="/files/commit/SHA40">SHA7</a> add docs/guide.md</p> | |
| 143 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 144 | + | </p> | |
| 145 | + | </section> | |
| 146 | + | <section aria-labelledby="r-outline"> | |
| 147 | + | <h2 id="r-outline">outline</h2> | |
| 148 | + | <nav class="outline" aria-label="Outline"> | |
| 149 | + | <a class="l1" href="#md-guide">Guide</a> | |
| 150 | + | <a class="l2" href="#md-steps">Steps</a> | |
| 151 | + | </nav> | |
| 152 | + | </section> | |
| 153 | + | <section aria-labelledby="r-sel"> | |
| 154 | + | <h2 id="r-sel">selection</h2> | |
| 155 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 156 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 157 | + | </section> | |
| 158 | + | </details> | |
| 159 | + | </aside> | |
| 160 | + | </div> | |
| 161 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 162 | + | <div class="panel-head"> | |
| 163 | + | <span>keys</span> | |
| 164 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 165 | + | </div> | |
| 166 | + | <dl class="keys"> | |
| 167 | + | <div> | |
| 168 | + | <dt> | |
| 169 | + | <kbd>j</kbd> | |
| 170 | + | <kbd>k</kbd> | |
| 171 | + | </dt> | |
| 172 | + | <dd>move cursor down / up</dd> | |
| 173 | + | </div> | |
| 174 | + | <div> | |
| 175 | + | <dt> | |
| 176 | + | <kbd>enter</kbd> | |
| 177 | + | </dt> | |
| 178 | + | <dd>open row under cursor</dd> | |
| 179 | + | </div> | |
| 180 | + | <div> | |
| 181 | + | <dt> | |
| 182 | + | <kbd>g</kbd> | |
| 183 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 184 | + | </dt> | |
| 185 | + | <dd>first / last row</dd> | |
| 186 | + | </div> | |
| 187 | + | <div> | |
| 188 | + | <dt> | |
| 189 | + | <kbd>/</kbd> | |
| 190 | + | </dt> | |
| 191 | + | <dd>focus the filter</dd> | |
| 192 | + | </div> | |
| 193 | + | <div> | |
| 194 | + | <dt> | |
| 195 | + | <kbd>g h</kbd> | |
| 196 | + | <kbd>g t</kbd> | |
| 197 | + | </dt> | |
| 198 | + | <dd>go home / tree</dd> | |
| 199 | + | </div> | |
| 200 | + | <div> | |
| 201 | + | <dt> | |
| 202 | + | <kbd>g c</kbd> | |
| 203 | + | <kbd>g r</kbd> | |
| 204 | + | </dt> | |
| 205 | + | <dd>go commits / refs</dd> | |
| 206 | + | </div> | |
| 207 | + | <div> | |
| 208 | + | <dt> | |
| 209 | + | <kbd>b</kbd> | |
| 210 | + | </dt> | |
| 211 | + | <dd>switch ref (branches, tags)</dd> | |
| 212 | + | </div> | |
| 213 | + | <div> | |
| 214 | + | <dt> | |
| 215 | + | <kbd>:</kbd> | |
| 216 | + | <kbd>f</kbd> | |
| 217 | + | </dt> | |
| 218 | + | <dd>go to: section, ref, file, repo</dd> | |
| 219 | + | </div> | |
| 220 | + | <div> | |
| 221 | + | <dt> | |
| 222 | + | <kbd>y</kbd> | |
| 223 | + | </dt> | |
| 224 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 225 | + | </div> | |
| 226 | + | <div> | |
| 227 | + | <dt> | |
| 228 | + | <kbd>s</kbd> | |
| 229 | + | <kbd>t</kbd> | |
| 230 | + | <kbd>c</kbd> | |
| 231 | + | <kbd>r</kbd> | |
| 232 | + | </dt> | |
| 233 | + | <dd>repo sections (when shown)</dd> | |
| 234 | + | </div> | |
| 235 | + | <div> | |
| 236 | + | <dt> | |
| 237 | + | <kbd>d</kbd> | |
| 238 | + | <kbd>u</kbd> | |
| 239 | + | <kbd>x</kbd> | |
| 240 | + | </dt> | |
| 241 | + | <dd>commit: split / unified / collapse all</dd> | |
| 242 | + | </div> | |
| 243 | + | <div> | |
| 244 | + | <dt> | |
| 245 | + | <kbd>esc</kbd> | |
| 246 | + | </dt> | |
| 247 | + | <dd>close panel</dd> | |
| 248 | + | </div> | |
| 249 | + | </dl> | |
| 250 | + | </dialog> | |
| 251 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 252 | + | <div class="panel-head"> | |
| 253 | + | <label for="refs-filter">ref></label> | |
| 254 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 255 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 256 | + | </div> | |
| 257 | + | <div class="panel-cols" data-picklist> | |
| 258 | + | <section aria-label="Branches"> | |
| 259 | + | <h2>branches <span class="mute">2</span> | |
| 260 | + | </h2> | |
| 261 | + | <ul> | |
| 262 | + | <li> | |
| 263 | + | <a href="/files/tree/feature/x"> | |
| 264 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 265 | + | </li> | |
| 266 | + | <li> | |
| 267 | + | <a href="/files/tree/main" aria-current="true"> | |
| 268 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 269 | + | </li> | |
| 270 | + | </ul> | |
| 271 | + | </section> | |
| 272 | + | <section aria-label="Tags"> | |
| 273 | + | <h2>tags <span class="mute">1</span> | |
| 274 | + | </h2> | |
| 275 | + | <ul> | |
| 276 | + | <li> | |
| 277 | + | <a href="/files/tree/v1.0"> | |
| 278 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 279 | + | </li> | |
| 280 | + | </ul> | |
| 281 | + | </section> | |
| 282 | + | </div> | |
| 283 | + | </dialog> | |
| 284 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 285 | + | <div class="panel-head"> | |
| 286 | + | <label for="cmd-q" class="hi">:</label> | |
| 287 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 288 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 289 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 290 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 291 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 292 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 293 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 294 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 295 | + | </div> | |
| 296 | + | <ul id="cmd-list" class="cmd-list"> | |
| 297 | + | <li data-k="go home" data-cursor> | |
| 298 | + | <a href="/" tabindex="-1"> | |
| 299 | + | <span class="k">go</span>home</a> | |
| 300 | + | </li> | |
| 301 | + | <li data-k="section summary"> | |
| 302 | + | <a href="/files" tabindex="-1"> | |
| 303 | + | <span class="k">section</span>summary</a> | |
| 304 | + | </li> | |
| 305 | + | <li data-k="section tree"> | |
| 306 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 307 | + | <span class="k">section</span>tree</a> | |
| 308 | + | </li> | |
| 309 | + | <li data-k="section commits"> | |
| 310 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 311 | + | <span class="k">section</span>commits</a> | |
| 312 | + | </li> | |
| 313 | + | <li data-k="section refs"> | |
| 314 | + | <a href="/files/refs" tabindex="-1"> | |
| 315 | + | <span class="k">section</span>refs</a> | |
| 316 | + | </li> | |
| 317 | + | <li data-k="branch feature/x"> | |
| 318 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 319 | + | <span class="k">branch</span>feature/x</a> | |
| 320 | + | </li> | |
| 321 | + | <li data-k="branch main"> | |
| 322 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 323 | + | <span class="k">branch</span>main</a> | |
| 324 | + | </li> | |
| 325 | + | <li data-k="tag v1.0"> | |
| 326 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 327 | + | <span class="k">tag</span>v1.0</a> | |
| 328 | + | </li> | |
| 329 | + | </ul> | |
| 330 | + | </dialog> | |
| 331 | + | <footer class="bar status" aria-label="Status"> | |
| 332 | + | <span class="mode">blob</span> | |
| 333 | + | <span class="st-ctx">guide.md</span> | |
| 334 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 335 | + | <span class="hi">:</span> go to…</button> | |
| 336 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 337 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 338 | + | </span> | |
| 339 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 340 | + | <span class="st-src"> | |
| 341 | + | <span class="st-ver">ermite devel · </span> | |
| 342 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 343 | + | </span> | |
| 344 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 345 | + | </footer> | |
| 346 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 347 | + | if (q('dialog[open]').count) return; | |
| 348 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 349 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 350 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 351 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 352 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 353 | + | })(1); | |
| 354 | + | k-k from:body -> ((d) => { | |
| 355 | + | if (q('dialog[open]').count) return; | |
| 356 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 360 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 361 | + | })(-1); | |
| 362 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 363 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 364 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 365 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 366 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 367 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 368 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 369 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 370 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 371 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 372 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 373 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 374 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 375 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 376 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 377 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 378 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 379 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 380 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 381 | + | k-permalink from:body -> { | |
| 382 | + | let b = document.body.dataset; | |
| 383 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 384 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 385 | + | navigator.clipboard?.writeText(location.href); | |
| 386 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 387 | + | }"> | |
| 388 | + | </div> | |
| 389 | + | </body> | |
| 390 | + | </html> | |
A cmd/ermite/testdata/web/blob-svg.html +348 -0
| @@ -0,0 +1,348 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>logo.svg · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/logo.svg" aria-current="page">logo.svg</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>logo.svg</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/logo.svg">raw</a> | |
| 65 | + | <a href="/files/commits/main/logo.svg">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 70 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 71 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 72 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 73 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 74 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 75 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 76 | + | <code> | |
| 77 | + | <span class="line" id="L1"> | |
| 78 | + | <a class="ln" href="#L1">1</a> | |
| 79 | + | <span class="nt"><svg</span> | |
| 80 | + | <span class="na">xmlns=</span> | |
| 81 | + | <span class="s">"http://www.w3.org/2000/svg"</span> | |
| 82 | + | <span class="na">onload=</span> | |
| 83 | + | <span class="s">"alert(1)"</span> | |
| 84 | + | <span class="nt">><script></span>alert(2)<span class="nt"></script></svg></span> | |
| 85 | + | </span> | |
| 86 | + | </code> | |
| 87 | + | </pre> | |
| 88 | + | </main> | |
| 89 | + | <aside class="rail" aria-label="Context"> | |
| 90 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 91 | + | <summary>file · last change · selection</summary> | |
| 92 | + | <section aria-labelledby="r-file"> | |
| 93 | + | <h2 id="r-file">file</h2> | |
| 94 | + | <dl class="kv"> | |
| 95 | + | <dt>size</dt> | |
| 96 | + | <dd>89 B</dd> | |
| 97 | + | <dt>lines</dt> | |
| 98 | + | <dd>1</dd> | |
| 99 | + | <dt>type</dt> | |
| 100 | + | <dd>XML</dd> | |
| 101 | + | </dl> | |
| 102 | + | </section> | |
| 103 | + | <section aria-labelledby="r-last"> | |
| 104 | + | <h2 id="r-last">last change <a href="/files/commits/main/logo.svg">history</a> | |
| 105 | + | </h2> | |
| 106 | + | <p> | |
| 107 | + | <a href="/files/commit/SHA40">SHA7</a> add logo.svg</p> | |
| 108 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 109 | + | </p> | |
| 110 | + | </section> | |
| 111 | + | <section aria-labelledby="r-sel"> | |
| 112 | + | <h2 id="r-sel">selection</h2> | |
| 113 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 114 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 115 | + | </section> | |
| 116 | + | </details> | |
| 117 | + | </aside> | |
| 118 | + | </div> | |
| 119 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 120 | + | <div class="panel-head"> | |
| 121 | + | <span>keys</span> | |
| 122 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 123 | + | </div> | |
| 124 | + | <dl class="keys"> | |
| 125 | + | <div> | |
| 126 | + | <dt> | |
| 127 | + | <kbd>j</kbd> | |
| 128 | + | <kbd>k</kbd> | |
| 129 | + | </dt> | |
| 130 | + | <dd>move cursor down / up</dd> | |
| 131 | + | </div> | |
| 132 | + | <div> | |
| 133 | + | <dt> | |
| 134 | + | <kbd>enter</kbd> | |
| 135 | + | </dt> | |
| 136 | + | <dd>open row under cursor</dd> | |
| 137 | + | </div> | |
| 138 | + | <div> | |
| 139 | + | <dt> | |
| 140 | + | <kbd>g</kbd> | |
| 141 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 142 | + | </dt> | |
| 143 | + | <dd>first / last row</dd> | |
| 144 | + | </div> | |
| 145 | + | <div> | |
| 146 | + | <dt> | |
| 147 | + | <kbd>/</kbd> | |
| 148 | + | </dt> | |
| 149 | + | <dd>focus the filter</dd> | |
| 150 | + | </div> | |
| 151 | + | <div> | |
| 152 | + | <dt> | |
| 153 | + | <kbd>g h</kbd> | |
| 154 | + | <kbd>g t</kbd> | |
| 155 | + | </dt> | |
| 156 | + | <dd>go home / tree</dd> | |
| 157 | + | </div> | |
| 158 | + | <div> | |
| 159 | + | <dt> | |
| 160 | + | <kbd>g c</kbd> | |
| 161 | + | <kbd>g r</kbd> | |
| 162 | + | </dt> | |
| 163 | + | <dd>go commits / refs</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt> | |
| 167 | + | <kbd>b</kbd> | |
| 168 | + | </dt> | |
| 169 | + | <dd>switch ref (branches, tags)</dd> | |
| 170 | + | </div> | |
| 171 | + | <div> | |
| 172 | + | <dt> | |
| 173 | + | <kbd>:</kbd> | |
| 174 | + | <kbd>f</kbd> | |
| 175 | + | </dt> | |
| 176 | + | <dd>go to: section, ref, file, repo</dd> | |
| 177 | + | </div> | |
| 178 | + | <div> | |
| 179 | + | <dt> | |
| 180 | + | <kbd>y</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 183 | + | </div> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 186 | + | <kbd>s</kbd> | |
| 187 | + | <kbd>t</kbd> | |
| 188 | + | <kbd>c</kbd> | |
| 189 | + | <kbd>r</kbd> | |
| 190 | + | </dt> | |
| 191 | + | <dd>repo sections (when shown)</dd> | |
| 192 | + | </div> | |
| 193 | + | <div> | |
| 194 | + | <dt> | |
| 195 | + | <kbd>d</kbd> | |
| 196 | + | <kbd>u</kbd> | |
| 197 | + | <kbd>x</kbd> | |
| 198 | + | </dt> | |
| 199 | + | <dd>commit: split / unified / collapse all</dd> | |
| 200 | + | </div> | |
| 201 | + | <div> | |
| 202 | + | <dt> | |
| 203 | + | <kbd>esc</kbd> | |
| 204 | + | </dt> | |
| 205 | + | <dd>close panel</dd> | |
| 206 | + | </div> | |
| 207 | + | </dl> | |
| 208 | + | </dialog> | |
| 209 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 210 | + | <div class="panel-head"> | |
| 211 | + | <label for="refs-filter">ref></label> | |
| 212 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 213 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 214 | + | </div> | |
| 215 | + | <div class="panel-cols" data-picklist> | |
| 216 | + | <section aria-label="Branches"> | |
| 217 | + | <h2>branches <span class="mute">2</span> | |
| 218 | + | </h2> | |
| 219 | + | <ul> | |
| 220 | + | <li> | |
| 221 | + | <a href="/files/tree/feature/x"> | |
| 222 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 223 | + | </li> | |
| 224 | + | <li> | |
| 225 | + | <a href="/files/tree/main" aria-current="true"> | |
| 226 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 227 | + | </li> | |
| 228 | + | </ul> | |
| 229 | + | </section> | |
| 230 | + | <section aria-label="Tags"> | |
| 231 | + | <h2>tags <span class="mute">1</span> | |
| 232 | + | </h2> | |
| 233 | + | <ul> | |
| 234 | + | <li> | |
| 235 | + | <a href="/files/tree/v1.0"> | |
| 236 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 237 | + | </li> | |
| 238 | + | </ul> | |
| 239 | + | </section> | |
| 240 | + | </div> | |
| 241 | + | </dialog> | |
| 242 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 243 | + | <div class="panel-head"> | |
| 244 | + | <label for="cmd-q" class="hi">:</label> | |
| 245 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 246 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 247 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 248 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 249 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 250 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 251 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 252 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 253 | + | </div> | |
| 254 | + | <ul id="cmd-list" class="cmd-list"> | |
| 255 | + | <li data-k="go home" data-cursor> | |
| 256 | + | <a href="/" tabindex="-1"> | |
| 257 | + | <span class="k">go</span>home</a> | |
| 258 | + | </li> | |
| 259 | + | <li data-k="section summary"> | |
| 260 | + | <a href="/files" tabindex="-1"> | |
| 261 | + | <span class="k">section</span>summary</a> | |
| 262 | + | </li> | |
| 263 | + | <li data-k="section tree"> | |
| 264 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 265 | + | <span class="k">section</span>tree</a> | |
| 266 | + | </li> | |
| 267 | + | <li data-k="section commits"> | |
| 268 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 269 | + | <span class="k">section</span>commits</a> | |
| 270 | + | </li> | |
| 271 | + | <li data-k="section refs"> | |
| 272 | + | <a href="/files/refs" tabindex="-1"> | |
| 273 | + | <span class="k">section</span>refs</a> | |
| 274 | + | </li> | |
| 275 | + | <li data-k="branch feature/x"> | |
| 276 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 277 | + | <span class="k">branch</span>feature/x</a> | |
| 278 | + | </li> | |
| 279 | + | <li data-k="branch main"> | |
| 280 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 281 | + | <span class="k">branch</span>main</a> | |
| 282 | + | </li> | |
| 283 | + | <li data-k="tag v1.0"> | |
| 284 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 285 | + | <span class="k">tag</span>v1.0</a> | |
| 286 | + | </li> | |
| 287 | + | </ul> | |
| 288 | + | </dialog> | |
| 289 | + | <footer class="bar status" aria-label="Status"> | |
| 290 | + | <span class="mode">blob</span> | |
| 291 | + | <span class="st-ctx">logo.svg</span> | |
| 292 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 293 | + | <span class="hi">:</span> go to…</button> | |
| 294 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 295 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 296 | + | </span> | |
| 297 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 298 | + | <span class="st-src"> | |
| 299 | + | <span class="st-ver">ermite devel · </span> | |
| 300 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 301 | + | </span> | |
| 302 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 303 | + | </footer> | |
| 304 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 305 | + | if (q('dialog[open]').count) return; | |
| 306 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 307 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 308 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 309 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 310 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 311 | + | })(1); | |
| 312 | + | k-k from:body -> ((d) => { | |
| 313 | + | if (q('dialog[open]').count) return; | |
| 314 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 315 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 316 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 317 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 318 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 319 | + | })(-1); | |
| 320 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 321 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 322 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 323 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 324 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 325 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 326 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 327 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 328 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 329 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 330 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 331 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 332 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 333 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 334 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 335 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 336 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 337 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 338 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 339 | + | k-permalink from:body -> { | |
| 340 | + | let b = document.body.dataset; | |
| 341 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 342 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 343 | + | navigator.clipboard?.writeText(location.href); | |
| 344 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 345 | + | }"> | |
| 346 | + | </div> | |
| 347 | + | </body> | |
| 348 | + | </html> | |
A cmd/ermite/testdata/web/blob-text.html +343 -0
| @@ -0,0 +1,343 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>notes.txt · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/notes.txt" aria-current="page">notes.txt</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>notes.txt</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/notes.txt">raw</a> | |
| 65 | + | <a href="/files/commits/main/notes.txt">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 70 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 71 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 72 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 73 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 74 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 75 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 76 | + | <code> | |
| 77 | + | <span class="line" id="L1"> | |
| 78 | + | <a class="ln" href="#L1">1</a>plain notes</span> | |
| 79 | + | <span class="line" id="L2"> | |
| 80 | + | <a class="ln" href="#L2">2</a>second line</span> | |
| 81 | + | </code> | |
| 82 | + | </pre> | |
| 83 | + | </main> | |
| 84 | + | <aside class="rail" aria-label="Context"> | |
| 85 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 86 | + | <summary>file · last change · selection</summary> | |
| 87 | + | <section aria-labelledby="r-file"> | |
| 88 | + | <h2 id="r-file">file</h2> | |
| 89 | + | <dl class="kv"> | |
| 90 | + | <dt>size</dt> | |
| 91 | + | <dd>24 B</dd> | |
| 92 | + | <dt>lines</dt> | |
| 93 | + | <dd>2</dd> | |
| 94 | + | <dt>type</dt> | |
| 95 | + | <dd>plaintext</dd> | |
| 96 | + | </dl> | |
| 97 | + | </section> | |
| 98 | + | <section aria-labelledby="r-last"> | |
| 99 | + | <h2 id="r-last">last change <a href="/files/commits/main/notes.txt">history</a> | |
| 100 | + | </h2> | |
| 101 | + | <p> | |
| 102 | + | <a href="/files/commit/SHA40">SHA7</a> add notes.txt</p> | |
| 103 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 104 | + | </p> | |
| 105 | + | </section> | |
| 106 | + | <section aria-labelledby="r-sel"> | |
| 107 | + | <h2 id="r-sel">selection</h2> | |
| 108 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 109 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 110 | + | </section> | |
| 111 | + | </details> | |
| 112 | + | </aside> | |
| 113 | + | </div> | |
| 114 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 115 | + | <div class="panel-head"> | |
| 116 | + | <span>keys</span> | |
| 117 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 118 | + | </div> | |
| 119 | + | <dl class="keys"> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>j</kbd> | |
| 123 | + | <kbd>k</kbd> | |
| 124 | + | </dt> | |
| 125 | + | <dd>move cursor down / up</dd> | |
| 126 | + | </div> | |
| 127 | + | <div> | |
| 128 | + | <dt> | |
| 129 | + | <kbd>enter</kbd> | |
| 130 | + | </dt> | |
| 131 | + | <dd>open row under cursor</dd> | |
| 132 | + | </div> | |
| 133 | + | <div> | |
| 134 | + | <dt> | |
| 135 | + | <kbd>g</kbd> | |
| 136 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>first / last row</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>/</kbd> | |
| 143 | + | </dt> | |
| 144 | + | <dd>focus the filter</dd> | |
| 145 | + | </div> | |
| 146 | + | <div> | |
| 147 | + | <dt> | |
| 148 | + | <kbd>g h</kbd> | |
| 149 | + | <kbd>g t</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>go home / tree</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>g c</kbd> | |
| 156 | + | <kbd>g r</kbd> | |
| 157 | + | </dt> | |
| 158 | + | <dd>go commits / refs</dd> | |
| 159 | + | </div> | |
| 160 | + | <div> | |
| 161 | + | <dt> | |
| 162 | + | <kbd>b</kbd> | |
| 163 | + | </dt> | |
| 164 | + | <dd>switch ref (branches, tags)</dd> | |
| 165 | + | </div> | |
| 166 | + | <div> | |
| 167 | + | <dt> | |
| 168 | + | <kbd>:</kbd> | |
| 169 | + | <kbd>f</kbd> | |
| 170 | + | </dt> | |
| 171 | + | <dd>go to: section, ref, file, repo</dd> | |
| 172 | + | </div> | |
| 173 | + | <div> | |
| 174 | + | <dt> | |
| 175 | + | <kbd>y</kbd> | |
| 176 | + | </dt> | |
| 177 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 178 | + | </div> | |
| 179 | + | <div> | |
| 180 | + | <dt> | |
| 181 | + | <kbd>s</kbd> | |
| 182 | + | <kbd>t</kbd> | |
| 183 | + | <kbd>c</kbd> | |
| 184 | + | <kbd>r</kbd> | |
| 185 | + | </dt> | |
| 186 | + | <dd>repo sections (when shown)</dd> | |
| 187 | + | </div> | |
| 188 | + | <div> | |
| 189 | + | <dt> | |
| 190 | + | <kbd>d</kbd> | |
| 191 | + | <kbd>u</kbd> | |
| 192 | + | <kbd>x</kbd> | |
| 193 | + | </dt> | |
| 194 | + | <dd>commit: split / unified / collapse all</dd> | |
| 195 | + | </div> | |
| 196 | + | <div> | |
| 197 | + | <dt> | |
| 198 | + | <kbd>esc</kbd> | |
| 199 | + | </dt> | |
| 200 | + | <dd>close panel</dd> | |
| 201 | + | </div> | |
| 202 | + | </dl> | |
| 203 | + | </dialog> | |
| 204 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 205 | + | <div class="panel-head"> | |
| 206 | + | <label for="refs-filter">ref></label> | |
| 207 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 208 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 209 | + | </div> | |
| 210 | + | <div class="panel-cols" data-picklist> | |
| 211 | + | <section aria-label="Branches"> | |
| 212 | + | <h2>branches <span class="mute">2</span> | |
| 213 | + | </h2> | |
| 214 | + | <ul> | |
| 215 | + | <li> | |
| 216 | + | <a href="/files/tree/feature/x"> | |
| 217 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 218 | + | </li> | |
| 219 | + | <li> | |
| 220 | + | <a href="/files/tree/main" aria-current="true"> | |
| 221 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 222 | + | </li> | |
| 223 | + | </ul> | |
| 224 | + | </section> | |
| 225 | + | <section aria-label="Tags"> | |
| 226 | + | <h2>tags <span class="mute">1</span> | |
| 227 | + | </h2> | |
| 228 | + | <ul> | |
| 229 | + | <li> | |
| 230 | + | <a href="/files/tree/v1.0"> | |
| 231 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 232 | + | </li> | |
| 233 | + | </ul> | |
| 234 | + | </section> | |
| 235 | + | </div> | |
| 236 | + | </dialog> | |
| 237 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 238 | + | <div class="panel-head"> | |
| 239 | + | <label for="cmd-q" class="hi">:</label> | |
| 240 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 241 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 242 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 243 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 244 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 245 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 246 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 247 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 248 | + | </div> | |
| 249 | + | <ul id="cmd-list" class="cmd-list"> | |
| 250 | + | <li data-k="go home" data-cursor> | |
| 251 | + | <a href="/" tabindex="-1"> | |
| 252 | + | <span class="k">go</span>home</a> | |
| 253 | + | </li> | |
| 254 | + | <li data-k="section summary"> | |
| 255 | + | <a href="/files" tabindex="-1"> | |
| 256 | + | <span class="k">section</span>summary</a> | |
| 257 | + | </li> | |
| 258 | + | <li data-k="section tree"> | |
| 259 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 260 | + | <span class="k">section</span>tree</a> | |
| 261 | + | </li> | |
| 262 | + | <li data-k="section commits"> | |
| 263 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 264 | + | <span class="k">section</span>commits</a> | |
| 265 | + | </li> | |
| 266 | + | <li data-k="section refs"> | |
| 267 | + | <a href="/files/refs" tabindex="-1"> | |
| 268 | + | <span class="k">section</span>refs</a> | |
| 269 | + | </li> | |
| 270 | + | <li data-k="branch feature/x"> | |
| 271 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 272 | + | <span class="k">branch</span>feature/x</a> | |
| 273 | + | </li> | |
| 274 | + | <li data-k="branch main"> | |
| 275 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 276 | + | <span class="k">branch</span>main</a> | |
| 277 | + | </li> | |
| 278 | + | <li data-k="tag v1.0"> | |
| 279 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 280 | + | <span class="k">tag</span>v1.0</a> | |
| 281 | + | </li> | |
| 282 | + | </ul> | |
| 283 | + | </dialog> | |
| 284 | + | <footer class="bar status" aria-label="Status"> | |
| 285 | + | <span class="mode">blob</span> | |
| 286 | + | <span class="st-ctx">notes.txt</span> | |
| 287 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 288 | + | <span class="hi">:</span> go to…</button> | |
| 289 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 290 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 291 | + | </span> | |
| 292 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 293 | + | <span class="st-src"> | |
| 294 | + | <span class="st-ver">ermite devel · </span> | |
| 295 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 296 | + | </span> | |
| 297 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 298 | + | </footer> | |
| 299 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 300 | + | if (q('dialog[open]').count) return; | |
| 301 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 302 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 303 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 304 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 305 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 306 | + | })(1); | |
| 307 | + | k-k from:body -> ((d) => { | |
| 308 | + | if (q('dialog[open]').count) return; | |
| 309 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 310 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 311 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 312 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 313 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 314 | + | })(-1); | |
| 315 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 316 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 317 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 318 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 319 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 320 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 321 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 322 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 323 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 324 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 325 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 326 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 327 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 328 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 329 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 330 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 331 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 332 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 333 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 334 | + | k-permalink from:body -> { | |
| 335 | + | let b = document.body.dataset; | |
| 336 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 337 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 338 | + | navigator.clipboard?.writeText(location.href); | |
| 339 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 340 | + | }"> | |
| 341 | + | </div> | |
| 342 | + | </body> | |
| 343 | + | </html> | |
A cmd/ermite/testdata/web/blob-too-large.html +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>big.txt · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/big.txt" aria-current="page">big.txt</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>big.txt</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/big.txt">raw</a> | |
| 65 | + | <a href="/files/commits/main/big.txt">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <p class="notice" role="note">file too large to show (1.1 MiB, the limit is 1.0 MiB). use raw.</p> | |
| 70 | + | </main> | |
| 71 | + | <aside class="rail" aria-label="Context"> | |
| 72 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 73 | + | <summary>file · last change · selection</summary> | |
| 74 | + | <section aria-labelledby="r-file"> | |
| 75 | + | <h2 id="r-file">file</h2> | |
| 76 | + | <dl class="kv"> | |
| 77 | + | <dt>size</dt> | |
| 78 | + | <dd>1.1 MiB</dd> | |
| 79 | + | <dt>type</dt> | |
| 80 | + | <dd>text/plain</dd> | |
| 81 | + | </dl> | |
| 82 | + | </section> | |
| 83 | + | <section aria-labelledby="r-last"> | |
| 84 | + | <h2 id="r-last">last change <a href="/files/commits/main/big.txt">history</a> | |
| 85 | + | </h2> | |
| 86 | + | <p> | |
| 87 | + | <a href="/files/commit/SHA40">SHA7</a> add big.txt</p> | |
| 88 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 89 | + | </p> | |
| 90 | + | </section> | |
| 91 | + | </details> | |
| 92 | + | </aside> | |
| 93 | + | </div> | |
| 94 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 95 | + | <div class="panel-head"> | |
| 96 | + | <span>keys</span> | |
| 97 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 98 | + | </div> | |
| 99 | + | <dl class="keys"> | |
| 100 | + | <div> | |
| 101 | + | <dt> | |
| 102 | + | <kbd>j</kbd> | |
| 103 | + | <kbd>k</kbd> | |
| 104 | + | </dt> | |
| 105 | + | <dd>move cursor down / up</dd> | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <dt> | |
| 109 | + | <kbd>enter</kbd> | |
| 110 | + | </dt> | |
| 111 | + | <dd>open row under cursor</dd> | |
| 112 | + | </div> | |
| 113 | + | <div> | |
| 114 | + | <dt> | |
| 115 | + | <kbd>g</kbd> | |
| 116 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 117 | + | </dt> | |
| 118 | + | <dd>first / last row</dd> | |
| 119 | + | </div> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>/</kbd> | |
| 123 | + | </dt> | |
| 124 | + | <dd>focus the filter</dd> | |
| 125 | + | </div> | |
| 126 | + | <div> | |
| 127 | + | <dt> | |
| 128 | + | <kbd>g h</kbd> | |
| 129 | + | <kbd>g t</kbd> | |
| 130 | + | </dt> | |
| 131 | + | <dd>go home / tree</dd> | |
| 132 | + | </div> | |
| 133 | + | <div> | |
| 134 | + | <dt> | |
| 135 | + | <kbd>g c</kbd> | |
| 136 | + | <kbd>g r</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>go commits / refs</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>b</kbd> | |
| 143 | + | </dt> | |
| 144 | + | <dd>switch ref (branches, tags)</dd> | |
| 145 | + | </div> | |
| 146 | + | <div> | |
| 147 | + | <dt> | |
| 148 | + | <kbd>:</kbd> | |
| 149 | + | <kbd>f</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>go to: section, ref, file, repo</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>y</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>s</kbd> | |
| 162 | + | <kbd>t</kbd> | |
| 163 | + | <kbd>c</kbd> | |
| 164 | + | <kbd>r</kbd> | |
| 165 | + | </dt> | |
| 166 | + | <dd>repo sections (when shown)</dd> | |
| 167 | + | </div> | |
| 168 | + | <div> | |
| 169 | + | <dt> | |
| 170 | + | <kbd>d</kbd> | |
| 171 | + | <kbd>u</kbd> | |
| 172 | + | <kbd>x</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>commit: split / unified / collapse all</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>close panel</dd> | |
| 181 | + | </div> | |
| 182 | + | </dl> | |
| 183 | + | </dialog> | |
| 184 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 185 | + | <div class="panel-head"> | |
| 186 | + | <label for="refs-filter">ref></label> | |
| 187 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 188 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 189 | + | </div> | |
| 190 | + | <div class="panel-cols" data-picklist> | |
| 191 | + | <section aria-label="Branches"> | |
| 192 | + | <h2>branches <span class="mute">2</span> | |
| 193 | + | </h2> | |
| 194 | + | <ul> | |
| 195 | + | <li> | |
| 196 | + | <a href="/files/tree/feature/x"> | |
| 197 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 198 | + | </li> | |
| 199 | + | <li> | |
| 200 | + | <a href="/files/tree/main" aria-current="true"> | |
| 201 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 202 | + | </li> | |
| 203 | + | </ul> | |
| 204 | + | </section> | |
| 205 | + | <section aria-label="Tags"> | |
| 206 | + | <h2>tags <span class="mute">1</span> | |
| 207 | + | </h2> | |
| 208 | + | <ul> | |
| 209 | + | <li> | |
| 210 | + | <a href="/files/tree/v1.0"> | |
| 211 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 212 | + | </li> | |
| 213 | + | </ul> | |
| 214 | + | </section> | |
| 215 | + | </div> | |
| 216 | + | </dialog> | |
| 217 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 218 | + | <div class="panel-head"> | |
| 219 | + | <label for="cmd-q" class="hi">:</label> | |
| 220 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 221 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 222 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 223 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 224 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 225 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 226 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 227 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 228 | + | </div> | |
| 229 | + | <ul id="cmd-list" class="cmd-list"> | |
| 230 | + | <li data-k="go home" data-cursor> | |
| 231 | + | <a href="/" tabindex="-1"> | |
| 232 | + | <span class="k">go</span>home</a> | |
| 233 | + | </li> | |
| 234 | + | <li data-k="section summary"> | |
| 235 | + | <a href="/files" tabindex="-1"> | |
| 236 | + | <span class="k">section</span>summary</a> | |
| 237 | + | </li> | |
| 238 | + | <li data-k="section tree"> | |
| 239 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 240 | + | <span class="k">section</span>tree</a> | |
| 241 | + | </li> | |
| 242 | + | <li data-k="section commits"> | |
| 243 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 244 | + | <span class="k">section</span>commits</a> | |
| 245 | + | </li> | |
| 246 | + | <li data-k="section refs"> | |
| 247 | + | <a href="/files/refs" tabindex="-1"> | |
| 248 | + | <span class="k">section</span>refs</a> | |
| 249 | + | </li> | |
| 250 | + | <li data-k="branch feature/x"> | |
| 251 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 252 | + | <span class="k">branch</span>feature/x</a> | |
| 253 | + | </li> | |
| 254 | + | <li data-k="branch main"> | |
| 255 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 256 | + | <span class="k">branch</span>main</a> | |
| 257 | + | </li> | |
| 258 | + | <li data-k="tag v1.0"> | |
| 259 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 260 | + | <span class="k">tag</span>v1.0</a> | |
| 261 | + | </li> | |
| 262 | + | </ul> | |
| 263 | + | </dialog> | |
| 264 | + | <footer class="bar status" aria-label="Status"> | |
| 265 | + | <span class="mode">blob</span> | |
| 266 | + | <span class="st-ctx">big.txt</span> | |
| 267 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 268 | + | <span class="hi">:</span> go to…</button> | |
| 269 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 270 | + | </span> | |
| 271 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 272 | + | <span class="st-src"> | |
| 273 | + | <span class="st-ver">ermite devel · </span> | |
| 274 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 275 | + | </span> | |
| 276 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 277 | + | </footer> | |
| 278 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 279 | + | if (q('dialog[open]').count) return; | |
| 280 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 281 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 282 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 283 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 284 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 285 | + | })(1); | |
| 286 | + | k-k from:body -> ((d) => { | |
| 287 | + | if (q('dialog[open]').count) return; | |
| 288 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 289 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 290 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 291 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 292 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 293 | + | })(-1); | |
| 294 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 295 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 296 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 297 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 298 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 299 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 300 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 301 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 302 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 303 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 304 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 305 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 306 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 307 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 308 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 309 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 310 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 311 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 312 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 313 | + | k-permalink from:body -> { | |
| 314 | + | let b = document.body.dataset; | |
| 315 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 316 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 317 | + | navigator.clipboard?.writeText(location.href); | |
| 318 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 319 | + | }"> | |
| 320 | + | </div> | |
| 321 | + | </body> | |
| 322 | + | </html> | |
A cmd/ermite/testdata/web/blob-trojan.html +390 -0
| @@ -0,0 +1,390 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>evil.go · files · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">blob</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/blob/main/evil.go" aria-current="page">evil.go</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <h1 class="file-h"> | |
| 62 | + | <span>evil.go</span> | |
| 63 | + | <span class="acts"> | |
| 64 | + | <a href="/files/raw/main/evil.go">raw</a> | |
| 65 | + | <a href="/files/commits/main/evil.go">history</a> | |
| 66 | + | <a href="/files/tree/main">dir</a> | |
| 67 | + | </span> | |
| 68 | + | </h1> | |
| 69 | + | <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 70 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 71 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 72 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 73 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 74 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 75 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 76 | + | <code> | |
| 77 | + | <span class="line" id="L1"> | |
| 78 | + | <a class="ln" href="#L1">1</a> | |
| 79 | + | <span class="kn">package</span> | |
| 80 | + | <span class="w"> | |
| 81 | + | </span> | |
| 82 | + | <span class="nx">main</span> | |
| 83 | + | </span> | |
| 84 | + | <span class="line" id="L2"> | |
| 85 | + | <a class="ln" href="#L2">2</a> | |
| 86 | + | </span> | |
| 87 | + | <span class="line" id="L3"> | |
| 88 | + | <a class="ln" href="#L3">3</a> | |
| 89 | + | <span class="kd">func</span> | |
| 90 | + | <span class="w"> | |
| 91 | + | </span> | |
| 92 | + | <span class="nf">main</span> | |
| 93 | + | <span class="p">()</span> | |
| 94 | + | <span class="w"> | |
| 95 | + | </span> | |
| 96 | + | <span class="p">{</span> | |
| 97 | + | </span> | |
| 98 | + | <span class="line" id="L4"> | |
| 99 | + | <a class="ln" href="#L4">4</a> | |
| 100 | + | <span class="w"> | |
| 101 | + | </span> | |
| 102 | + | <span class="nx">access</span> | |
| 103 | + | <span class="w"> | |
| 104 | + | </span> | |
| 105 | + | <span class="o">:=</span> | |
| 106 | + | <span class="w"> | |
| 107 | + | </span> | |
| 108 | + | <span class="s">"user<span class="ctl" title="U+202E right-to-left override"><U+202E></span> | |
| 109 | + | <span class="ctl" title="U+2066 left-to-right isolate"><U+2066></span>// admin only<span class="ctl" title="U+2069 pop directional isolate"><U+2069></span> | |
| 110 | + | <span class="ctl" title="U+2066 left-to-right isolate"><U+2066></span>"</span> | |
| 111 | + | </span> | |
| 112 | + | <span class="line" id="L5"> | |
| 113 | + | <a class="ln" href="#L5">5</a> | |
| 114 | + | <span class="w"> | |
| 115 | + | </span> | |
| 116 | + | <span class="nx">_</span> | |
| 117 | + | <span class="w"> | |
| 118 | + | </span> | |
| 119 | + | <span class="p">=</span> | |
| 120 | + | <span class="w"> | |
| 121 | + | </span> | |
| 122 | + | <span class="nx">access</span> | |
| 123 | + | </span> | |
| 124 | + | <span class="line" id="L6"> | |
| 125 | + | <a class="ln" href="#L6">6</a> | |
| 126 | + | <span class="p">}</span> | |
| 127 | + | </span> | |
| 128 | + | </code> | |
| 129 | + | </pre> | |
| 130 | + | </main> | |
| 131 | + | <aside class="rail" aria-label="Context"> | |
| 132 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 133 | + | <summary>file · last change · selection</summary> | |
| 134 | + | <section aria-labelledby="r-file"> | |
| 135 | + | <h2 id="r-file">file</h2> | |
| 136 | + | <dl class="kv"> | |
| 137 | + | <dt>size</dt> | |
| 138 | + | <dd>87 B</dd> | |
| 139 | + | <dt>lines</dt> | |
| 140 | + | <dd>6</dd> | |
| 141 | + | <dt>type</dt> | |
| 142 | + | <dd>Go</dd> | |
| 143 | + | </dl> | |
| 144 | + | </section> | |
| 145 | + | <section aria-labelledby="r-last"> | |
| 146 | + | <h2 id="r-last">last change <a href="/files/commits/main/evil.go">history</a> | |
| 147 | + | </h2> | |
| 148 | + | <p> | |
| 149 | + | <a href="/files/commit/SHA40">SHA7</a> add evil.go</p> | |
| 150 | + | <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time> | |
| 151 | + | </p> | |
| 152 | + | </section> | |
| 153 | + | <section aria-labelledby="r-sel"> | |
| 154 | + | <h2 id="r-sel">selection</h2> | |
| 155 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 156 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 157 | + | </section> | |
| 158 | + | </details> | |
| 159 | + | </aside> | |
| 160 | + | </div> | |
| 161 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 162 | + | <div class="panel-head"> | |
| 163 | + | <span>keys</span> | |
| 164 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 165 | + | </div> | |
| 166 | + | <dl class="keys"> | |
| 167 | + | <div> | |
| 168 | + | <dt> | |
| 169 | + | <kbd>j</kbd> | |
| 170 | + | <kbd>k</kbd> | |
| 171 | + | </dt> | |
| 172 | + | <dd>move cursor down / up</dd> | |
| 173 | + | </div> | |
| 174 | + | <div> | |
| 175 | + | <dt> | |
| 176 | + | <kbd>enter</kbd> | |
| 177 | + | </dt> | |
| 178 | + | <dd>open row under cursor</dd> | |
| 179 | + | </div> | |
| 180 | + | <div> | |
| 181 | + | <dt> | |
| 182 | + | <kbd>g</kbd> | |
| 183 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 184 | + | </dt> | |
| 185 | + | <dd>first / last row</dd> | |
| 186 | + | </div> | |
| 187 | + | <div> | |
| 188 | + | <dt> | |
| 189 | + | <kbd>/</kbd> | |
| 190 | + | </dt> | |
| 191 | + | <dd>focus the filter</dd> | |
| 192 | + | </div> | |
| 193 | + | <div> | |
| 194 | + | <dt> | |
| 195 | + | <kbd>g h</kbd> | |
| 196 | + | <kbd>g t</kbd> | |
| 197 | + | </dt> | |
| 198 | + | <dd>go home / tree</dd> | |
| 199 | + | </div> | |
| 200 | + | <div> | |
| 201 | + | <dt> | |
| 202 | + | <kbd>g c</kbd> | |
| 203 | + | <kbd>g r</kbd> | |
| 204 | + | </dt> | |
| 205 | + | <dd>go commits / refs</dd> | |
| 206 | + | </div> | |
| 207 | + | <div> | |
| 208 | + | <dt> | |
| 209 | + | <kbd>b</kbd> | |
| 210 | + | </dt> | |
| 211 | + | <dd>switch ref (branches, tags)</dd> | |
| 212 | + | </div> | |
| 213 | + | <div> | |
| 214 | + | <dt> | |
| 215 | + | <kbd>:</kbd> | |
| 216 | + | <kbd>f</kbd> | |
| 217 | + | </dt> | |
| 218 | + | <dd>go to: section, ref, file, repo</dd> | |
| 219 | + | </div> | |
| 220 | + | <div> | |
| 221 | + | <dt> | |
| 222 | + | <kbd>y</kbd> | |
| 223 | + | </dt> | |
| 224 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 225 | + | </div> | |
| 226 | + | <div> | |
| 227 | + | <dt> | |
| 228 | + | <kbd>s</kbd> | |
| 229 | + | <kbd>t</kbd> | |
| 230 | + | <kbd>c</kbd> | |
| 231 | + | <kbd>r</kbd> | |
| 232 | + | </dt> | |
| 233 | + | <dd>repo sections (when shown)</dd> | |
| 234 | + | </div> | |
| 235 | + | <div> | |
| 236 | + | <dt> | |
| 237 | + | <kbd>d</kbd> | |
| 238 | + | <kbd>u</kbd> | |
| 239 | + | <kbd>x</kbd> | |
| 240 | + | </dt> | |
| 241 | + | <dd>commit: split / unified / collapse all</dd> | |
| 242 | + | </div> | |
| 243 | + | <div> | |
| 244 | + | <dt> | |
| 245 | + | <kbd>esc</kbd> | |
| 246 | + | </dt> | |
| 247 | + | <dd>close panel</dd> | |
| 248 | + | </div> | |
| 249 | + | </dl> | |
| 250 | + | </dialog> | |
| 251 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 252 | + | <div class="panel-head"> | |
| 253 | + | <label for="refs-filter">ref></label> | |
| 254 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 255 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 256 | + | </div> | |
| 257 | + | <div class="panel-cols" data-picklist> | |
| 258 | + | <section aria-label="Branches"> | |
| 259 | + | <h2>branches <span class="mute">2</span> | |
| 260 | + | </h2> | |
| 261 | + | <ul> | |
| 262 | + | <li> | |
| 263 | + | <a href="/files/tree/feature/x"> | |
| 264 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 265 | + | </li> | |
| 266 | + | <li> | |
| 267 | + | <a href="/files/tree/main" aria-current="true"> | |
| 268 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 269 | + | </li> | |
| 270 | + | </ul> | |
| 271 | + | </section> | |
| 272 | + | <section aria-label="Tags"> | |
| 273 | + | <h2>tags <span class="mute">1</span> | |
| 274 | + | </h2> | |
| 275 | + | <ul> | |
| 276 | + | <li> | |
| 277 | + | <a href="/files/tree/v1.0"> | |
| 278 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 279 | + | </li> | |
| 280 | + | </ul> | |
| 281 | + | </section> | |
| 282 | + | </div> | |
| 283 | + | </dialog> | |
| 284 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 285 | + | <div class="panel-head"> | |
| 286 | + | <label for="cmd-q" class="hi">:</label> | |
| 287 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 288 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 289 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 290 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 291 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 292 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 293 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 294 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 295 | + | </div> | |
| 296 | + | <ul id="cmd-list" class="cmd-list"> | |
| 297 | + | <li data-k="go home" data-cursor> | |
| 298 | + | <a href="/" tabindex="-1"> | |
| 299 | + | <span class="k">go</span>home</a> | |
| 300 | + | </li> | |
| 301 | + | <li data-k="section summary"> | |
| 302 | + | <a href="/files" tabindex="-1"> | |
| 303 | + | <span class="k">section</span>summary</a> | |
| 304 | + | </li> | |
| 305 | + | <li data-k="section tree"> | |
| 306 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 307 | + | <span class="k">section</span>tree</a> | |
| 308 | + | </li> | |
| 309 | + | <li data-k="section commits"> | |
| 310 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 311 | + | <span class="k">section</span>commits</a> | |
| 312 | + | </li> | |
| 313 | + | <li data-k="section refs"> | |
| 314 | + | <a href="/files/refs" tabindex="-1"> | |
| 315 | + | <span class="k">section</span>refs</a> | |
| 316 | + | </li> | |
| 317 | + | <li data-k="branch feature/x"> | |
| 318 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 319 | + | <span class="k">branch</span>feature/x</a> | |
| 320 | + | </li> | |
| 321 | + | <li data-k="branch main"> | |
| 322 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 323 | + | <span class="k">branch</span>main</a> | |
| 324 | + | </li> | |
| 325 | + | <li data-k="tag v1.0"> | |
| 326 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 327 | + | <span class="k">tag</span>v1.0</a> | |
| 328 | + | </li> | |
| 329 | + | </ul> | |
| 330 | + | </dialog> | |
| 331 | + | <footer class="bar status" aria-label="Status"> | |
| 332 | + | <span class="mode">blob</span> | |
| 333 | + | <span class="st-ctx">evil.go</span> | |
| 334 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 335 | + | <span class="hi">:</span> go to…</button> | |
| 336 | + | <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span> | |
| 337 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 338 | + | </span> | |
| 339 | + | <span class="st-hints">click a line number · y permalink · ? help</span> | |
| 340 | + | <span class="st-src"> | |
| 341 | + | <span class="st-ver">ermite devel · </span> | |
| 342 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 343 | + | </span> | |
| 344 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 345 | + | </footer> | |
| 346 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 347 | + | if (q('dialog[open]').count) return; | |
| 348 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 349 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 350 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 351 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 352 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 353 | + | })(1); | |
| 354 | + | k-k from:body -> ((d) => { | |
| 355 | + | if (q('dialog[open]').count) return; | |
| 356 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 360 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 361 | + | })(-1); | |
| 362 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 363 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 364 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 365 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 366 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 367 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 368 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 369 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 370 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 371 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 372 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 373 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 374 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 375 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 376 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 377 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 378 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 379 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 380 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 381 | + | k-permalink from:body -> { | |
| 382 | + | let b = document.body.dataset; | |
| 383 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 384 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 385 | + | navigator.clipboard?.writeText(location.href); | |
| 386 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 387 | + | }"> | |
| 388 | + | </div> | |
| 389 | + | </body> | |
| 390 | + | </html> | |
A cmd/ermite/web_files_test.go +336 -0
| @@ -0,0 +1,336 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "image" | |
| 8 | + | "image/color" | |
| 9 | + | "image/png" | |
| 10 | + | "net/http" | |
| 11 | + | "regexp" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/app" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Trojan Source: a right-to-left override and an isolate around code. | |
| 24 | + | const webTrojan = "package main\n\nfunc main() {\n\taccess := \"user\u202e \u2066// admin only\u2069 \u2066\"\n\t_ = access\n}\n" | |
| 25 | + | ||
| 26 | + | func tinyPNG(t *testing.T) []byte { | |
| 27 | + | t.Helper() | |
| 28 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 29 | + | img.Set(0, 0, color.RGBA{R: 255, A: 255}) | |
| 30 | + | var b bytes.Buffer | |
| 31 | + | require.NoError(t, png.Encode(&b, img)) | |
| 32 | + | return b.Bytes() | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // webFilesRepo is the repository of the tree, blob and raw tests: files of | |
| 36 | + | // every kind the pages treat differently, a directory README, a branch and a | |
| 37 | + | // tag with a slash. | |
| 38 | + | func (h *harness) webFilesRepo(name string) { | |
| 39 | + | h.t.Helper() | |
| 40 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |
| 41 | + | Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic, | |
| 42 | + | }) | |
| 43 | + | require.NoError(h.t, err) | |
| 44 | + | wt := h.t.TempDir() | |
| 45 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| 46 | + | h.writeFile(wt, "README.md", []byte("# Files\n\nSee [the guide](docs/guide.md).\n")) | |
| 47 | + | h.writeFile(wt, "main.go", []byte("package main\n\nimport \"fmt\"\n\n// main prints.\nfunc main() {\n\tfmt.Println(\"hi <b>\", 42)\n}\n")) | |
| 48 | + | h.writeFile(wt, "docs/README.md", []byte("# Docs\n\nBack to [the guide](guide.md) and [root](../main.go).\n")) | |
| 49 | + | h.writeFile(wt, "docs/guide.md", []byte("# Guide\n\n## Steps\n\nRun it.\n\n```sh\nmake all\n```\n")) | |
| 50 | + | h.writeFile(wt, "src/lib/util.go", []byte("package lib\n")) | |
| 51 | + | h.writeFile(wt, "notes.txt", []byte("plain notes\nsecond line\n")) | |
| 52 | + | h.writeFile(wt, "big.txt", bytes.Repeat([]byte("0123456789abcdef\n"), 70000)) // 1.1 MiB | |
| 53 | + | h.writeFile(wt, "lines.txt", bytes.Repeat([]byte("x\n"), 20005)) | |
| 54 | + | h.writeFile(wt, "data.bin", []byte("\x00\x01\x02binary\x00")) | |
| 55 | + | h.writeFile(wt, "pic.png", tinyPNG(h.t)) | |
| 56 | + | h.writeFile(wt, "logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script></svg>`)) | |
| 57 | + | h.writeFile(wt, "page.html", []byte("<script>alert(3)</script>\n")) | |
| 58 | + | h.writeFile(wt, "evil.go", []byte(webTrojan)) | |
| 59 | + | h.writeFile(wt, "empty.txt", nil) | |
| 60 | + | h.writeFile(wt, "fake.png", []byte("<html><script>alert(1)</script></html>\n")) | |
| 61 | + | h.writeFile(wt, "fake.jpg", []byte("GIF89a\x00\x00binary\x00")) | |
| 62 | + | h.mustGit(wt, "tag", "v1.0") | |
| 63 | + | h.mustGit(wt, "checkout", "-q", "-b", "feature/x") | |
| 64 | + | h.writeFile(wt, "x/y.txt", []byte("on the feature branch\n")) | |
| 65 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // AC1: the tree and blob pages render for a fixture repository; the HTML is | |
| 69 | + | // compared after normalising times and ids. | |
| 70 | + | func TestWebFilesGolden(t *testing.T) { | |
| 71 | + | h := newHarness(t, harnessOpts{}) | |
| 72 | + | h.webFilesRepo("files") | |
| 73 | + | ||
| 74 | + | for _, tt := range []struct { | |
| 75 | + | name, path string | |
| 76 | + | status int | |
| 77 | + | }{ | |
| 78 | + | {"tree-root", "/files/tree/main", 200}, | |
| 79 | + | {"tree-subdir", "/files/tree/main/docs", 200}, | |
| 80 | + | {"tree-nested", "/files/tree/main/src/lib", 200}, | |
| 81 | + | {"tree-slash-ref", "/files/tree/feature/x/x", 200}, | |
| 82 | + | {"blob-code", "/files/blob/main/main.go", 200}, | |
| 83 | + | {"blob-markdown", "/files/blob/main/docs/guide.md", 200}, | |
| 84 | + | {"blob-text", "/files/blob/main/notes.txt", 200}, | |
| 85 | + | {"blob-too-large", "/files/blob/main/big.txt", 200}, | |
| 86 | + | {"blob-binary", "/files/blob/main/data.bin", 200}, | |
| 87 | + | {"blob-image", "/files/blob/main/pic.png", 200}, | |
| 88 | + | {"blob-svg", "/files/blob/main/logo.svg", 200}, | |
| 89 | + | {"blob-trojan", "/files/blob/main/evil.go", 200}, | |
| 90 | + | {"blob-empty", "/files/blob/main/empty.txt", 200}, | |
| 91 | + | } { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | status, hdr, body := h.get(tt.path) | |
| 94 | + | require.Equal(t, tt.status, status) | |
| 95 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |
| 96 | + | assert.Contains(t, hdr.Get("Content-Type"), "text/html") | |
| 97 | + | assertNoInline(t, body) | |
| 98 | + | golden(t, tt.name, normalise(body)) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Ref names with a slash are found by their longest prefix, and a commit id | |
| 104 | + | // works where a ref does. | |
| 105 | + | func TestWebFilesRefs(t *testing.T) { | |
| 106 | + | h := newHarness(t, harnessOpts{}) | |
| 107 | + | h.webFilesRepo("files") | |
| 108 | + | ||
| 109 | + | _, _, body := h.get("/files/tree/feature/x") | |
| 110 | + | assert.Contains(t, body, ">x/<", "the directory of the branch feature/x") | |
| 111 | + | status, _, body := h.get("/files/blob/feature/x/x/y.txt") | |
| 112 | + | require.Equal(t, 200, status) | |
| 113 | + | assert.Contains(t, body, "on the feature branch") | |
| 114 | + | status, _, body = h.get("/files/blob/v1.0/main.go") | |
| 115 | + | require.Equal(t, 200, status, "a tag") | |
| 116 | + | assert.Contains(t, body, "package") | |
| 117 | + | ||
| 118 | + | sha := h.bareGit("files", "rev-parse", "main") | |
| 119 | + | status, _, body = h.get("/files/blob/" + sha + "/notes.txt") | |
| 120 | + | require.Equal(t, 200, status, "a commit id") | |
| 121 | + | assert.Contains(t, body, "plain notes") | |
| 122 | + | assert.Contains(t, body, "detached") | |
| 123 | + | status, _, _ = h.get("/files/tree/" + sha[:7]) | |
| 124 | + | assert.Equal(t, 200, status, "an abbreviated commit id") | |
| 125 | + | ||
| 126 | + | // Links of a directory README stay under the directory and the ref. | |
| 127 | + | _, _, body = h.get("/files/tree/main/docs") | |
| 128 | + | assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`) | |
| 129 | + | assert.Contains(t, body, `href="/files/blob/main/main.go"`) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // AC3: what does not exist is the 404 page, with no hint of why. | |
| 133 | + | func TestWebFilesNotFound(t *testing.T) { | |
| 134 | + | h := newHarness(t, harnessOpts{}) | |
| 135 | + | h.webFilesRepo("files") | |
| 136 | + | h.seed("quiet", domain.VisibilityPublic) // empty repository | |
| 137 | + | ||
| 138 | + | for _, p := range []string{ | |
| 139 | + | "/files/tree/nope", "/files/tree/main/no/such/dir", "/files/blob/main/nothing.txt", "/files/blob/nope/main.go", | |
| 140 | + | "/files/blob/main", "/files/blob/main/docs", "/files/tree/main/main.go", "/files/raw/main/nothing.txt", | |
| 141 | + | "/files/raw/main", "/files/raw/main/docs", "/files/blob/ffffffffffffffffffffffffffffffffffffffff/main.go", | |
| 142 | + | "/files/blob", "/files/raw", "/files/blob/main/../main.go", | |
| 143 | + | "/quiet/tree/main", "/quiet/tree", "/quiet/blob/main/x", "/never/blob/main/x", "/never/raw/main/x", | |
| 144 | + | } { | |
| 145 | + | status, hdr, body := h.get(p) | |
| 146 | + | assert.Equal(t, 404, status, p) | |
| 147 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |
| 148 | + | assert.Contains(t, hdr.Get("Content-Type"), "text/html", p) | |
| 149 | + | assert.NotContains(t, body, "nothing.txt", p) | |
| 150 | + | } | |
| 151 | + | status, _, body := h.get("/files/blob/main/nothing.txt") | |
| 152 | + | require.Equal(t, 404, status) | |
| 153 | + | _, _, other := h.get("/files/tree/nope") | |
| 154 | + | assert.Equal(t, normalise(other), normalise(body), "one 404 page for everything") | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // AC2: the tree, blob and raw pages of a private repository are the 404 of a | |
| 158 | + | // missing one. | |
| 159 | + | func TestWebFilesPrivate(t *testing.T) { | |
| 160 | + | h := newHarness(t, harnessOpts{}) | |
| 161 | + | h.seed("secret-sauce", domain.VisibilityPrivate, "README.md", "a.txt") | |
| 162 | + | ||
| 163 | + | for _, kind := range []string{"tree", "blob", "raw"} { | |
| 164 | + | for _, p := range []string{"/secret-sauce/" + kind + "/main", "/secret-sauce/" + kind + "/main/a.txt"} { | |
| 165 | + | status, hdr, body := h.get(p) | |
| 166 | + | assert.Equal(t, 404, status, p) | |
| 167 | + | assert.NotContains(t, body, "secret-sauce", p) | |
| 168 | + | assert.NotContains(t, body, "content of", p) | |
| 169 | + | assert.NotContains(t, hdr.Get("Content-Type"), "text/plain", p) | |
| 170 | + | _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1)) | |
| 171 | + | assert.Equal(t, normalise(missing), normalise(body), "%s is the page of a missing repository", p) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func rawGet(t *testing.T, h *harness, path string) (int, http.Header, []byte) { | |
| 177 | + | t.Helper() | |
| 178 | + | status, hdr, body := h.get(path) | |
| 179 | + | return status, hdr, []byte(body) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // The raw route serves text as text/plain, the four image types as images, | |
| 183 | + | // everything else as an attachment; never as HTML or SVG; and always with | |
| 184 | + | // nosniff and a CSP that makes the file inert. | |
| 185 | + | func TestWebRaw(t *testing.T) { | |
| 186 | + | h := newHarness(t, harnessOpts{}) | |
| 187 | + | h.webFilesRepo("files") | |
| 188 | + | sha := h.bareGit("files", "rev-parse", "main") | |
| 189 | + | ||
| 190 | + | for _, tt := range []struct { | |
| 191 | + | name, path, typ, disposition string | |
| 192 | + | }{ | |
| 193 | + | {"text", "/files/raw/main/notes.txt", "text/plain; charset=utf-8", ""}, | |
| 194 | + | {"code", "/files/raw/main/main.go", "text/plain; charset=utf-8", ""}, | |
| 195 | + | {"markdown", "/files/raw/main/README.md", "text/plain; charset=utf-8", ""}, | |
| 196 | + | {"html is text", "/files/raw/main/page.html", "text/plain; charset=utf-8", ""}, | |
| 197 | + | {"empty", "/files/raw/main/empty.txt", "text/plain; charset=utf-8", ""}, | |
| 198 | + | {"large text", "/files/raw/main/big.txt", "text/plain; charset=utf-8", ""}, | |
| 199 | + | {"png", "/files/raw/main/pic.png", "image/png", ""}, | |
| 200 | + | {"binary", "/files/raw/main/data.bin", "application/octet-stream", `attachment; filename=data.bin`}, | |
| 201 | + | {"svg is text", "/files/raw/main/logo.svg", "text/plain; charset=utf-8", ""}, | |
| 202 | + | {"slash ref", "/files/raw/feature/x/x/y.txt", "text/plain; charset=utf-8", ""}, | |
| 203 | + | {"tag", "/files/raw/v1.0/main.go", "text/plain; charset=utf-8", ""}, | |
| 204 | + | {"commit", "/files/raw/" + sha + "/notes.txt", "text/plain; charset=utf-8", ""}, | |
| 205 | + | } { | |
| 206 | + | t.Run(tt.name, func(t *testing.T) { | |
| 207 | + | status, hdr, body := rawGet(t, h, tt.path) | |
| 208 | + | require.Equal(t, 200, status) | |
| 209 | + | assert.Equal(t, tt.typ, hdr.Get("Content-Type")) | |
| 210 | + | assert.Equal(t, tt.disposition, hdr.Get("Content-Disposition")) | |
| 211 | + | assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options")) | |
| 212 | + | assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy")) | |
| 213 | + | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options")) | |
| 214 | + | assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length")) | |
| 215 | + | if strings.Contains(tt.path, sha) { | |
| 216 | + | assert.Equal(t, "public, max-age=31536000, immutable", hdr.Get("Cache-Control")) | |
| 217 | + | } else { | |
| 218 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), "a branch or a tag moves") | |
| 219 | + | } | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Content is the file, byte for byte. | |
| 224 | + | _, _, body := rawGet(t, h, "/files/raw/main/data.bin") | |
| 225 | + | assert.Equal(t, []byte("\x00\x01\x02binary\x00"), body) | |
| 226 | + | _, _, body = rawGet(t, h, "/files/raw/main/pic.png") | |
| 227 | + | assert.Equal(t, tinyPNG(t), body) | |
| 228 | + | _, _, body = rawGet(t, h, "/files/raw/main/big.txt") | |
| 229 | + | assert.Len(t, body, 17*70000) | |
| 230 | + | _, _, body = rawGet(t, h, "/files/raw/main/evil.go") | |
| 231 | + | assert.Equal(t, webTrojan, string(body), "raw is not altered") | |
| 232 | + | ||
| 233 | + | // A file named like an image is not one unless its bytes say so. | |
| 234 | + | _, hdr, _ := rawGet(t, h, "/files/raw/main/fake.png") | |
| 235 | + | assert.Equal(t, "text/plain; charset=utf-8", hdr.Get("Content-Type"), "the type comes from the bytes, not the name") | |
| 236 | + | _, hdr, _ = rawGet(t, h, "/files/raw/main/fake.jpg") | |
| 237 | + | assert.Equal(t, "image/gif", hdr.Get("Content-Type")) | |
| 238 | + | _, _, page := h.get("/files/blob/main/fake.png") | |
| 239 | + | assert.NotContains(t, page, "<script>alert", "shown as code, escaped") | |
| 240 | + | ||
| 241 | + | // HEAD and conditional requests. | |
| 242 | + | req, err := http.NewRequest(http.MethodGet, h.url("/files/raw/main/notes.txt"), nil) | |
| 243 | + | require.NoError(t, err) | |
| 244 | + | resp, err := http.DefaultClient.Do(req) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | _ = resp.Body.Close() | |
| 247 | + | etag := resp.Header.Get("ETag") | |
| 248 | + | require.NotEmpty(t, etag) | |
| 249 | + | req.Header.Set("If-None-Match", etag) | |
| 250 | + | resp, err = http.DefaultClient.Do(req) | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | _ = resp.Body.Close() | |
| 253 | + | assert.Equal(t, http.StatusNotModified, resp.StatusCode) | |
| 254 | + | assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil)) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | var reLineID = regexp.MustCompile(`<span class="line" id="L(\d+)">`) | |
| 258 | + | ||
| 259 | + | // AC1, AC6: the code view numbers its lines with anchors, the page has no | |
| 260 | + | // inline script or style, and a file that attacks the page is shown inert. | |
| 261 | + | func TestWebBlobCode(t *testing.T) { | |
| 262 | + | h := newHarness(t, harnessOpts{}) | |
| 263 | + | h.webFilesRepo("files") | |
| 264 | + | ||
| 265 | + | _, _, body := h.get("/files/blob/main/main.go") | |
| 266 | + | assert.Equal(t, 8, len(reLineID.FindAllString(body, -1))) | |
| 267 | + | assert.Contains(t, body, `<a class="ln" href="#L6">6</a>`) | |
| 268 | + | assert.Contains(t, body, `<span class="kn">package</span>`) | |
| 269 | + | assert.Contains(t, body, `hi <b>`, "source is escaped") | |
| 270 | + | assert.NotContains(t, body, "<b>") | |
| 271 | + | assertNoInline(t, body) | |
| 272 | + | assert.Regexp(t, `href="/files/raw/main/main.go"`, body) | |
| 273 | + | ||
| 274 | + | // Trojan Source: the controls are shown, not applied. | |
| 275 | + | _, _, body = h.get("/files/blob/main/evil.go") | |
| 276 | + | for _, r := range []string{"\u202e", "\u2066", "\u2069"} { | |
| 277 | + | assert.NotContains(t, body, r, "%+q is not output", r) | |
| 278 | + | } | |
| 279 | + | assert.Contains(t, body, `<span class="ctl" title="U+202E right-to-left override"><U+202E></span>`) | |
| 280 | + | assert.Contains(t, body, "<U+2066>") | |
| 281 | + | assert.Contains(t, body, "<U+2069>") | |
| 282 | + | ||
| 283 | + | // The image and SVG: only the four raster types are images. | |
| 284 | + | _, _, body = h.get("/files/blob/main/pic.png") | |
| 285 | + | assert.Contains(t, body, `<img src="/files/raw/main/pic.png"`) | |
| 286 | + | _, _, body = h.get("/files/blob/main/logo.svg") | |
| 287 | + | assert.NotContains(t, body, "<img") | |
| 288 | + | assert.NotContains(t, body, "<svg", "the source is escaped, never markup") | |
| 289 | + | assert.NotContains(t, body, "<script>alert") | |
| 290 | + | assert.Contains(t, body, "<svg") | |
| 291 | + | assertNoInline(t, body) | |
| 292 | + | ||
| 293 | + | // Too many lines: the first ones, with a notice. | |
| 294 | + | _, _, body = h.get("/files/blob/main/lines.txt") | |
| 295 | + | assert.Equal(t, 20000, len(reLineID.FindAllString(body, -1))) | |
| 296 | + | assert.Contains(t, body, "20005 lines: showing the first 20000") | |
| 297 | + | ||
| 298 | + | // Markdown: rendered, source under a fold, links under the directory. | |
| 299 | + | _, _, body = h.get("/files/blob/main/docs/guide.md") | |
| 300 | + | assert.Contains(t, body, `<details class="source">`) | |
| 301 | + | assert.Contains(t, body, "<h1 id=\"md-guide\">Guide</h1>") | |
| 302 | + | assert.Contains(t, body, `href="#md-steps"`, "outline") | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // AC6 on the new pages: the CSP of the site, the security headers, nothing | |
| 306 | + | // inline and nothing loaded from another origin. | |
| 307 | + | func TestWebFilesContentSecurity(t *testing.T) { | |
| 308 | + | h := newHarness(t, harnessOpts{}) | |
| 309 | + | h.webFilesRepo("files") | |
| 310 | + | ||
| 311 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 312 | + | for _, p := range []string{ | |
| 313 | + | "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md", | |
| 314 | + | "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt", | |
| 315 | + | "/files/blob/main/data.bin", "/files/tree/nope", | |
| 316 | + | } { | |
| 317 | + | _, hdr, body := h.get(p) | |
| 318 | + | assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p) | |
| 319 | + | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p) | |
| 320 | + | assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p) | |
| 321 | + | assertNoInline(t, body) | |
| 322 | + | assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p) | |
| 323 | + | assert.False(t, reImport.MatchString(body), p) | |
| 324 | + | for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) { | |
| 325 | + | assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1]) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // The theme of the code view is a static file with no colour of its own. | |
| 330 | + | _, _, page := h.get("/files/blob/main/main.go") | |
| 331 | + | css := regexp.MustCompile(`href="(/-/static/css/[^"]+)"`).FindStringSubmatch(page) | |
| 332 | + | require.Len(t, css, 2) | |
| 333 | + | _, _, sheet := h.get(css[1]) | |
| 334 | + | assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)") | |
| 335 | + | assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet) | |
| 336 | + | } | |
M internal/adapters/web/ASSETS.md +1 -0
| @@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 11 | | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | | | |
| 12 | + | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 12 | 13 | | `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | | | |
| 13 | 14 | ||
| 14 | 15 | The three htmx files are the exact files the approved mockup was built with. | |
A internal/adapters/web/blob.go +236 -0
| @@ -0,0 +1,236 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "html/template" | |
| 9 | + | "net/http" | |
| 10 | + | "path" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/a-h/templ" | |
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | ||
| 17 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | // maxCodeBytes is the largest file shown in the page; a larger one gets | |
| 22 | + | // a notice and the link to raw. | |
| 23 | + | maxCodeBytes = 1 << 20 | |
| 24 | + | // maxRenderBytes is the largest Markdown file that is rendered; above it | |
| 25 | + | // the file is shown as source only. | |
| 26 | + | maxRenderBytes = 512 << 10 | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // BlobPage is a file. | |
| 30 | + | type BlobPage struct { | |
| 31 | + | Name string | |
| 32 | + | Notice string | |
| 33 | + | // Code is the highlighted source, shown when HasCode. | |
| 34 | + | Code template.HTML | |
| 35 | + | HasCode bool | |
| 36 | + | // Rendered is a Markdown file as HTML; the source is under a fold. | |
| 37 | + | Rendered *Readme | |
| 38 | + | Outline []Heading | |
| 39 | + | // Image says the file is shown as an image loaded from RawHref. | |
| 40 | + | Image bool | |
| 41 | + | Facts []Fact | |
| 42 | + | Last *CommitRow | |
| 43 | + | RawHref string | |
| 44 | + | HistHref string | |
| 45 | + | DirHref string | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // blobStream is an open GetBlob stream whose first message has been read. | |
| 49 | + | type blobStream struct { | |
| 50 | + | stream *connect.ServerStreamForClient[pb.GetBlobResponse] | |
| 51 | + | info *pb.BlobInfo | |
| 52 | + | // buf holds the content already received and not yet handed out. | |
| 53 | + | buf []byte | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // openBlob starts the stream of a file and reads its metadata. | |
| 57 | + | func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) { | |
| 58 | + | if t.path == "" { | |
| 59 | + | return nil, errNotFound | |
| 60 | + | } | |
| 61 | + | st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path})) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | if !st.Receive() { | |
| 66 | + | err := st.Err() | |
| 67 | + | _ = st.Close() | |
| 68 | + | if err == nil { | |
| 69 | + | err = errNotFound | |
| 70 | + | } | |
| 71 | + | return nil, err | |
| 72 | + | } | |
| 73 | + | m := st.Msg() | |
| 74 | + | return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (b *blobStream) close() { _ = b.stream.Close() } | |
| 78 | + | ||
| 79 | + | // fill reads from the stream until buf holds n bytes or the content ends. | |
| 80 | + | func (b *blobStream) fill(n int) error { | |
| 81 | + | for len(b.buf) < n && b.stream.Receive() { | |
| 82 | + | b.buf = append(b.buf, b.stream.Msg().GetChunk()...) | |
| 83 | + | } | |
| 84 | + | return b.stream.Err() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // image types: the only images the pages show and raw serves as images. | |
| 88 | + | var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true} | |
| 89 | + | ||
| 90 | + | // sniffImage is the image type of the first bytes of a file, "" when it is | |
| 91 | + | // not a PNG, JPEG, GIF or WebP. SVG is never one: it can carry scripts. | |
| 92 | + | func sniffImage(head []byte) string { | |
| 93 | + | if len(head) > 512 { | |
| 94 | + | head = head[:512] | |
| 95 | + | } | |
| 96 | + | if t := http.DetectContentType(head); imageTypes[t] { | |
| 97 | + | return t | |
| 98 | + | } | |
| 99 | + | return "" | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // blob shows a file: highlighted source, rendered Markdown with its source | |
| 103 | + | // under a fold, an image, or a notice when the file is too large or binary. | |
| 104 | + | func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 105 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 106 | + | if err != nil { | |
| 107 | + | return nil, 0, err | |
| 108 | + | } | |
| 109 | + | t, ok := d.locate(r.PathValue("rest")) | |
| 110 | + | if !ok { | |
| 111 | + | return nil, 0, errNotFound | |
| 112 | + | } | |
| 113 | + | b, err := s.openBlob(ctx, d.name(), t) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, 0, err | |
| 116 | + | } | |
| 117 | + | defer b.close() | |
| 118 | + | ||
| 119 | + | name := path.Base(t.path) | |
| 120 | + | dir := path.Dir(t.path) | |
| 121 | + | if dir == "." { | |
| 122 | + | dir = "" | |
| 123 | + | } | |
| 124 | + | size := b.info.GetSize() | |
| 125 | + | p := BlobPage{ | |
| 126 | + | Name: markedText(name), | |
| 127 | + | RawHref: repoHref(d.name(), "raw", t.name, t.path), | |
| 128 | + | HistHref: repoHref(d.name(), "commits", t.name, t.path), | |
| 129 | + | DirHref: repoHref(d.name(), "tree", t.name, dir), | |
| 130 | + | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | |
| 131 | + | } | |
| 132 | + | typ := b.info.GetMediaType() | |
| 133 | + | lang := "" | |
| 134 | + | ||
| 135 | + | // An image is recognised by its bytes, whatever Git's text rule says. | |
| 136 | + | if err := b.fill(512); err != nil { | |
| 137 | + | return nil, 0, err | |
| 138 | + | } | |
| 139 | + | img := sniffImage(b.buf) | |
| 140 | + | ||
| 141 | + | var src []byte | |
| 142 | + | switch { | |
| 143 | + | case img != "": | |
| 144 | + | p.Image, typ = true, img | |
| 145 | + | case b.info.GetBinary(): | |
| 146 | + | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | |
| 147 | + | case size > maxCodeBytes: | |
| 148 | + | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | |
| 149 | + | case size == 0: | |
| 150 | + | p.Notice = "empty file." | |
| 151 | + | default: | |
| 152 | + | if err := b.fill(int(size)); err != nil { | |
| 153 | + | return nil, 0, err | |
| 154 | + | } | |
| 155 | + | src = b.buf | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | if src != nil { | |
| 159 | + | text := string(src) | |
| 160 | + | lines := countLines(src) | |
| 161 | + | p.Facts = append(p.Facts, Fact{Key: "lines", Val: itoa(lines)}) | |
| 162 | + | if lines > maxViewLines { | |
| 163 | + | text = firstLines(text, maxViewLines) | |
| 164 | + | p.Notice = fmt.Sprintf("%d lines: showing the first %d. use raw for the rest.", lines, maxViewLines) | |
| 165 | + | } | |
| 166 | + | cv := highlightCtx(ctx, t.path, text) | |
| 167 | + | p.Code, p.HasCode, lang = cv.html, true, cv.lang | |
| 168 | + | if isMarkdown(name) && size <= maxRenderBytes { | |
| 169 | + | h, outline, err := s.md.render(src, d.name(), t.name, dir) | |
| 170 | + | if err == nil { | |
| 171 | + | p.Rendered, p.Outline = &Readme{Name: p.Name, HTML: h}, outline | |
| 172 | + | } else { | |
| 173 | + | log := zerowrap.FromCtx(ctx) | |
| 174 | + | log.Warn().Err(err).Str("repo", d.name()).Msg("render Markdown file, showing source") | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if lang == "" { | |
| 179 | + | lang = typ | |
| 180 | + | } | |
| 181 | + | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | |
| 182 | + | ||
| 183 | + | if last := s.lastChange(ctx, d.name(), t); last != nil { | |
| 184 | + | p.Last = last | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | l := s.repoLayout(ctx, d, "tree") | |
| 188 | + | pinRef(&l, t) | |
| 189 | + | l.Crumbs = fileCrumbs(d.name(), "blob", t) | |
| 190 | + | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | |
| 191 | + | l.Status = Status{ | |
| 192 | + | Mode: "blob", Context: p.Name, | |
| 193 | + | Hints: "click a line number · y permalink · ? help", Blob: p.HasCode, | |
| 194 | + | } | |
| 195 | + | l.HasRail, l.Read, l.RailSum = true, p.Rendered != nil, "file · last change · selection" | |
| 196 | + | return Blob(l, p), http.StatusOK, nil | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // lastChange is the latest commit that touched the path, nil when it cannot | |
| 200 | + | // be found (the page does not depend on it). | |
| 201 | + | func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow { | |
| 202 | + | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 203 | + | Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1, | |
| 204 | + | })) | |
| 205 | + | if err != nil || len(res.Msg.GetCommits()) != 1 { | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | c := res.Msg.GetCommits()[0] | |
| 209 | + | row := &CommitRow{ | |
| 210 | + | Short: short(c.GetId()), Subject: markedText(c.GetSummary()), Author: c.GetAuthor().GetName(), | |
| 211 | + | CHref: repoHref(repo, "commit", c.GetId()), | |
| 212 | + | } | |
| 213 | + | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | |
| 214 | + | row.When, row.WhenAt = ago(s.cfg.Now(), ts.AsTime()), rfc3339(ts.AsTime()) | |
| 215 | + | } | |
| 216 | + | return row | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // isMarkdown says whether a file name is a Markdown file. | |
| 220 | + | func isMarkdown(name string) bool { | |
| 221 | + | n := strings.ToLower(name) | |
| 222 | + | return strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".markdown") | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // firstLines is the first n lines of s. | |
| 226 | + | func firstLines(s string, n int) string { | |
| 227 | + | idx := 0 | |
| 228 | + | for i := 0; i < n; i++ { | |
| 229 | + | j := strings.IndexByte(s[idx:], '\n') | |
| 230 | + | if j < 0 { | |
| 231 | + | return s | |
| 232 | + | } | |
| 233 | + | idx += j + 1 | |
| 234 | + | } | |
| 235 | + | return s[:idx] | |
| 236 | + | } | |
A internal/adapters/web/blob.templ +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import "html/template" | |
| 6 | + | ||
| 7 | + | // Blob is a file: its title with the actions, the notice of a state (too | |
| 8 | + | // large, binary, empty), the code, the rendered Markdown with its source | |
| 9 | + | // under a fold, or the image; the rail holds the facts, the last change, the | |
| 10 | + | // outline and the line selection. | |
| 11 | + | templ Blob(l Layout, p BlobPage) { | |
| 12 | + | @Shell(l, blobRail(p)) { | |
| 13 | + | <h1 class="file-h"> | |
| 14 | + | <span>{ p.Name }</span> | |
| 15 | + | <span class="acts"><a href={ templ.SafeURL(p.RawHref) }>raw</a> <a href={ templ.SafeURL(p.HistHref) }>history</a> <a href={ templ.SafeURL(p.DirHref) }>dir</a></span> | |
| 16 | + | </h1> | |
| 17 | + | if p.Notice != "" { | |
| 18 | + | <p class="notice" role="note">{ p.Notice }</p> | |
| 19 | + | } | |
| 20 | + | if p.Image { | |
| 21 | + | <p class="img-view"><img src={ p.RawHref } alt={ p.Name }/></p> | |
| 22 | + | } | |
| 23 | + | if p.Rendered != nil { | |
| 24 | + | @ReadmeView(*p.Rendered) | |
| 25 | + | } | |
| 26 | + | if p.HasCode { | |
| 27 | + | if p.Rendered != nil { | |
| 28 | + | <details class="source"> | |
| 29 | + | <summary>source</summary> | |
| 30 | + | @CodeView(p.Code) | |
| 31 | + | </details> | |
| 32 | + | } else { | |
| 33 | + | @CodeView(p.Code) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // CodeView is the highlighted lines with a link on each number. A click on a | |
| 40 | + | // number selects the line, shift-click extends the selection to a range; the | |
| 41 | + | // URL hash (#L10 or #L10-L24) is the state, so a page loaded with a hash is | |
| 42 | + | // painted the same way. body[data-sel] feeds the status bar and the rail. | |
| 43 | + | templ CodeView(h template.HTML) { | |
| 44 | + | <pre | |
| 45 | + | class="chroma code" | |
| 46 | + | data-anchor="" | |
| 47 | + | hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault(); | |
| 48 | + | let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n; | |
| 49 | + | let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n); | |
| 50 | + | history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')" | |
| 51 | + | hx-on:sel="let m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0; | |
| 52 | + | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 53 | + | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 54 | + | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" | |
| 55 | + | hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" | |
| 56 | + | data-ready="" | |
| 57 | + | ><code>@templ.Raw(string(h))</code></pre> | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | templ blobRail(p BlobPage) { | |
| 61 | + | <section aria-labelledby="r-file"> | |
| 62 | + | <h2 id="r-file">file</h2> | |
| 63 | + | @Facts(p.Facts) | |
| 64 | + | </section> | |
| 65 | + | if p.Last != nil { | |
| 66 | + | <section aria-labelledby="r-last"> | |
| 67 | + | <h2 id="r-last">last change <a href={ templ.SafeURL(p.HistHref) }>history</a></h2> | |
| 68 | + | <p><a href={ templ.SafeURL(p.Last.CHref) }>{ p.Last.Short }</a> { p.Last.Subject }</p> | |
| 69 | + | <p class="dim"> | |
| 70 | + | { p.Last.Author } · | |
| 71 | + | if p.Last.WhenAt != "" { | |
| 72 | + | <time datetime={ p.Last.WhenAt } title={ p.Last.WhenAt }>{ p.Last.When }</time> | |
| 73 | + | } | |
| 74 | + | </p> | |
| 75 | + | </section> | |
| 76 | + | } | |
| 77 | + | if len(p.Outline) > 0 { | |
| 78 | + | <section aria-labelledby="r-outline"> | |
| 79 | + | <h2 id="r-outline">outline</h2> | |
| 80 | + | @Outline(p.Outline) | |
| 81 | + | </section> | |
| 82 | + | } | |
| 83 | + | if p.HasCode { | |
| 84 | + | <section aria-labelledby="r-sel"> | |
| 85 | + | <h2 id="r-sel">selection</h2> | |
| 86 | + | <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p> | |
| 87 | + | <p class="dim">y copies a permalink pinned to the commit sha</p> | |
| 88 | + | </section> | |
| 89 | + | } | |
| 90 | + | } | |
A internal/adapters/web/blob_templ.go +426 -0
| @@ -0,0 +1,426 @@ | |||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | // templ: version: v0.3.1020 | |
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 5 | + | ||
| 6 | + | package web | |
| 7 | + | ||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |
| 9 | + | ||
| 10 | + | import "github.com/a-h/templ" | |
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |
| 12 | + | ||
| 13 | + | import "html/template" | |
| 14 | + | ||
| 15 | + | // Blob is a file: its title with the actions, the notice of a state (too | |
| 16 | + | // large, binary, empty), the code, the rendered Markdown with its source | |
| 17 | + | // under a fold, or the image; the rail holds the facts, the last change, the | |
| 18 | + | // outline and the line selection. | |
| 19 | + | func Blob(l Layout, p BlobPage) templ.Component { | |
| 20 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 21 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 22 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 23 | + | return templ_7745c5c3_CtxErr | |
| 24 | + | } | |
| 25 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 26 | + | if !templ_7745c5c3_IsBuffer { | |
| 27 | + | defer func() { | |
| 28 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 29 | + | if templ_7745c5c3_Err == nil { | |
| 30 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 31 | + | } | |
| 32 | + | }() | |
| 33 | + | } | |
| 34 | + | ctx = templ.InitializeContext(ctx) | |
| 35 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |
| 36 | + | if templ_7745c5c3_Var1 == nil { | |
| 37 | + | templ_7745c5c3_Var1 = templ.NopComponent | |
| 38 | + | } | |
| 39 | + | ctx = templ.ClearChildren(ctx) | |
| 40 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 41 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 42 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 43 | + | if !templ_7745c5c3_IsBuffer { | |
| 44 | + | defer func() { | |
| 45 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 46 | + | if templ_7745c5c3_Err == nil { | |
| 47 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 48 | + | } | |
| 49 | + | }() | |
| 50 | + | } | |
| 51 | + | ctx = templ.InitializeContext(ctx) | |
| 52 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"file-h\"><span>") | |
| 53 | + | if templ_7745c5c3_Err != nil { | |
| 54 | + | return templ_7745c5c3_Err | |
| 55 | + | } | |
| 56 | + | var templ_7745c5c3_Var3 string | |
| 57 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(p.Name) | |
| 58 | + | if templ_7745c5c3_Err != nil { | |
| 59 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 14, Col: 17} | |
| 60 | + | } | |
| 61 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | |
| 62 | + | if templ_7745c5c3_Err != nil { | |
| 63 | + | return templ_7745c5c3_Err | |
| 64 | + | } | |
| 65 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span> <span class=\"acts\"><a href=\"") | |
| 66 | + | if templ_7745c5c3_Err != nil { | |
| 67 | + | return templ_7745c5c3_Err | |
| 68 | + | } | |
| 69 | + | var templ_7745c5c3_Var4 templ.SafeURL | |
| 70 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref)) | |
| 71 | + | if templ_7745c5c3_Err != nil { | |
| 72 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 15, Col: 56} | |
| 73 | + | } | |
| 74 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 75 | + | if templ_7745c5c3_Err != nil { | |
| 76 | + | return templ_7745c5c3_Err | |
| 77 | + | } | |
| 78 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">raw</a> <a href=\"") | |
| 79 | + | if templ_7745c5c3_Err != nil { | |
| 80 | + | return templ_7745c5c3_Err | |
| 81 | + | } | |
| 82 | + | var templ_7745c5c3_Var5 templ.SafeURL | |
| 83 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.HistHref)) | |
| 84 | + | if templ_7745c5c3_Err != nil { | |
| 85 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 15, Col: 102} | |
| 86 | + | } | |
| 87 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 88 | + | if templ_7745c5c3_Err != nil { | |
| 89 | + | return templ_7745c5c3_Err | |
| 90 | + | } | |
| 91 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">history</a> <a href=\"") | |
| 92 | + | if templ_7745c5c3_Err != nil { | |
| 93 | + | return templ_7745c5c3_Err | |
| 94 | + | } | |
| 95 | + | var templ_7745c5c3_Var6 templ.SafeURL | |
| 96 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.DirHref)) | |
| 97 | + | if templ_7745c5c3_Err != nil { | |
| 98 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 15, Col: 151} | |
| 99 | + | } | |
| 100 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 101 | + | if templ_7745c5c3_Err != nil { | |
| 102 | + | return templ_7745c5c3_Err | |
| 103 | + | } | |
| 104 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">dir</a></span></h1>") | |
| 105 | + | if templ_7745c5c3_Err != nil { | |
| 106 | + | return templ_7745c5c3_Err | |
| 107 | + | } | |
| 108 | + | if p.Notice != "" { | |
| 109 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"notice\" role=\"note\">") | |
| 110 | + | if templ_7745c5c3_Err != nil { | |
| 111 | + | return templ_7745c5c3_Err | |
| 112 | + | } | |
| 113 | + | var templ_7745c5c3_Var7 string | |
| 114 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(p.Notice) | |
| 115 | + | if templ_7745c5c3_Err != nil { | |
| 116 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 18, Col: 43} | |
| 117 | + | } | |
| 118 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 119 | + | if templ_7745c5c3_Err != nil { | |
| 120 | + | return templ_7745c5c3_Err | |
| 121 | + | } | |
| 122 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p>") | |
| 123 | + | if templ_7745c5c3_Err != nil { | |
| 124 | + | return templ_7745c5c3_Err | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") | |
| 128 | + | if templ_7745c5c3_Err != nil { | |
| 129 | + | return templ_7745c5c3_Err | |
| 130 | + | } | |
| 131 | + | if p.Image { | |
| 132 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"img-view\"><img src=\"") | |
| 133 | + | if templ_7745c5c3_Err != nil { | |
| 134 | + | return templ_7745c5c3_Err | |
| 135 | + | } | |
| 136 | + | var templ_7745c5c3_Var8 string | |
| 137 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.RawHref) | |
| 138 | + | if templ_7745c5c3_Err != nil { | |
| 139 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 21, Col: 43} | |
| 140 | + | } | |
| 141 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) | |
| 142 | + | if templ_7745c5c3_Err != nil { | |
| 143 | + | return templ_7745c5c3_Err | |
| 144 | + | } | |
| 145 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" alt=\"") | |
| 146 | + | if templ_7745c5c3_Err != nil { | |
| 147 | + | return templ_7745c5c3_Err | |
| 148 | + | } | |
| 149 | + | var templ_7745c5c3_Var9 string | |
| 150 | + | templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Name) | |
| 151 | + | if templ_7745c5c3_Err != nil { | |
| 152 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 21, Col: 58} | |
| 153 | + | } | |
| 154 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) | |
| 155 | + | if templ_7745c5c3_Err != nil { | |
| 156 | + | return templ_7745c5c3_Err | |
| 157 | + | } | |
| 158 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></p>") | |
| 159 | + | if templ_7745c5c3_Err != nil { | |
| 160 | + | return templ_7745c5c3_Err | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") | |
| 164 | + | if templ_7745c5c3_Err != nil { | |
| 165 | + | return templ_7745c5c3_Err | |
| 166 | + | } | |
| 167 | + | if p.Rendered != nil { | |
| 168 | + | templ_7745c5c3_Err = ReadmeView(*p.Rendered).Render(ctx, templ_7745c5c3_Buffer) | |
| 169 | + | if templ_7745c5c3_Err != nil { | |
| 170 | + | return templ_7745c5c3_Err | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") | |
| 174 | + | if templ_7745c5c3_Err != nil { | |
| 175 | + | return templ_7745c5c3_Err | |
| 176 | + | } | |
| 177 | + | if p.HasCode { | |
| 178 | + | if p.Rendered != nil { | |
| 179 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<details class=\"source\"><summary>source</summary>") | |
| 180 | + | if templ_7745c5c3_Err != nil { | |
| 181 | + | return templ_7745c5c3_Err | |
| 182 | + | } | |
| 183 | + | templ_7745c5c3_Err = CodeView(p.Code).Render(ctx, templ_7745c5c3_Buffer) | |
| 184 | + | if templ_7745c5c3_Err != nil { | |
| 185 | + | return templ_7745c5c3_Err | |
| 186 | + | } | |
| 187 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</details>") | |
| 188 | + | if templ_7745c5c3_Err != nil { | |
| 189 | + | return templ_7745c5c3_Err | |
| 190 | + | } | |
| 191 | + | } else { | |
| 192 | + | templ_7745c5c3_Err = CodeView(p.Code).Render(ctx, templ_7745c5c3_Buffer) | |
| 193 | + | if templ_7745c5c3_Err != nil { | |
| 194 | + | return templ_7745c5c3_Err | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | return nil | |
| 199 | + | }) | |
| 200 | + | templ_7745c5c3_Err = Shell(l, blobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |
| 201 | + | if templ_7745c5c3_Err != nil { | |
| 202 | + | return templ_7745c5c3_Err | |
| 203 | + | } | |
| 204 | + | return nil | |
| 205 | + | }) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // CodeView is the highlighted lines with a link on each number. A click on a | |
| 209 | + | // number selects the line, shift-click extends the selection to a range; the | |
| 210 | + | // URL hash (#L10 or #L10-L24) is the state, so a page loaded with a hash is | |
| 211 | + | // painted the same way. body[data-sel] feeds the status bar and the rail. | |
| 212 | + | func CodeView(h template.HTML) templ.Component { | |
| 213 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 214 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 215 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 216 | + | return templ_7745c5c3_CtxErr | |
| 217 | + | } | |
| 218 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 219 | + | if !templ_7745c5c3_IsBuffer { | |
| 220 | + | defer func() { | |
| 221 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 222 | + | if templ_7745c5c3_Err == nil { | |
| 223 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 224 | + | } | |
| 225 | + | }() | |
| 226 | + | } | |
| 227 | + | ctx = templ.InitializeContext(ctx) | |
| 228 | + | templ_7745c5c3_Var10 := templ.GetChildren(ctx) | |
| 229 | + | if templ_7745c5c3_Var10 == nil { | |
| 230 | + | templ_7745c5c3_Var10 = templ.NopComponent | |
| 231 | + | } | |
| 232 | + | ctx = templ.ClearChildren(ctx) | |
| 233 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<pre class=\"chroma code\" data-anchor=\"\" hx-on:click=\"let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();\n let n = +a.textContent; if (!event.shiftKey || !data.anchor) data.anchor = n;\n let lo = Math.min(+data.anchor, n), hi = Math.max(+data.anchor, n);\n history.replaceState(null, '', '#L' + lo + (hi > lo ? '-L' + hi : '')); trigger('sel')\" hx-on:sel=\"let m = location.hash.match(/^#L(\\d+)(?:-L(\\d+))?$/); let lo = m ? +m[1] : 0, hi = m ? +(m[2] || m[1]) : 0;\n q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });\n document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';\n if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })\" hx-live=\"if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }\" data-ready=\"\"><code>") | |
| 234 | + | if templ_7745c5c3_Err != nil { | |
| 235 | + | return templ_7745c5c3_Err | |
| 236 | + | } | |
| 237 | + | templ_7745c5c3_Err = templ.Raw(string(h)).Render(ctx, templ_7745c5c3_Buffer) | |
| 238 | + | if templ_7745c5c3_Err != nil { | |
| 239 | + | return templ_7745c5c3_Err | |
| 240 | + | } | |
| 241 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</code></pre>") | |
| 242 | + | if templ_7745c5c3_Err != nil { | |
| 243 | + | return templ_7745c5c3_Err | |
| 244 | + | } | |
| 245 | + | return nil | |
| 246 | + | }) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func blobRail(p BlobPage) templ.Component { | |
| 250 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 251 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 252 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 253 | + | return templ_7745c5c3_CtxErr | |
| 254 | + | } | |
| 255 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 256 | + | if !templ_7745c5c3_IsBuffer { | |
| 257 | + | defer func() { | |
| 258 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 259 | + | if templ_7745c5c3_Err == nil { | |
| 260 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 261 | + | } | |
| 262 | + | }() | |
| 263 | + | } | |
| 264 | + | ctx = templ.InitializeContext(ctx) | |
| 265 | + | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | |
| 266 | + | if templ_7745c5c3_Var11 == nil { | |
| 267 | + | templ_7745c5c3_Var11 = templ.NopComponent | |
| 268 | + | } | |
| 269 | + | ctx = templ.ClearChildren(ctx) | |
| 270 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<section aria-labelledby=\"r-file\"><h2 id=\"r-file\">file</h2>") | |
| 271 | + | if templ_7745c5c3_Err != nil { | |
| 272 | + | return templ_7745c5c3_Err | |
| 273 | + | } | |
| 274 | + | templ_7745c5c3_Err = Facts(p.Facts).Render(ctx, templ_7745c5c3_Buffer) | |
| 275 | + | if templ_7745c5c3_Err != nil { | |
| 276 | + | return templ_7745c5c3_Err | |
| 277 | + | } | |
| 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</section>") | |
| 279 | + | if templ_7745c5c3_Err != nil { | |
| 280 | + | return templ_7745c5c3_Err | |
| 281 | + | } | |
| 282 | + | if p.Last != nil { | |
| 283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<section aria-labelledby=\"r-last\"><h2 id=\"r-last\">last change <a href=\"") | |
| 284 | + | if templ_7745c5c3_Err != nil { | |
| 285 | + | return templ_7745c5c3_Err | |
| 286 | + | } | |
| 287 | + | var templ_7745c5c3_Var12 templ.SafeURL | |
| 288 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.HistHref)) | |
| 289 | + | if templ_7745c5c3_Err != nil { | |
| 290 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 67, Col: 66} | |
| 291 | + | } | |
| 292 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 293 | + | if templ_7745c5c3_Err != nil { | |
| 294 | + | return templ_7745c5c3_Err | |
| 295 | + | } | |
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">history</a></h2><p><a href=\"") | |
| 297 | + | if templ_7745c5c3_Err != nil { | |
| 298 | + | return templ_7745c5c3_Err | |
| 299 | + | } | |
| 300 | + | var templ_7745c5c3_Var13 templ.SafeURL | |
| 301 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Last.CHref)) | |
| 302 | + | if templ_7745c5c3_Err != nil { | |
| 303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 68, Col: 43} | |
| 304 | + | } | |
| 305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 306 | + | if templ_7745c5c3_Err != nil { | |
| 307 | + | return templ_7745c5c3_Err | |
| 308 | + | } | |
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">") | |
| 310 | + | if templ_7745c5c3_Err != nil { | |
| 311 | + | return templ_7745c5c3_Err | |
| 312 | + | } | |
| 313 | + | var templ_7745c5c3_Var14 string | |
| 314 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Last.Short) | |
| 315 | + | if templ_7745c5c3_Err != nil { | |
| 316 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 68, Col: 60} | |
| 317 | + | } | |
| 318 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 319 | + | if templ_7745c5c3_Err != nil { | |
| 320 | + | return templ_7745c5c3_Err | |
| 321 | + | } | |
| 322 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</a> ") | |
| 323 | + | if templ_7745c5c3_Err != nil { | |
| 324 | + | return templ_7745c5c3_Err | |
| 325 | + | } | |
| 326 | + | var templ_7745c5c3_Var15 string | |
| 327 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Last.Subject) | |
| 328 | + | if templ_7745c5c3_Err != nil { | |
| 329 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 68, Col: 83} | |
| 330 | + | } | |
| 331 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 332 | + | if templ_7745c5c3_Err != nil { | |
| 333 | + | return templ_7745c5c3_Err | |
| 334 | + | } | |
| 335 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p><p class=\"dim\">") | |
| 336 | + | if templ_7745c5c3_Err != nil { | |
| 337 | + | return templ_7745c5c3_Err | |
| 338 | + | } | |
| 339 | + | var templ_7745c5c3_Var16 string | |
| 340 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(p.Last.Author) | |
| 341 | + | if templ_7745c5c3_Err != nil { | |
| 342 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 70, Col: 19} | |
| 343 | + | } | |
| 344 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 345 | + | if templ_7745c5c3_Err != nil { | |
| 346 | + | return templ_7745c5c3_Err | |
| 347 | + | } | |
| 348 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " · ") | |
| 349 | + | if templ_7745c5c3_Err != nil { | |
| 350 | + | return templ_7745c5c3_Err | |
| 351 | + | } | |
| 352 | + | if p.Last.WhenAt != "" { | |
| 353 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<time datetime=\"") | |
| 354 | + | if templ_7745c5c3_Err != nil { | |
| 355 | + | return templ_7745c5c3_Err | |
| 356 | + | } | |
| 357 | + | var templ_7745c5c3_Var17 string | |
| 358 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Last.WhenAt) | |
| 359 | + | if templ_7745c5c3_Err != nil { | |
| 360 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 72, Col: 35} | |
| 361 | + | } | |
| 362 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 363 | + | if templ_7745c5c3_Err != nil { | |
| 364 | + | return templ_7745c5c3_Err | |
| 365 | + | } | |
| 366 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"") | |
| 367 | + | if templ_7745c5c3_Err != nil { | |
| 368 | + | return templ_7745c5c3_Err | |
| 369 | + | } | |
| 370 | + | var templ_7745c5c3_Var18 string | |
| 371 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Last.WhenAt) | |
| 372 | + | if templ_7745c5c3_Err != nil { | |
| 373 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 72, Col: 59} | |
| 374 | + | } | |
| 375 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) | |
| 376 | + | if templ_7745c5c3_Err != nil { | |
| 377 | + | return templ_7745c5c3_Err | |
| 378 | + | } | |
| 379 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">") | |
| 380 | + | if templ_7745c5c3_Err != nil { | |
| 381 | + | return templ_7745c5c3_Err | |
| 382 | + | } | |
| 383 | + | var templ_7745c5c3_Var19 string | |
| 384 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Last.When) | |
| 385 | + | if templ_7745c5c3_Err != nil { | |
| 386 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/blob.templ`, Line: 72, Col: 75} | |
| 387 | + | } | |
| 388 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 389 | + | if templ_7745c5c3_Err != nil { | |
| 390 | + | return templ_7745c5c3_Err | |
| 391 | + | } | |
| 392 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time>") | |
| 393 | + | if templ_7745c5c3_Err != nil { | |
| 394 | + | return templ_7745c5c3_Err | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</p></section>") | |
| 398 | + | if templ_7745c5c3_Err != nil { | |
| 399 | + | return templ_7745c5c3_Err | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | if len(p.Outline) > 0 { | |
| 403 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">outline</h2>") | |
| 404 | + | if templ_7745c5c3_Err != nil { | |
| 405 | + | return templ_7745c5c3_Err | |
| 406 | + | } | |
| 407 | + | templ_7745c5c3_Err = Outline(p.Outline).Render(ctx, templ_7745c5c3_Buffer) | |
| 408 | + | if templ_7745c5c3_Err != nil { | |
| 409 | + | return templ_7745c5c3_Err | |
| 410 | + | } | |
| 411 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</section>") | |
| 412 | + | if templ_7745c5c3_Err != nil { | |
| 413 | + | return templ_7745c5c3_Err | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | if p.HasCode { | |
| 417 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<section aria-labelledby=\"r-sel\"><h2 id=\"r-sel\">selection</h2><p :text=\"document.body.dataset.sel || 'none · click a line number, shift-click extends'\">none · click a line number, shift-click extends</p><p class=\"dim\">y copies a permalink pinned to the commit sha</p></section>") | |
| 418 | + | if templ_7745c5c3_Err != nil { | |
| 419 | + | return templ_7745c5c3_Err | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | return nil | |
| 423 | + | }) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | var _ = templruntime.GeneratedTemplate | |
A internal/adapters/web/highlight_test.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestHighlightLines(t *testing.T) { | |
| 17 | + | v := highlight("a.go", "package main\n\nfunc main() {}\n") | |
| 18 | + | assert.Equal(t, 3, v.lines) | |
| 19 | + | assert.Equal(t, "Go", v.lang) | |
| 20 | + | h := string(v.html) | |
| 21 | + | assert.Contains(t, h, `<span class="line" id="L1"><a class="ln" href="#L1">1</a><span class="kn">package</span>`) | |
| 22 | + | assert.Contains(t, h, `<span class="line" id="L2"><a class="ln" href="#L2">2</a></span>`) | |
| 23 | + | assert.NotContains(t, h, "style=") | |
| 24 | + | ||
| 25 | + | // No final line feed, CRLF and lone CR are lines too. | |
| 26 | + | assert.Equal(t, 2, highlight("x.txt", "a\nb").lines) | |
| 27 | + | assert.Equal(t, 3, highlight("x.txt", "a\r\nb\rc").lines) | |
| 28 | + | assert.Equal(t, 0, highlight("x.txt", "").lines) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | |
| 32 | + | v := highlight("unknown.zzz", "<script>alert(1)</script> & \"q\"\n") | |
| 33 | + | assert.Equal(t, "", v.lang) | |
| 34 | + | h := string(v.html) | |
| 35 | + | assert.Contains(t, h, "<script>alert(1)</script> & "q"") | |
| 36 | + | assert.NotContains(t, h, "<script>") | |
| 37 | + | ||
| 38 | + | // Invalid UTF-8 is replaced, not passed on. | |
| 39 | + | assert.Contains(t, string(highlight("x.txt", "a\xffb\n").html), "a\uFFFDb") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |
| 43 | + | for r, cp := range map[rune]string{ | |
| 44 | + | 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF", | |
| 45 | + | 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F", | |
| 46 | + | } { | |
| 47 | + | h := string(highlight("x.go", "a"+string(r)+"b\n").html) | |
| 48 | + | assert.NotContains(t, h, string(r), cp) | |
| 49 | + | assert.Contains(t, h, "<"+cp+"></span>", cp) | |
| 50 | + | assert.Contains(t, h, `<span class="ctl" title="`+cp+" ", cp) | |
| 51 | + | } | |
| 52 | + | // A tab and ordinary non-ASCII text are left alone. | |
| 53 | + | h := string(highlight("x.txt", "\tcafé 日本\n").html) | |
| 54 | + | assert.Contains(t, h, "\tcafé 日本") | |
| 55 | + | assert.NotContains(t, h, "ctl") | |
| 56 | + | // Inside a token of a lexer too. | |
| 57 | + | h = string(highlight("x.go", "x := \"a\u202eb\"\n").html) | |
| 58 | + | assert.NotContains(t, h, "\u202e") | |
| 59 | + | assert.Contains(t, h, "<U+202E>") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestMarkedText(t *testing.T) { | |
| 63 | + | assert.Equal(t, "plain.go", markedText("plain.go")) | |
| 64 | + | assert.Equal(t, "a<U+202E>b", markedText("a\u202eb")) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // The lexers do not lose or add characters: the text of the lines is the | |
| 68 | + | // source, whatever the language. | |
| 69 | + | func TestHighlightKeepsTheSource(t *testing.T) { | |
| 70 | + | srcs := map[string]string{ | |
| 71 | + | "a.go": "package a\n\nvar s = `multi\nline`\n// end", | |
| 72 | + | "b.py": "def f():\n '''doc\n string'''\n return 1\n", | |
| 73 | + | "c.md": "# T\n\n```go\nx := 1\n```\n", | |
| 74 | + | "d.json": "{\"a\": [1, 2, {\"b\": null}]}\n", | |
| 75 | + | "e.sh": "echo \"$(date)\" | tr a b\n", | |
| 76 | + | "f.yaml": "a:\n - b\n - c: d\n", | |
| 77 | + | "Makefile": "all:\n\t@echo hi\n", | |
| 78 | + | "g.rs": "fn main() { let _ = \"x\"; }\n", | |
| 79 | + | "h.html": "<p class=\"a\">x & y</p>\n", | |
| 80 | + | "i.templ": "templ A() {\n\t<p>x</p>\n}\n", | |
| 81 | + | "Dockerfile": "FROM alpine\nRUN echo hi\n", | |
| 82 | + | } | |
| 83 | + | for name, src := range srcs { | |
| 84 | + | v := highlight(name, src) | |
| 85 | + | var text strings.Builder | |
| 86 | + | for _, l := range strings.Split(string(v.html), "\n") { | |
| 87 | + | l = strings.TrimSpace(l) | |
| 88 | + | if l == "" { | |
| 89 | + | continue | |
| 90 | + | } | |
| 91 | + | text.WriteString(stripTags(l) + "\n") | |
| 92 | + | } | |
| 93 | + | want := src | |
| 94 | + | assert.Equal(t, strings.Count(strings.TrimSuffix(src, "\n"), "\n")+1, v.lines, name) | |
| 95 | + | assert.Equal(t, normSpace(want), normSpace(unescape(text.String())), name) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestFirstLinesAndCount(t *testing.T) { | |
| 100 | + | assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2)) | |
| 101 | + | assert.Equal(t, "a\nb", firstLines("a\nb", 5)) | |
| 102 | + | assert.Equal(t, 3, countLines([]byte("a\nb\nc"))) | |
| 103 | + | assert.Equal(t, 3, countLines([]byte("a\nb\nc\n"))) | |
| 104 | + | assert.Equal(t, 0, countLines(nil)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestHighlightCtxCancelled(t *testing.T) { | |
| 108 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 109 | + | cancel() | |
| 110 | + | v := highlightCtx(ctx, "a.go", "package a\n") | |
| 111 | + | assert.Equal(t, 1, v.lines, "a cancelled context still gives the plain text, or the highlighted one") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestSniffImage(t *testing.T) { | |
| 115 | + | assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"))) | |
| 116 | + | assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a...."))) | |
| 117 | + | assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF"))) | |
| 118 | + | assert.Equal(t, "", sniffImage([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`))) | |
| 119 | + | assert.Equal(t, "", sniffImage([]byte(`<?xml version="1.0"?><svg/>`))) | |
| 120 | + | assert.Equal(t, "", sniffImage([]byte("plain"))) | |
| 121 | + | assert.Equal(t, "", sniffImage(nil)) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestLocate(t *testing.T) { | |
| 125 | + | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 126 | + | d := &repoData{ | |
| 127 | + | branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, | |
| 128 | + | tags: []*pb.Ref{ref(tag, "v1/rc")}, | |
| 129 | + | } | |
| 130 | + | d.head = d.branches[0] | |
| 131 | + | for _, tt := range []struct { | |
| 132 | + | in, name, path string | |
| 133 | + | detached, ok bool | |
| 134 | + | }{ | |
| 135 | + | {"", "main", "", false, true}, | |
| 136 | + | {"main/a/b.go", "main", "a/b.go", false, true}, | |
| 137 | + | {"feature/x/dir/f", "feature/x", "dir/f", false, true}, | |
| 138 | + | {"v1/rc", "v1/rc", "", false, true}, | |
| 139 | + | {"0123abc/f.txt", "0123abc", "f.txt", true, true}, | |
| 140 | + | {"feature/y/f", "", "", false, false}, | |
| 141 | + | {"abc/f", "", "", false, false}, | |
| 142 | + | {"MAIN/f", "", "", false, false}, | |
| 143 | + | } { | |
| 144 | + | got, ok := d.locate(tt.in) | |
| 145 | + | assert.Equal(t, tt.ok, ok, tt.in) | |
| 146 | + | if !ok { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | assert.Equal(t, tt.name, got.name, tt.in) | |
| 150 | + | assert.Equal(t, tt.path, got.path, tt.in) | |
| 151 | + | assert.Equal(t, tt.detached, got.detached, tt.in) | |
| 152 | + | } | |
| 153 | + | empty := &repoData{} | |
| 154 | + | _, ok := empty.locate("") | |
| 155 | + | assert.False(t, ok, "an empty repository has no head") | |
| 156 | + | require.True(t, looksLikeCommit(strings.Repeat("a", 40))) | |
| 157 | + | assert.False(t, looksLikeCommit(strings.Repeat("a", 41))) | |
| 158 | + | assert.False(t, looksLikeCommit("abcdef")) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestFileCrumbs(t *testing.T) { | |
| 162 | + | cs := fileCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"}) | |
| 163 | + | require.Len(t, cs, 5) | |
| 164 | + | assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href) | |
| 165 | + | assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href) | |
| 166 | + | assert.True(t, cs[4].Current) | |
| 167 | + | cs = fileCrumbs("demo", "tree", target{name: "main", path: "src"}) | |
| 168 | + | assert.Equal(t, "/demo/tree/main/src", cs[3].Href) | |
| 169 | + | assert.Equal(t, "a<U+202E>b", fileCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func stripTags(s string) string { | |
| 173 | + | var b strings.Builder | |
| 174 | + | in := false | |
| 175 | + | for _, r := range s { | |
| 176 | + | switch { | |
| 177 | + | case r == '<': | |
| 178 | + | in = true | |
| 179 | + | case r == '>': | |
| 180 | + | in = false | |
| 181 | + | case !in: | |
| 182 | + | b.WriteRune(r) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | return b.String() | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func unescape(s string) string { | |
| 189 | + | return strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'").Replace(s) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // normSpace drops the leading numbers of the gutter and the white space, which | |
| 193 | + | // the tests do not care about. | |
| 194 | + | func normSpace(s string) string { | |
| 195 | + | var out []string | |
| 196 | + | for _, l := range strings.Split(s, "\n") { | |
| 197 | + | l = strings.TrimSpace(l) | |
| 198 | + | if l == "" { | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | i := 0 | |
| 202 | + | for i < len(l) && l[i] >= '0' && l[i] <= '9' { | |
| 203 | + | i++ | |
| 204 | + | } | |
| 205 | + | if l = strings.Join(strings.Fields(l[i:]), ""); l != "" { | |
| 206 | + | out = append(out, l) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | return strings.Join(out, "\n") | |
| 210 | + | } | |
A internal/adapters/web/raw.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "mime" | |
| 8 | + | "net/http" | |
| 9 | + | "path" | |
| 10 | + | "strconv" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // rawTimeout bounds the stream of one file to the visitor. | |
| 17 | + | const rawTimeout = 5 * time.Minute | |
| 18 | + | ||
| 19 | + | // rawCSP makes a raw file inert if a browser opens it as a document: it can | |
| 20 | + | // load nothing and run nothing, whatever it contains. | |
| 21 | + | const rawCSP = "default-src 'none'; sandbox" | |
| 22 | + | ||
| 23 | + | // raw streams the content of a file. Text is served as text/plain, the four | |
| 24 | + | // image types (by their bytes, never SVG) as images, and anything else as an | |
| 25 | + | // attachment of unknown type. A file is never served as HTML or SVG, so what | |
| 26 | + | // a repository holds cannot run in the origin of the forge. | |
| 27 | + | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |
| 28 | + | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | |
| 29 | + | defer cancel() | |
| 30 | + | b, t, repo, err := s.rawOpen(ctx, r) | |
| 31 | + | if err != nil { | |
| 32 | + | s.fail(ctx, w, r, err) | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | defer b.close() | |
| 36 | + | ||
| 37 | + | // The type needs the first bytes; headers must not be sent before. | |
| 38 | + | if err := b.fill(512); err != nil { | |
| 39 | + | s.fail(ctx, w, r, err) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | typ, attach := rawType(b) | |
| 43 | + | h := w.Header() | |
| 44 | + | h.Set("Content-Type", typ) | |
| 45 | + | h.Set("X-Content-Type-Options", "nosniff") | |
| 46 | + | h.Set("Content-Security-Policy", rawCSP) | |
| 47 | + | h.Set("Content-Length", strconv.FormatInt(b.info.GetSize(), 10)) | |
| 48 | + | if attach { | |
| 49 | + | name := path.Base(t.path) | |
| 50 | + | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | |
| 51 | + | if cd == "" { | |
| 52 | + | cd = "attachment" | |
| 53 | + | } | |
| 54 | + | h.Set("Content-Disposition", cd) | |
| 55 | + | } | |
| 56 | + | if etag := b.info.GetId(); etag != "" { | |
| 57 | + | h.Set("ETag", `"`+etag+`"`) | |
| 58 | + | } | |
| 59 | + | // A commit named in full never changes; a branch or a tag moves. | |
| 60 | + | if t.detached && len(t.rev) == 40 { | |
| 61 | + | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 62 | + | } else { | |
| 63 | + | h.Set("Cache-Control", "no-cache") | |
| 64 | + | } | |
| 65 | + | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | |
| 66 | + | w.WriteHeader(http.StatusNotModified) | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | w.WriteHeader(http.StatusOK) | |
| 70 | + | if r.Method == http.MethodHead { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | if _, err := w.Write(b.buf); err != nil { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | b.buf = nil | |
| 77 | + | for b.stream.Receive() { | |
| 78 | + | if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil { | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | if err := b.stream.Err(); err != nil { | |
| 83 | + | // Headers are gone: all that is left is to cut the answer short, so | |
| 84 | + | // that the client sees it is incomplete. | |
| 85 | + | log := zerowrap.FromCtx(ctx) | |
| 86 | + | log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed") | |
| 87 | + | panic(http.ErrAbortHandler) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // rawOpen finds the file a raw URL designates and opens its stream. | |
| 92 | + | func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) { | |
| 93 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 94 | + | if err != nil { | |
| 95 | + | return nil, target{}, "", err | |
| 96 | + | } | |
| 97 | + | t, ok := d.locate(r.PathValue("rest")) | |
| 98 | + | if !ok { | |
| 99 | + | return nil, target{}, "", errNotFound | |
| 100 | + | } | |
| 101 | + | b, err := s.openBlob(ctx, d.name(), t) | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, target{}, "", err | |
| 104 | + | } | |
| 105 | + | return b, t, d.name(), nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // rawType is the Content-Type of a file and whether it is an attachment. | |
| 109 | + | // b.buf holds the first bytes (up to 512 when the file has that many). | |
| 110 | + | func rawType(b *blobStream) (typ string, attachment bool) { | |
| 111 | + | if img := sniffImage(b.buf); img != "" { | |
| 112 | + | return img, false | |
| 113 | + | } | |
| 114 | + | if !b.info.GetBinary() { | |
| 115 | + | return "text/plain; charset=utf-8", false | |
| 116 | + | } | |
| 117 | + | return "application/octet-stream", true | |
| 118 | + | } | |
A internal/adapters/web/static/css/56-chroma.css +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | /* Syntax colours of the code view. Generated by scripts/chroma-css.go from | |
| 2 | + | chroma's token classes; the colours are the --syn-* properties of | |
| 3 | + | 00-tokens.css (the Despair hues, dark and light), none is defined here. */ | |
| 4 | + | @layer components { | |
| 5 | + | .chroma { color: var(--syn-plain); } | |
| 6 | + | .chroma .c { color: var(--syn-comment); font-style: italic } | |
| 7 | + | .chroma .c1 { color: var(--syn-comment); font-style: italic } | |
| 8 | + | .chroma .ch { color: var(--syn-comment); font-style: italic } | |
| 9 | + | .chroma .cm { color: var(--syn-comment); font-style: italic } | |
| 10 | + | .chroma .cs { color: var(--syn-comment); font-style: italic } | |
| 11 | + | .chroma .dl { color: var(--syn-string); font-style: italic } | |
| 12 | + | .chroma .k { color: var(--syn-keyword); font-weight: bold } | |
| 13 | + | .chroma .kc { color: var(--syn-keyword); font-weight: bold } | |
| 14 | + | .chroma .kd { color: var(--syn-keyword); font-weight: bold } | |
| 15 | + | .chroma .kn { color: var(--syn-keyword); font-weight: bold } | |
| 16 | + | .chroma .kr { color: var(--syn-keyword); font-weight: bold } | |
| 17 | + | .chroma .nc { color: var(--syn-type); font-weight: bold } | |
| 18 | + | .chroma .ne { color: var(--syn-type); font-weight: bold } | |
| 19 | + | .chroma .nn { color: var(--syn-type); font-weight: bold } | |
| 20 | + | .chroma .ow { color: var(--syn-punct); font-weight: bold } | |
| 21 | + | .chroma .s { color: var(--syn-string); font-style: italic } | |
| 22 | + | .chroma .s1 { color: var(--syn-string); font-style: italic } | |
| 23 | + | .chroma .s2 { color: var(--syn-string); font-style: italic } | |
| 24 | + | .chroma .sa { color: var(--syn-string); font-style: italic } | |
| 25 | + | .chroma .sb { color: var(--syn-string); font-style: italic } | |
| 26 | + | .chroma .sc { color: var(--syn-string); font-style: italic } | |
| 27 | + | .chroma .sd { color: var(--syn-string); font-style: italic } | |
| 28 | + | .chroma .se { color: var(--syn-string); font-weight: bold; font-style: italic } | |
| 29 | + | .chroma .sh { color: var(--syn-string); font-style: italic } | |
| 30 | + | .chroma .si { color: var(--syn-string); font-weight: bold; font-style: italic } | |
| 31 | + | .chroma .sr { color: var(--syn-string); font-style: italic } | |
| 32 | + | .chroma .ss { color: var(--syn-string); font-style: italic } | |
| 33 | + | .chroma .sx { color: var(--syn-string); font-style: italic } | |
| 34 | + | .chroma .line { display: block; } | |
| 35 | + | ||
| 36 | + | /* an invisible character in code, shown instead of applied (Trojan Source) */ | |
| 37 | + | .chroma .ctl { | |
| 38 | + | color: var(--red); font-weight: bold; font-style: normal; font-size: var(--fs-s); | |
| 39 | + | outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch; | |
| 40 | + | } | |
| 41 | + | .img-view { margin-top: var(--lh); & img { max-width: 100%; height: auto; } } | |
| 42 | + | } | |
M internal/adapters/web/web.go +2 -0
| @@ -103,6 +103,8 @@ func (s *Server) routes() *http.ServeMux { | |||
| 103 | 103 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | |
| 104 | 104 | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | |
| 105 | 105 | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |
| 106 | + | mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob)) | |
| 107 | + | mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw) | |
| 106 | 108 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 107 | 109 | return mux | |
| 108 | 110 | } | |
A scripts/chroma-css.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | //go:build ignore | |
| 4 | + | ||
| 5 | + | // chroma-css writes internal/adapters/web/static/css/56-chroma.css: the | |
| 6 | + | // chroma token classes mapped to the --syn-* custom properties of the design | |
| 7 | + | // tokens (the Despair hues, dark and light). Colours are not in the file, so | |
| 8 | + | // the theme follows the colour scheme; only bold and italic come from chroma | |
| 9 | + | // (its "bw" style, as in the mockup). | |
| 10 | + | // | |
| 11 | + | // go run scripts/chroma-css.go > internal/adapters/web/static/css/56-chroma.css | |
| 12 | + | package main | |
| 13 | + | ||
| 14 | + | import ( | |
| 15 | + | "fmt" | |
| 16 | + | "os" | |
| 17 | + | "sort" | |
| 18 | + | "strings" | |
| 19 | + | ||
| 20 | + | "github.com/alecthomas/chroma/v2" | |
| 21 | + | "github.com/alecthomas/chroma/v2/formatters/html" | |
| 22 | + | "github.com/alecthomas/chroma/v2/styles" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // synVar maps a token type to the name of a --syn-* property, "" for none. | |
| 26 | + | func synVar(t chroma.TokenType) string { | |
| 27 | + | switch { | |
| 28 | + | case t == chroma.KeywordType, t == chroma.NameClass, t == chroma.NameBuiltin, t == chroma.NameBuiltinPseudo, | |
| 29 | + | t == chroma.NameException, t == chroma.NameNamespace: | |
| 30 | + | return "type" | |
| 31 | + | case t.InCategory(chroma.Keyword): | |
| 32 | + | return "keyword" | |
| 33 | + | case t.InSubCategory(chroma.LiteralString): | |
| 34 | + | return "string" | |
| 35 | + | case t.InCategory(chroma.Comment): | |
| 36 | + | return "comment" | |
| 37 | + | case t.InSubCategory(chroma.LiteralNumber): | |
| 38 | + | return "number" | |
| 39 | + | case t.InSubCategory(chroma.NameFunction), t == chroma.NameDecorator: | |
| 40 | + | return "func" | |
| 41 | + | case t.InCategory(chroma.Punctuation), t.InCategory(chroma.Operator): | |
| 42 | + | return "punct" | |
| 43 | + | } | |
| 44 | + | return "" | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func main() { | |
| 48 | + | // Learn the classes chroma emits, so that every one is covered. | |
| 49 | + | var learned strings.Builder | |
| 50 | + | if err := html.New(html.WithClasses(true)).WriteCSS(&learned, styles.Get("bw")); err != nil { | |
| 51 | + | fmt.Fprintln(os.Stderr, err) | |
| 52 | + | os.Exit(1) | |
| 53 | + | } | |
| 54 | + | types := map[string]chroma.TokenType{} | |
| 55 | + | for t, c := range chroma.StandardTypes { | |
| 56 | + | if c != "" && t > 0 { | |
| 57 | + | types[c] = t | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | bw := styles.Get("bw") | |
| 61 | + | var classes []string | |
| 62 | + | for _, l := range strings.Split(learned.String(), "\n") { | |
| 63 | + | i := strings.Index(l, ".chroma .") | |
| 64 | + | if i < 0 { | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | c := l[i+len(".chroma ."):] | |
| 68 | + | c = c[:strings.IndexAny(c, " {")] | |
| 69 | + | if t, ok := types[c]; ok && synVar(t) != "" { | |
| 70 | + | classes = append(classes, c) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | sort.Strings(classes) | |
| 74 | + | ||
| 75 | + | fmt.Println("/* Syntax colours of the code view. Generated by scripts/chroma-css.go from") | |
| 76 | + | fmt.Println(" chroma's token classes; the colours are the --syn-* properties of") | |
| 77 | + | fmt.Println(" 00-tokens.css (the Despair hues, dark and light), none is defined here. */") | |
| 78 | + | fmt.Println("@layer components {") | |
| 79 | + | fmt.Println(" .chroma { color: var(--syn-plain); }") | |
| 80 | + | for _, c := range classes { | |
| 81 | + | t := types[c] | |
| 82 | + | decl := "color: var(--syn-" + synVar(t) + ")" | |
| 83 | + | e := bw.Get(t) | |
| 84 | + | if e.Bold == chroma.Yes { | |
| 85 | + | decl += "; font-weight: bold" | |
| 86 | + | } | |
| 87 | + | if e.Italic == chroma.Yes { | |
| 88 | + | decl += "; font-style: italic" | |
| 89 | + | } | |
| 90 | + | fmt.Printf(" .chroma .%s { %s }\n", c, decl) | |
| 91 | + | } | |
| 92 | + | fmt.Println(" .chroma .line { display: block; }") | |
| 93 | + | fmt.Println(` | |
| 94 | + | /* an invisible character in code, shown instead of applied (Trojan Source) */ | |
| 95 | + | .chroma .ctl { | |
| 96 | + | color: var(--red); font-weight: bold; font-style: normal; font-size: var(--fs-s); | |
| 97 | + | outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch; | |
| 98 | + | } | |
| 99 | + | .img-view { margin-top: var(--lh); & img { max-width: 100%; height: auto; } } | |
| 100 | + | }`) | |
| 101 | + | } | |